Compare commits
22 Commits
386d8aa2f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7b959893 | ||
|
|
6363709987 | ||
|
|
7764a2946c | ||
|
|
85d1abf61c | ||
|
|
b4e0594901 | ||
|
|
14b95d5639 | ||
|
|
8294bce436 | ||
|
|
f1517963a5 | ||
|
|
d0e99b6483 | ||
|
|
13fc8fe885 | ||
|
|
e8aed49a4c | ||
|
|
87ee6c66c8 | ||
|
|
f6228988e1 | ||
|
|
5c7171acd9 | ||
|
|
06cc5b6725 | ||
|
|
ece575cc73 | ||
|
|
a4099717be | ||
|
|
a37b9ed8a7 | ||
|
|
a4a5b661b9 | ||
|
|
43fbcfb227 | ||
|
|
a6c40451fe | ||
|
|
5fa2709a84 |
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)"
|
||||
],
|
||||
"ask": [
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)"
|
||||
"Skill(update-config:*)",
|
||||
"Bash(git commit)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git push)",
|
||||
"Bash(git push *)"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
|
||||
95
CHANGELOG.md
95
CHANGELOG.md
@@ -1,3 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
## [1.16.0] - 2026-06-28
|
||||
|
||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||
|
||||
## [1.15.2] - 2026-05-28
|
||||
|
||||
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||
|
||||
## [1.15.1] - 2026-05-28
|
||||
|
||||
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||
|
||||
## [1.15.0] - 2026-05-28
|
||||
|
||||
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||
|
||||
|
||||
## [1.14.0] - 2026-05-27
|
||||
|
||||
* Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
|
||||
## [1.13.4] - 2026-05-27
|
||||
|
||||
* Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
|
||||
## [1.13.3] - 2026-03-26
|
||||
|
||||
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
|
||||
12
Makefile
12
Makefile
@@ -2,6 +2,7 @@
|
||||
build build-no-cache build-no-cache-all build-missing \
|
||||
delete-volumes purge \
|
||||
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
||||
lint \
|
||||
setup setup-venv setup-nix
|
||||
|
||||
# Distro
|
||||
@@ -82,6 +83,13 @@ build-no-cache-all:
|
||||
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Lint targets (run on the host, not in a container)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
lint:
|
||||
@bash scripts/lint/python.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Test targets (delegated to scripts/test)
|
||||
# ------------------------------------------------------------
|
||||
@@ -101,8 +109,8 @@ test-env-virtual: build-missing
|
||||
test-env-nix: build-missing
|
||||
@bash scripts/test/test-env-nix.sh
|
||||
|
||||
# Combined test target for local + CI (unit + integration + e2e)
|
||||
test: test-env-virtual test-unit test-integration test-e2e
|
||||
# Combined test target for local + CI (lint + unit + integration + e2e)
|
||||
test: lint test-env-virtual test-unit test-integration test-e2e
|
||||
|
||||
delete-volumes:
|
||||
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "1.13.3";
|
||||
version = "1.16.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=1.13.3
|
||||
pkgver=1.16.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,105 @@
|
||||
package-manager (1.16.0-1) unstable; urgency=medium
|
||||
|
||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 28 Jun 2026 16:54:16 +0200
|
||||
|
||||
package-manager (1.15.2-1) unstable; urgency=medium
|
||||
|
||||
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
|
||||
|
||||
package-manager (1.15.1-1) unstable; urgency=medium
|
||||
|
||||
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
|
||||
|
||||
package-manager (1.15.0-1) unstable; urgency=medium
|
||||
|
||||
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
|
||||
|
||||
package-manager (1.14.0-1) unstable; urgency=medium
|
||||
|
||||
* Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
|
||||
|
||||
package-manager (1.13.4-1) unstable; urgency=medium
|
||||
|
||||
* Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:32:39 +0200
|
||||
|
||||
package-manager (1.13.3-1) unstable; urgency=medium
|
||||
|
||||
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 1.13.3
|
||||
Version: 1.16.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,90 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
||||
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||
- * The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||
|
||||
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
|
||||
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||
|
||||
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
|
||||
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||
|
||||
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
|
||||
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||
|
||||
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
||||
- Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
|
||||
- Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
|
||||
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "1.13.3"
|
||||
version = "1.16.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
15
scripts/lint/python.sh
Executable file
15
scripts/lint/python.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "============================================================"
|
||||
echo ">>> Running RUFF lint on src and tests (local, no container)"
|
||||
echo "============================================================"
|
||||
|
||||
if ! command -v ruff >/dev/null 2>&1; then
|
||||
echo "ruff is not installed or not on PATH." >&2
|
||||
echo "Install it with: pip install ruff" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
ruff --version
|
||||
ruff check src tests
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Top-level pkgmgr package.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# expose subpackages for patch() / resolve_name() friendliness
|
||||
from . import release as release # noqa: F401
|
||||
from . import release as release
|
||||
|
||||
__all__ = ["release"]
|
||||
|
||||
31
src/pkgmgr/actions/archive/__init__.py
Normal file
31
src/pkgmgr/actions/archive/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Archive fully-checked Markdown files into a README index, then delete them.
|
||||
|
||||
The archive action walks a directory for numbered ``NNN-topic.md`` files
|
||||
(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
|
||||
unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
|
||||
the directory README, and deletes the per-file source. Useful for
|
||||
keeping ``docs/requirements/`` (or any other task-tracked spec folder)
|
||||
short and focused on open work.
|
||||
|
||||
The module was extracted from
|
||||
``cli/contributing/requirements/archive`` in infinito-nexus-core so
|
||||
every kpmx-managed repository can rely on the same archival convention
|
||||
without copy-pasting the helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .discovery import iter_archivable_files
|
||||
from .inspect import count_unchecked_items, extract_h1
|
||||
from .readme import existing_archive_entries, merge_archive_section
|
||||
from .workflow import ArchivePlan, run_archive
|
||||
|
||||
__all__ = [
|
||||
"ArchivePlan",
|
||||
"count_unchecked_items",
|
||||
"existing_archive_entries",
|
||||
"extract_h1",
|
||||
"iter_archivable_files",
|
||||
"merge_archive_section",
|
||||
"run_archive",
|
||||
]
|
||||
53
src/pkgmgr/actions/archive/discovery.py
Normal file
53
src/pkgmgr/actions/archive/discovery.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Locate archivable Markdown files under a target directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
||||
TEMPLATE_FILENAME = "000-template.md"
|
||||
|
||||
|
||||
def iter_archivable_files(
|
||||
directory: Path,
|
||||
*,
|
||||
include_template: bool = False,
|
||||
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||
template_filename: str = TEMPLATE_FILENAME,
|
||||
) -> list[Path]:
|
||||
"""Return all files in *directory* whose name matches *pattern*, sorted.
|
||||
|
||||
``000-template.md`` (or whatever *template_filename* matches) is
|
||||
excluded unless *include_template* is true. The check is filename
|
||||
based; nested directories are not traversed.
|
||||
"""
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for path in sorted(directory.iterdir()):
|
||||
if not path.is_file() or not pattern.match(path.name):
|
||||
continue
|
||||
if not include_template and path.name == template_filename:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def filter_archivable_files(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
include_template: bool = False,
|
||||
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||
template_filename: str = TEMPLATE_FILENAME,
|
||||
) -> list[Path]:
|
||||
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
|
||||
result: list[Path] = []
|
||||
for path in paths:
|
||||
if not path.is_file() or not pattern.match(path.name):
|
||||
continue
|
||||
if not include_template and path.name == template_filename:
|
||||
continue
|
||||
result.append(path)
|
||||
return result
|
||||
35
src/pkgmgr/actions/archive/inspect.py
Normal file
35
src/pkgmgr/actions/archive/inspect.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Parse a single Markdown file: H1 heading and task-list completeness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
H1_RE = re.compile(r"^#\s+(?P<title>\S.*?)\s*$")
|
||||
UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
|
||||
|
||||
|
||||
def extract_h1(path: Path) -> str | None:
|
||||
"""Return the first H1 title in *path* or ``None`` if there is none."""
|
||||
try:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
match = H1_RE.match(line.rstrip("\n"))
|
||||
if match:
|
||||
return match.group("title")
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def count_unchecked_items(path: Path) -> int:
|
||||
"""Return the number of ``- [ ]`` task-list markers anywhere in *path*.
|
||||
|
||||
A non-zero count means the file is not yet fully complete and MUST
|
||||
NOT be archived.
|
||||
"""
|
||||
try:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
|
||||
except OSError:
|
||||
return 0
|
||||
76
src/pkgmgr/actions/archive/readme.py
Normal file
76
src/pkgmgr/actions/archive/readme.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Read and update the ``## Archive`` section of a directory README."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ARCHIVE_HEADING = "## Archive"
|
||||
LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P<body>\S.*)$")
|
||||
|
||||
|
||||
def existing_archive_entries(readme_text: str) -> set[str]:
|
||||
"""Return the deduplicated set of list-item bodies under ``## Archive``."""
|
||||
lines = readme_text.splitlines()
|
||||
in_archive = False
|
||||
entries: set[str] = set()
|
||||
for line in lines:
|
||||
stripped = line.rstrip()
|
||||
if stripped == ARCHIVE_HEADING:
|
||||
in_archive = True
|
||||
continue
|
||||
if in_archive and stripped.startswith("## "):
|
||||
break
|
||||
if not in_archive:
|
||||
continue
|
||||
match = LIST_ITEM_RE.match(stripped)
|
||||
if match:
|
||||
entries.add(match.group("body").strip())
|
||||
return entries
|
||||
|
||||
|
||||
def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
|
||||
"""Return ``readme_text`` with *new_entries* appended under ``## Archive``.
|
||||
|
||||
Existing entries are preserved verbatim. If the section is missing it
|
||||
is created at the end of the document.
|
||||
"""
|
||||
if not new_entries:
|
||||
return readme_text
|
||||
|
||||
lines = readme_text.splitlines()
|
||||
archive_index = next(
|
||||
(i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
|
||||
None,
|
||||
)
|
||||
|
||||
if archive_index is None:
|
||||
suffix = [""] if (lines and lines[-1] != "") else []
|
||||
suffix.append(ARCHIVE_HEADING)
|
||||
suffix.append("")
|
||||
suffix.extend(f"- {entry}" for entry in new_entries)
|
||||
merged = lines + suffix
|
||||
return "\n".join(merged) + "\n"
|
||||
|
||||
section_end = next(
|
||||
(i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
|
||||
len(lines),
|
||||
)
|
||||
|
||||
body_start = archive_index + 1
|
||||
while body_start < section_end and not lines[body_start].strip():
|
||||
body_start += 1
|
||||
|
||||
last_item = body_start - 1
|
||||
for i in range(body_start, section_end):
|
||||
if LIST_ITEM_RE.match(lines[i]):
|
||||
last_item = i
|
||||
|
||||
insertion_point = (last_item + 1) if last_item >= body_start else body_start
|
||||
if insertion_point == body_start and body_start == archive_index + 1:
|
||||
new_block = ["", *[f"- {entry}" for entry in new_entries]]
|
||||
else:
|
||||
new_block = [f"- {entry}" for entry in new_entries]
|
||||
|
||||
merged = lines[:insertion_point] + new_block + lines[insertion_point:]
|
||||
trailing = "\n" if readme_text.endswith("\n") else ""
|
||||
return "\n".join(merged) + trailing
|
||||
114
src/pkgmgr/actions/archive/workflow.py
Normal file
114
src/pkgmgr/actions/archive/workflow.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Orchestrator for archiving fully-checked Markdown files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .discovery import iter_archivable_files
|
||||
from .inspect import count_unchecked_items, extract_h1
|
||||
from .readme import existing_archive_entries, merge_archive_section
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchivePlan:
|
||||
"""Outcome of an archive analysis run.
|
||||
|
||||
Attributes:
|
||||
archived: ``(source_path, title)`` for every file that was (or
|
||||
would be) archived. Order matches the original directory
|
||||
listing.
|
||||
skipped_incomplete: ``(source_path, unchecked_count)`` for files
|
||||
that still hold ``- [ ]`` markers.
|
||||
skipped_without_h1: files that had no H1 heading to use as title.
|
||||
new_entries: titles that will be appended to the README index.
|
||||
existing_entries: titles already present in the README index.
|
||||
"""
|
||||
|
||||
archived: list[tuple[Path, str]]
|
||||
skipped_incomplete: list[tuple[Path, int]]
|
||||
skipped_without_h1: list[Path]
|
||||
new_entries: list[str]
|
||||
existing_entries: set[str]
|
||||
|
||||
|
||||
def _bucket_files(
|
||||
files: list[Path],
|
||||
) -> tuple[
|
||||
list[tuple[Path, str]],
|
||||
list[tuple[Path, int]],
|
||||
list[Path],
|
||||
]:
|
||||
plan: list[tuple[Path, str]] = []
|
||||
skipped_incomplete: list[tuple[Path, int]] = []
|
||||
skipped_without_h1: list[Path] = []
|
||||
for path in files:
|
||||
unchecked = count_unchecked_items(path)
|
||||
if unchecked > 0:
|
||||
skipped_incomplete.append((path, unchecked))
|
||||
continue
|
||||
title = extract_h1(path)
|
||||
if title is None:
|
||||
skipped_without_h1.append(path)
|
||||
continue
|
||||
plan.append((path, title))
|
||||
return plan, skipped_incomplete, skipped_without_h1
|
||||
|
||||
|
||||
def _dedupe_titles(
|
||||
plan: list[tuple[Path, str]], already_archived: set[str]
|
||||
) -> list[str]:
|
||||
new_entries: list[str] = []
|
||||
for _path, title in plan:
|
||||
if title in already_archived or title in new_entries:
|
||||
continue
|
||||
new_entries.append(title)
|
||||
return new_entries
|
||||
|
||||
|
||||
def run_archive(
|
||||
directory: Path,
|
||||
readme_path: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
include_template: bool = False,
|
||||
) -> ArchivePlan:
|
||||
"""Walk *directory* and archive every fully-checked file into *readme_path*.
|
||||
|
||||
Returns an :class:`ArchivePlan` describing the outcome. When
|
||||
``dry_run`` is true no files are deleted and the README is not
|
||||
rewritten — the plan still reflects what *would* happen.
|
||||
|
||||
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
|
||||
not exist.
|
||||
"""
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f"Archive directory not found: {directory}")
|
||||
if not readme_path.is_file():
|
||||
raise FileNotFoundError(f"README not found: {readme_path}")
|
||||
|
||||
files = iter_archivable_files(directory, include_template=include_template)
|
||||
readme_text = readme_path.read_text(encoding="utf-8")
|
||||
already_archived = existing_archive_entries(readme_text)
|
||||
|
||||
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
|
||||
new_entries = _dedupe_titles(archived, already_archived)
|
||||
|
||||
if not dry_run and new_entries:
|
||||
merged_text = merge_archive_section(readme_text, new_entries)
|
||||
if merged_text != readme_text:
|
||||
readme_path.write_text(merged_text, encoding="utf-8")
|
||||
|
||||
if not dry_run:
|
||||
for path, _title in archived:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
path.unlink()
|
||||
|
||||
return ArchivePlan(
|
||||
archived=archived,
|
||||
skipped_incomplete=skipped_incomplete,
|
||||
skipped_without_h1=skipped_without_h1,
|
||||
new_entries=new_entries,
|
||||
existing_entries=already_archived,
|
||||
)
|
||||
@@ -1,14 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Public API for branch actions.
|
||||
"""
|
||||
|
||||
from .open_branch import open_branch
|
||||
from .close_branch import close_branch
|
||||
from .drop_branch import drop_branch
|
||||
from .open_branch import open_branch
|
||||
|
||||
__all__ = [
|
||||
"open_branch",
|
||||
"close_branch",
|
||||
"drop_branch",
|
||||
"open_branch",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitDeleteRemoteBranchError,
|
||||
checkout,
|
||||
@@ -14,12 +10,12 @@ from pkgmgr.core.git.commands import (
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
|
||||
from pkgmgr.core.git.queries import resolve_base_branch
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||
|
||||
|
||||
def close_branch(
|
||||
name: Optional[str],
|
||||
name: str | None,
|
||||
base_branch: str = "main",
|
||||
fallback_base: str = "master",
|
||||
cwd: str = ".",
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitDeleteRemoteBranchError,
|
||||
delete_local_branch,
|
||||
delete_remote_branch,
|
||||
)
|
||||
|
||||
from pkgmgr.core.git.queries import resolve_base_branch
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||
|
||||
|
||||
def drop_branch(
|
||||
name: Optional[str],
|
||||
name: str | None,
|
||||
base_branch: str = "main",
|
||||
fallback_base: str = "master",
|
||||
cwd: str = ".",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git.commands import (
|
||||
checkout,
|
||||
create_branch,
|
||||
@@ -13,7 +11,7 @@ from pkgmgr.core.git.queries import resolve_base_branch
|
||||
|
||||
|
||||
def open_branch(
|
||||
name: Optional[str],
|
||||
name: str | None,
|
||||
base_branch: str = "main",
|
||||
fallback_base: str = "master",
|
||||
cwd: str = ".",
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Helpers to generate changelog information from Git history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git.queries import (
|
||||
get_changelog,
|
||||
GitChangelogQueryError,
|
||||
get_changelog,
|
||||
)
|
||||
|
||||
|
||||
def generate_changelog(
|
||||
cwd: str,
|
||||
from_ref: Optional[str] = None,
|
||||
to_ref: Optional[str] = None,
|
||||
from_ref: str | None = None,
|
||||
to_ref: str | None = None,
|
||||
include_merges: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
|
||||
161
src/pkgmgr/actions/code_scanning/__init__.py
Normal file
161
src/pkgmgr/actions/code_scanning/__init__.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Download GitHub code scanning results via the ``gh`` CLI.
|
||||
|
||||
Fetches all code scanning alerts (and the analysis metadata) for a
|
||||
repository and writes them, plus a readable digest, into a timestamped
|
||||
directory so they can be read and analysed offline. Authentication is
|
||||
delegated to ``gh`` (credentials from its keyring/login).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CodeScanningError(RuntimeError):
|
||||
"""Raised when code scanning results cannot be downloaded."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeScanningResult:
|
||||
repo: str
|
||||
output_dir: str
|
||||
alert_count: int
|
||||
files: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _gh(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
|
||||
|
||||
|
||||
def _resolve_repo(repo: str | None) -> str:
|
||||
if repo:
|
||||
return repo
|
||||
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
|
||||
name = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not name:
|
||||
raise CodeScanningError(
|
||||
"could not resolve the current repository; run inside a GitHub "
|
||||
"repo or pass --repo OWNER/REPO "
|
||||
f"({proc.stderr.strip()})"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _fetch_json(endpoint: str, params: list[str] | None = None) -> Any:
|
||||
args = ["api", endpoint, "--paginate"]
|
||||
for param in params or []:
|
||||
args += ["-f", param]
|
||||
proc = _gh(args)
|
||||
if proc.returncode != 0:
|
||||
raise CodeScanningError(
|
||||
f"gh api {endpoint} failed: {proc.stderr.strip() or 'unknown error'}"
|
||||
)
|
||||
body = proc.stdout.strip()
|
||||
return json.loads(body) if body else []
|
||||
|
||||
|
||||
def _alert_row(alert: dict) -> str:
|
||||
rule = alert.get("rule") or {}
|
||||
instance = alert.get("most_recent_instance") or {}
|
||||
location = instance.get("location") or {}
|
||||
message = (instance.get("message") or {}).get("text", "").strip().replace("\n", " ")
|
||||
severity = rule.get("security_severity_level") or rule.get("severity") or "unknown"
|
||||
path = location.get("path", "?")
|
||||
line = location.get("start_line", "?")
|
||||
state = alert.get("state", "?")
|
||||
rule_id = rule.get("id", "?")
|
||||
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
|
||||
|
||||
|
||||
def _build_summary(repo: str, generated_at: str, alerts: list[dict]) -> str:
|
||||
by_severity: Counter = Counter()
|
||||
by_state: Counter = Counter()
|
||||
by_rule: Counter = Counter()
|
||||
for alert in alerts:
|
||||
rule = alert.get("rule") or {}
|
||||
by_severity[
|
||||
rule.get("security_severity_level") or rule.get("severity") or "unknown"
|
||||
] += 1
|
||||
by_state[alert.get("state", "unknown")] += 1
|
||||
by_rule[rule.get("id", "unknown")] += 1
|
||||
|
||||
lines = [
|
||||
f"# Code scanning summary — {repo}",
|
||||
"",
|
||||
f"- Generated: {generated_at}",
|
||||
f"- Total alerts: {len(alerts)}",
|
||||
"",
|
||||
"## By severity",
|
||||
"",
|
||||
]
|
||||
lines += [f"- {sev}: {count}" for sev, count in by_severity.most_common()] or [
|
||||
"- none"
|
||||
]
|
||||
lines += ["", "## By state", ""]
|
||||
lines += [f"- {state}: {count}" for state, count in by_state.most_common()] or [
|
||||
"- none"
|
||||
]
|
||||
lines += ["", "## By rule", ""]
|
||||
lines += [f"- {rule}: {count}" for rule, count in by_rule.most_common()] or [
|
||||
"- none"
|
||||
]
|
||||
lines += ["", "## Alerts", ""]
|
||||
lines += [_alert_row(alert) for alert in alerts] or ["- none"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def download_code_scanning(
|
||||
repo: str | None = None,
|
||||
output_dir: str | None = None,
|
||||
state: str | None = None,
|
||||
) -> CodeScanningResult:
|
||||
if not shutil.which("gh"):
|
||||
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
|
||||
|
||||
repo = _resolve_repo(repo)
|
||||
repo_name = repo.rstrip("/").split("/")[-1]
|
||||
|
||||
if output_dir is None:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
|
||||
output_dir = os.path.abspath(os.path.expanduser(output_dir))
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
alerts = _fetch_json(
|
||||
f"repos/{repo}/code-scanning/alerts",
|
||||
params=[f"state={state}"] if state else None,
|
||||
)
|
||||
|
||||
files: list[str] = []
|
||||
|
||||
def _write(name: str, content: str) -> None:
|
||||
path = os.path.join(output_dir, name)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
files.append(path)
|
||||
|
||||
_write("alerts.json", json.dumps(alerts, indent=2, ensure_ascii=False) + "\n")
|
||||
_write("summary.md", _build_summary(repo, generated_at, alerts))
|
||||
|
||||
try:
|
||||
analyses = _fetch_json(f"repos/{repo}/code-scanning/analyses")
|
||||
_write(
|
||||
"analyses.json", json.dumps(analyses, indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
except CodeScanningError as exc:
|
||||
_write("analyses.json", json.dumps({"error": str(exc)}, indent=2) + "\n")
|
||||
|
||||
return CodeScanningResult(
|
||||
repo=repo,
|
||||
output_dir=output_dir,
|
||||
alert_count=len(alerts),
|
||||
files=files,
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import yaml
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
|
||||
|
||||
@@ -28,7 +30,7 @@ def interactive_add(config, USER_CONFIG_PATH: str):
|
||||
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
||||
if confirm == "y":
|
||||
if os.path.exists(USER_CONFIG_PATH):
|
||||
with open(USER_CONFIG_PATH, "r") as f:
|
||||
with open(USER_CONFIG_PATH) as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
else:
|
||||
user_config = {"repositories": []}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Initialize user configuration by scanning the repositories base directory.
|
||||
|
||||
@@ -23,7 +20,7 @@ For each discovered repository, the function:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.core.command.alias import generate_alias
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
@@ -31,8 +28,8 @@ from pkgmgr.core.git.queries import get_latest_commit
|
||||
|
||||
|
||||
def config_init(
|
||||
user_config: Dict[str, Any],
|
||||
defaults_config: Dict[str, Any],
|
||||
user_config: dict[str, Any],
|
||||
defaults_config: dict[str, Any],
|
||||
bin_dir: str,
|
||||
user_config_path: str,
|
||||
) -> None:
|
||||
@@ -55,7 +52,7 @@ def config_init(
|
||||
|
||||
print("[INIT] Scanning repository base directory:")
|
||||
print(f" {repositories_base_dir}")
|
||||
print("")
|
||||
print()
|
||||
|
||||
if not os.path.isdir(repositories_base_dir):
|
||||
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
|
||||
@@ -129,7 +126,7 @@ def config_init(
|
||||
"[WARN] Could not read commit (not a git repo or no commits)."
|
||||
)
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
entry: dict[str, Any] = {
|
||||
"provider": provider,
|
||||
"account": account,
|
||||
"repository": repo_name,
|
||||
@@ -153,7 +150,7 @@ def config_init(
|
||||
|
||||
new_entries.append(entry)
|
||||
|
||||
print("") # blank line between accounts
|
||||
print() # blank line between accounts
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Summary
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import yaml
|
||||
|
||||
from pkgmgr.core.config.load import load_config
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# src/pkgmgr/actions/install/__init__.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
High-level entry point for repository installation.
|
||||
@@ -16,28 +14,28 @@ Responsibilities:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.makefile import (
|
||||
MakefileInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.nix import (
|
||||
NixFlakeInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.os_packages import (
|
||||
ArchPkgbuildInstaller,
|
||||
DebianControlInstaller,
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.nix import (
|
||||
NixFlakeInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.install.installers.makefile import (
|
||||
MakefileInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
INSTALLERS = [
|
||||
ArchPkgbuildInstaller(),
|
||||
@@ -52,12 +50,12 @@ INSTALLERS = [
|
||||
def _ensure_repo_dir(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool,
|
||||
no_verification: bool,
|
||||
clone_mode: str,
|
||||
identifier: str,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""
|
||||
Compute and, if necessary, clone the repository directory.
|
||||
|
||||
@@ -127,7 +125,7 @@ def _create_context(
|
||||
repo_dir: str,
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
@@ -155,10 +153,10 @@ def _create_context(
|
||||
|
||||
|
||||
def install_repos(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
@@ -179,7 +177,7 @@ def install_repos(
|
||||
overall command never exits non-zero because of per-repository failures.
|
||||
"""
|
||||
pipeline = InstallationPipeline(INSTALLERS)
|
||||
failures: List[Tuple[str, str]] = []
|
||||
failures: list[tuple[str, str]] = []
|
||||
|
||||
for repo in selected_repos:
|
||||
identifier = get_repo_identifier(repo, all_repos)
|
||||
@@ -232,7 +230,7 @@ def install_repos(
|
||||
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
|
||||
failures.append((identifier, f"unexpected error: {exc}"))
|
||||
if not quiet:
|
||||
print(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Capability detection for pkgmgr.
|
||||
|
||||
@@ -35,7 +32,8 @@ from __future__ import annotations
|
||||
import glob
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Iterable, TYPE_CHECKING, Optional
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
@@ -46,12 +44,12 @@ if TYPE_CHECKING:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_text_if_exists(path: str) -> Optional[str]:
|
||||
def _read_text_if_exists(path: str) -> str | None:
|
||||
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
@@ -75,12 +73,12 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
|
||||
return False
|
||||
|
||||
|
||||
def _first_spec_file(repo_dir: str) -> Optional[str]:
|
||||
def _first_spec_file(repo_dir: str) -> str | None:
|
||||
"""Return the first *.spec file in repo_dir, if any."""
|
||||
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
||||
if not matches:
|
||||
return None
|
||||
return sorted(matches)[0]
|
||||
return min(matches)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,7 +98,7 @@ class CapabilityMatcher(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
||||
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||
"""
|
||||
Return True if this capability is actually provided by the given layer
|
||||
for this repository.
|
||||
@@ -133,7 +131,7 @@ class PythonRuntimeCapability(CapabilityMatcher):
|
||||
# OS packages may wrap Python builds, but must explicitly prove it
|
||||
return layer in {"python", "nix", "os-packages"}
|
||||
|
||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
||||
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||
repo_dir = ctx.repo_dir
|
||||
|
||||
if layer == "python":
|
||||
@@ -208,7 +206,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
||||
def applies_to_layer(self, layer: str) -> bool:
|
||||
return layer in {"makefile", "python", "nix", "os-packages"}
|
||||
|
||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
||||
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||
repo_dir = ctx.repo_dir
|
||||
|
||||
if layer == "makefile":
|
||||
@@ -216,7 +214,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
||||
if not os.path.exists(makefile):
|
||||
return False
|
||||
try:
|
||||
with open(makefile, "r", encoding="utf-8") as f:
|
||||
with open(makefile, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith("install:"):
|
||||
return True
|
||||
@@ -274,7 +272,7 @@ class NixFlakeCapability(CapabilityMatcher):
|
||||
# Only Nix itself and OS packages that explicitly wrap Nix
|
||||
return layer in {"nix", "os-packages"}
|
||||
|
||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
||||
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||
repo_dir = ctx.repo_dir
|
||||
|
||||
if layer == "nix":
|
||||
@@ -328,7 +326,7 @@ LAYER_ORDER: list[str] = [
|
||||
|
||||
|
||||
def detect_capabilities(
|
||||
ctx: "RepoContext",
|
||||
ctx: RepoContext,
|
||||
layers: Iterable[str],
|
||||
) -> dict[str, set[str]]:
|
||||
"""
|
||||
@@ -359,8 +357,8 @@ def detect_capabilities(
|
||||
|
||||
|
||||
def resolve_effective_capabilities(
|
||||
ctx: "RepoContext",
|
||||
layers: Optional[Iterable[str]] = None,
|
||||
ctx: RepoContext,
|
||||
layers: Iterable[str] | None = None,
|
||||
) -> dict[str, set[str]]:
|
||||
"""
|
||||
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
||||
@@ -381,10 +379,7 @@ def resolve_effective_capabilities(
|
||||
This means *any* higher layer can overshadow a lower layer, not just
|
||||
a specific one like Nix. The resolver is completely generic.
|
||||
"""
|
||||
if layers is None:
|
||||
layers_list = list(LAYER_ORDER)
|
||||
else:
|
||||
layers_list = list(layers)
|
||||
layers_list = list(LAYER_ORDER) if layers is None else list(layers)
|
||||
|
||||
raw_caps = detect_capabilities(ctx, layers_list)
|
||||
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# src/pkgmgr/actions/install/context.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Shared context object for repository installation steps.
|
||||
@@ -10,19 +8,19 @@ they do not depend on global state or long parameter lists.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoContext:
|
||||
"""Container for all repository-related data used during installation."""
|
||||
|
||||
repo: Dict[str, Any]
|
||||
repo: dict[str, Any]
|
||||
identifier: str
|
||||
repo_dir: str
|
||||
repositories_base_dir: str
|
||||
bin_dir: str
|
||||
all_repos: List[Dict[str, Any]]
|
||||
all_repos: list[dict[str, Any]]
|
||||
|
||||
no_verification: bool
|
||||
preview: bool
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
@@ -9,15 +6,17 @@ pkgmgr.actions.install.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
||||
) # noqa: F401
|
||||
)
|
||||
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
||||
DebianControlInstaller as DebianControlInstaller,
|
||||
) # noqa: F401
|
||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
)
|
||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
|
||||
RpmSpecInstaller, # noqa: F401
|
||||
)
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Base interface for all installer components in the pkgmgr installation pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Set, Optional
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
|
||||
|
||||
class BaseInstaller(ABC):
|
||||
@@ -24,9 +21,9 @@ class BaseInstaller(ABC):
|
||||
# Examples: "nix", "python", "makefile".
|
||||
# This is used by capability matchers to decide which patterns to
|
||||
# search for in the repository.
|
||||
layer: Optional[str] = None
|
||||
layer: str | None = None
|
||||
|
||||
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
||||
def discover_capabilities(self, ctx: RepoContext) -> set[str]:
|
||||
"""
|
||||
Determine which logical capabilities this installer will provide
|
||||
for this specific repository instance.
|
||||
@@ -36,7 +33,7 @@ class BaseInstaller(ABC):
|
||||
Makefile, etc.) and decide, via string matching, whether a given
|
||||
capability is actually provided by this layer.
|
||||
"""
|
||||
caps: Set[str] = set()
|
||||
caps: set[str] = set()
|
||||
if not self.layer:
|
||||
return caps
|
||||
|
||||
|
||||
@@ -26,16 +26,14 @@ class MakefileInstaller(BaseInstaller):
|
||||
|
||||
def _has_install_target(self, makefile_path: str) -> bool:
|
||||
try:
|
||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
with open(makefile_path, encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
||||
return True
|
||||
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
||||
return True
|
||||
return False
|
||||
return bool(re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE))
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .profile import NixProfileInspector
|
||||
from .retry import GitHubRateLimitRetry
|
||||
@@ -33,7 +33,7 @@ class NixConflictResolver:
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
ctx: RepoContext,
|
||||
install_cmd: str,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
@@ -49,7 +49,7 @@ class NixConflictResolver:
|
||||
store_prefixes = self._parser.existing_store_prefixes(combined)
|
||||
|
||||
# 2) Resolve them to concrete remove tokens
|
||||
tokens: List[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
||||
tokens: list[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
||||
ctx,
|
||||
self._runner,
|
||||
store_prefixes,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
|
||||
@@ -28,7 +28,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
|
||||
self._indices_supported: bool | None = None
|
||||
|
||||
def supports(self, ctx: "RepoContext") -> bool:
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
@@ -42,13 +42,13 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
|
||||
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
||||
|
||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
||||
def _profile_outputs(self, ctx: RepoContext) -> list[tuple[str, bool]]:
|
||||
# (output_name, allow_failure)
|
||||
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
||||
return [("pkgmgr", False), ("default", True)]
|
||||
return [("default", False)]
|
||||
|
||||
def run(self, ctx: "RepoContext") -> None:
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
if not self.supports(ctx):
|
||||
return
|
||||
|
||||
@@ -68,7 +68,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
else:
|
||||
self._install_only(ctx, output, allow_failure)
|
||||
|
||||
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
||||
def _installable(self, ctx: RepoContext, output: str) -> str:
|
||||
return f"{ctx.repo_dir}#{output}"
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@@ -76,7 +76,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _install_only(
|
||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
||||
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||
) -> None:
|
||||
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
||||
|
||||
@@ -162,7 +162,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _force_upgrade_output(
|
||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
||||
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||
) -> None:
|
||||
# Prefer token path if indices unsupported (new nix)
|
||||
if self._indices_supported is False:
|
||||
@@ -215,7 +215,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
s = (stderr or "").lower()
|
||||
return "no longer supports indices" in s or "does not support indices" in s
|
||||
|
||||
def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
|
||||
def _upgrade_index(self, ctx: RepoContext, idx: int) -> bool:
|
||||
cmd = f"nix profile upgrade --refresh {idx}"
|
||||
res = self._runner.run(ctx, cmd, allow_failure=True)
|
||||
|
||||
@@ -228,7 +228,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
|
||||
return res.returncode == 0
|
||||
|
||||
def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
|
||||
def _remove_index(self, ctx: RepoContext, idx: int) -> None:
|
||||
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
||||
|
||||
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
|
||||
@@ -237,7 +237,7 @@ class NixFlakeInstaller(BaseInstaller):
|
||||
if self._indices_supported is None:
|
||||
self._indices_supported = True
|
||||
|
||||
def _remove_tokens_for_output(self, ctx: "RepoContext", output: str) -> None:
|
||||
def _remove_tokens_for_output(self, ctx: RepoContext, output: str) -> None:
|
||||
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .inspector import NixProfileInspector
|
||||
from .models import NixProfileEntry
|
||||
|
||||
__all__ = ["NixProfileInspector", "NixProfileEntry"]
|
||||
__all__ = ["NixProfileEntry", "NixProfileInspector"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .matcher import (
|
||||
entry_matches_output,
|
||||
@@ -29,7 +29,7 @@ class NixProfileInspector:
|
||||
- find_remove_tokens_for_store_prefixes()
|
||||
"""
|
||||
|
||||
def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
|
||||
def list_json(self, ctx: RepoContext, runner: CommandRunner) -> dict[str, Any]:
|
||||
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
|
||||
raw = extract_stdout_text(res)
|
||||
return parse_profile_list_json(raw)
|
||||
@@ -40,14 +40,14 @@ class NixProfileInspector:
|
||||
|
||||
def find_installed_indices_for_output(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
runner: "CommandRunner",
|
||||
ctx: RepoContext,
|
||||
runner: CommandRunner,
|
||||
output: str,
|
||||
) -> List[int]:
|
||||
) -> list[int]:
|
||||
data = self.list_json(ctx, runner)
|
||||
entries = normalize_elements(data)
|
||||
|
||||
hits: List[int] = []
|
||||
hits: list[int] = []
|
||||
for e in entries:
|
||||
if e.index is None:
|
||||
continue
|
||||
@@ -58,10 +58,10 @@ class NixProfileInspector:
|
||||
|
||||
def find_indices_by_store_path(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
runner: "CommandRunner",
|
||||
ctx: RepoContext,
|
||||
runner: CommandRunner,
|
||||
store_path: str,
|
||||
) -> List[int]:
|
||||
) -> list[int]:
|
||||
needle = (store_path or "").strip()
|
||||
if not needle:
|
||||
return []
|
||||
@@ -69,7 +69,7 @@ class NixProfileInspector:
|
||||
data = self.list_json(ctx, runner)
|
||||
entries = normalize_elements(data)
|
||||
|
||||
hits: List[int] = []
|
||||
hits: list[int] = []
|
||||
for e in entries:
|
||||
if e.index is None:
|
||||
continue
|
||||
@@ -84,10 +84,10 @@ class NixProfileInspector:
|
||||
|
||||
def find_remove_tokens_for_output(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
runner: "CommandRunner",
|
||||
ctx: RepoContext,
|
||||
runner: CommandRunner,
|
||||
output: str,
|
||||
) -> List[str]:
|
||||
) -> list[str]:
|
||||
"""
|
||||
Returns profile remove tokens to remove entries matching a given output.
|
||||
|
||||
@@ -101,7 +101,7 @@ class NixProfileInspector:
|
||||
data = self.list_json(ctx, runner)
|
||||
entries = normalize_elements(data)
|
||||
|
||||
tokens: List[str] = [
|
||||
tokens: list[str] = [
|
||||
out
|
||||
] # critical: matches nix's own suggestion for conflicts
|
||||
|
||||
@@ -119,7 +119,7 @@ class NixProfileInspector:
|
||||
|
||||
# stable unique preserving order
|
||||
seen: set[str] = set()
|
||||
uniq: List[str] = []
|
||||
uniq: list[str] = []
|
||||
for t in tokens:
|
||||
if t and t not in seen:
|
||||
uniq.append(t)
|
||||
@@ -128,10 +128,10 @@ class NixProfileInspector:
|
||||
|
||||
def find_remove_tokens_for_store_prefixes(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
runner: "CommandRunner",
|
||||
prefixes: List[str],
|
||||
) -> List[str]:
|
||||
ctx: RepoContext,
|
||||
runner: CommandRunner,
|
||||
prefixes: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Returns remove tokens for entries whose store path matches any prefix.
|
||||
"""
|
||||
@@ -143,7 +143,7 @@ class NixProfileInspector:
|
||||
data = self.list_json(ctx, runner)
|
||||
entries = normalize_elements(data)
|
||||
|
||||
tokens: List[str] = []
|
||||
tokens: list[str] = []
|
||||
for e in entries:
|
||||
if not e.store_paths:
|
||||
continue
|
||||
@@ -156,7 +156,7 @@ class NixProfileInspector:
|
||||
tokens.append(n)
|
||||
|
||||
seen: set[str] = set()
|
||||
uniq: List[str] = []
|
||||
uniq: list[str] = []
|
||||
for t in tokens:
|
||||
if t and t not in seen:
|
||||
uniq.append(t)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .models import NixProfileEntry
|
||||
|
||||
|
||||
@@ -51,9 +49,9 @@ def entry_matches_store_path(entry: NixProfileEntry, store_path: str) -> bool:
|
||||
return any((p or "") == needle for p in entry.store_paths)
|
||||
|
||||
|
||||
def stable_unique_ints(values: List[int]) -> List[int]:
|
||||
def stable_unique_ints(values: list[int]) -> list[int]:
|
||||
seen: set[int] = set()
|
||||
uniq: List[int] = []
|
||||
uniq: list[int] = []
|
||||
for v in values:
|
||||
if v in seen:
|
||||
continue
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -11,7 +10,7 @@ class NixProfileEntry:
|
||||
"""
|
||||
|
||||
key: str
|
||||
index: Optional[int]
|
||||
index: int | None
|
||||
name: str
|
||||
attr_path: str
|
||||
store_paths: List[str]
|
||||
store_paths: list[str]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from .models import NixProfileEntry
|
||||
|
||||
|
||||
def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
||||
def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
|
||||
"""
|
||||
Nix JSON schema varies:
|
||||
- elements keys might be "0", "1", ...
|
||||
@@ -20,7 +21,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
||||
if k.isdigit():
|
||||
try:
|
||||
return int(k)
|
||||
except Exception:
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# 2) Explicit index fields (schema-dependent)
|
||||
@@ -31,7 +32,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
||||
if isinstance(v, str) and v.strip().isdigit():
|
||||
try:
|
||||
return int(v.strip())
|
||||
except Exception:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
|
||||
@@ -39,13 +40,13 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
||||
def iter_store_paths(entry: dict[str, Any]) -> Iterable[str]:
|
||||
"""
|
||||
Yield all possible store paths from a nix profile JSON entry.
|
||||
|
||||
@@ -72,7 +73,7 @@ def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
||||
|
||||
outs = entry.get("outputs")
|
||||
if isinstance(outs, dict):
|
||||
for _, ov in outs.items():
|
||||
for ov in outs.values():
|
||||
if isinstance(ov, dict):
|
||||
p = ov.get("storePath")
|
||||
if isinstance(p, str):
|
||||
@@ -87,7 +88,7 @@ def normalize_store_path(store_path: str) -> str:
|
||||
return (store_path or "").strip()
|
||||
|
||||
|
||||
def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
||||
def normalize_elements(data: dict[str, Any]) -> list[NixProfileEntry]:
|
||||
"""
|
||||
Converts nix profile list JSON into a list of normalized entries.
|
||||
|
||||
@@ -99,7 +100,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
||||
if not isinstance(elements, dict):
|
||||
return []
|
||||
|
||||
normalized: List[NixProfileEntry] = []
|
||||
normalized: list[NixProfileEntry] = []
|
||||
|
||||
for k, entry in elements.items():
|
||||
if not isinstance(entry, dict):
|
||||
@@ -109,7 +110,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
||||
name = str(entry.get("name", "") or "")
|
||||
attr = str(entry.get("attrPath", "") or "")
|
||||
|
||||
store_paths: List[str] = []
|
||||
store_paths: list[str] = []
|
||||
for p in iter_store_paths(entry):
|
||||
sp = normalize_store_path(p)
|
||||
if sp:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_profile_list_json(raw: str) -> Dict[str, Any]:
|
||||
def parse_profile_list_json(raw: str) -> dict[str, Any]:
|
||||
"""
|
||||
Parse JSON output from `nix profile list --json`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .runner import CommandRunner
|
||||
|
||||
@@ -19,12 +19,12 @@ class NixProfileListReader:
|
||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||
return m.group(1) if m else raw
|
||||
|
||||
def entries(self, ctx: "RepoContext") -> List[Tuple[int, str]]:
|
||||
def entries(self, ctx: RepoContext) -> list[tuple[int, str]]:
|
||||
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
entries: List[Tuple[int, str]] = []
|
||||
entries: list[tuple[int, str]] = []
|
||||
pat = re.compile(
|
||||
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
|
||||
re.MULTILINE,
|
||||
@@ -35,12 +35,12 @@ class NixProfileListReader:
|
||||
sp = m.group(2)
|
||||
try:
|
||||
idx = int(idx_s)
|
||||
except Exception:
|
||||
except ValueError:
|
||||
continue
|
||||
entries.append((idx, self._store_prefix(sp)))
|
||||
|
||||
seen: set[int] = set()
|
||||
uniq: List[Tuple[int, str]] = []
|
||||
uniq: list[tuple[int, str]] = []
|
||||
for idx, sp in entries:
|
||||
if idx not in seen:
|
||||
seen.add(idx)
|
||||
@@ -49,20 +49,20 @@ class NixProfileListReader:
|
||||
return uniq
|
||||
|
||||
def indices_matching_store_prefixes(
|
||||
self, ctx: "RepoContext", prefixes: List[str]
|
||||
) -> List[int]:
|
||||
self, ctx: RepoContext, prefixes: list[str]
|
||||
) -> list[int]:
|
||||
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
||||
prefixes = [p for p in prefixes if p]
|
||||
if not prefixes:
|
||||
return []
|
||||
|
||||
hits: List[int] = []
|
||||
hits: list[int] = []
|
||||
for idx, sp in self.entries(ctx):
|
||||
if any(sp == p for p in prefixes):
|
||||
hits.append(idx)
|
||||
|
||||
seen: set[int] = set()
|
||||
uniq: List[int] = []
|
||||
uniq: list[int] = []
|
||||
for i in hits:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
|
||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import RunResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
|
||||
from .runner import CommandRunner
|
||||
|
||||
|
||||
@@ -31,8 +33,8 @@ class GitHubRateLimitRetry:
|
||||
|
||||
def run_with_retry(
|
||||
self,
|
||||
ctx: "RepoContext",
|
||||
runner: "CommandRunner",
|
||||
ctx: RepoContext,
|
||||
runner: CommandRunner,
|
||||
install_cmd: str,
|
||||
) -> RunResult:
|
||||
quiet = bool(getattr(ctx, "quiet", False))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import RunResult
|
||||
@@ -16,7 +15,7 @@ class CommandRunner:
|
||||
Supports preview mode and compact failure output logging.
|
||||
"""
|
||||
|
||||
def run(self, ctx: "RepoContext", cmd: str, allow_failure: bool) -> RunResult:
|
||||
def run(self, ctx: RepoContext, cmd: str, allow_failure: bool) -> RunResult:
|
||||
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
||||
preview = bool(getattr(ctx, "preview", False))
|
||||
quiet = bool(getattr(ctx, "quiet", False))
|
||||
@@ -32,8 +31,7 @@ class CommandRunner:
|
||||
shell=True,
|
||||
cwd=repo_dir,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
class NixConflictTextParser:
|
||||
@@ -11,13 +10,13 @@ class NixConflictTextParser:
|
||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||
return m.group(1) if m else raw
|
||||
|
||||
def remove_tokens(self, text: str) -> List[str]:
|
||||
def remove_tokens(self, text: str) -> list[str]:
|
||||
pat = re.compile(
|
||||
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
tokens: List[str] = []
|
||||
tokens: list[str] = []
|
||||
for m in pat.finditer(text or ""):
|
||||
t = (m.group(1) or "").strip()
|
||||
if (t.startswith("'") and t.endswith("'")) or (
|
||||
@@ -28,7 +27,7 @@ class NixConflictTextParser:
|
||||
tokens.append(t)
|
||||
|
||||
seen: set[str] = set()
|
||||
uniq: List[str] = []
|
||||
uniq: list[str] = []
|
||||
for t in tokens:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
@@ -36,9 +35,9 @@ class NixConflictTextParser:
|
||||
|
||||
return uniq
|
||||
|
||||
def existing_store_prefixes(self, text: str) -> List[str]:
|
||||
def existing_store_prefixes(self, text: str) -> list[str]:
|
||||
lines = (text or "").splitlines()
|
||||
prefixes: List[str] = []
|
||||
prefixes: list[str] = []
|
||||
|
||||
in_existing = False
|
||||
in_new = False
|
||||
@@ -69,7 +68,7 @@ class NixConflictTextParser:
|
||||
norm = [self._store_prefix(p) for p in prefixes if p]
|
||||
|
||||
seen: set[str] = set()
|
||||
uniq: List[str] = []
|
||||
uniq: list[str] = []
|
||||
for p in norm:
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
|
||||
@@ -36,7 +36,7 @@ class ArchPkgbuildInstaller(BaseInstaller):
|
||||
try:
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
return False
|
||||
except Exception:
|
||||
except (AttributeError, OSError):
|
||||
# On non-POSIX platforms just ignore this check.
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Debian/Ubuntu packages defined via debian/control.
|
||||
|
||||
@@ -13,11 +10,11 @@ This installer:
|
||||
It is intended for Debian-based systems where dpkg-buildpackage and
|
||||
apt/dpkg tooling are available.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
@@ -56,7 +53,7 @@ class DebianControlInstaller(BaseInstaller):
|
||||
|
||||
return os.path.exists(self._control_path(ctx))
|
||||
|
||||
def _find_built_debs(self, repo_dir: str) -> List[str]:
|
||||
def _find_built_debs(self, repo_dir: str) -> list[str]:
|
||||
"""
|
||||
Find .deb files built by dpkg-buildpackage.
|
||||
|
||||
@@ -67,7 +64,7 @@ class DebianControlInstaller(BaseInstaller):
|
||||
pattern = os.path.join(parent, "*.deb")
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
def _privileged_prefix(self) -> Optional[str]:
|
||||
def _privileged_prefix(self) -> str | None:
|
||||
"""
|
||||
Determine how to run privileged commands:
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for RPM-based packages defined in *.spec files.
|
||||
|
||||
@@ -14,12 +11,12 @@ This installer:
|
||||
|
||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
@@ -53,7 +50,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
|
||||
return has_dnf or has_yum or has_yum_builddep
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
def _spec_path(self, ctx: RepoContext) -> str | None:
|
||||
"""Return the first *.spec file in the repository root, if any."""
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = sorted(glob.glob(pattern))
|
||||
@@ -92,7 +89,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
||||
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
||||
|
||||
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
|
||||
def _parse_name_version(self, spec_path: str) -> tuple[str, str] | None:
|
||||
"""
|
||||
Parse Name and Version from the given .spec file.
|
||||
|
||||
@@ -101,7 +98,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
name = None
|
||||
version = None
|
||||
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
# Ignore comments
|
||||
@@ -183,7 +180,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _find_built_rpms(self) -> List[str]:
|
||||
def _find_built_rpms(self) -> list[str]:
|
||||
"""
|
||||
Find RPMs built by rpmbuild.
|
||||
|
||||
@@ -202,9 +199,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
|
||||
if shutil.which("dnf") is not None:
|
||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||
elif shutil.which("yum-builddep") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
elif shutil.which("yum") is not None:
|
||||
elif shutil.which("yum-builddep") is not None or shutil.which("yum") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
else:
|
||||
print(
|
||||
@@ -215,7 +210,7 @@ class RpmSpecInstaller(BaseInstaller):
|
||||
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
|
||||
def _install_built_rpms(self, ctx: RepoContext, rpms: list[str]) -> None:
|
||||
"""
|
||||
Install or upgrade the built RPMs.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
CLI layer model for the pkgmgr installation pipeline.
|
||||
|
||||
@@ -19,7 +16,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CliLayer(str, Enum):
|
||||
@@ -38,7 +34,7 @@ CLI_LAYERS: list[CliLayer] = [
|
||||
]
|
||||
|
||||
|
||||
def layer_priority(layer: Optional[CliLayer]) -> int:
|
||||
def layer_priority(layer: CliLayer | None) -> int:
|
||||
"""
|
||||
Return a numeric priority index for a given layer.
|
||||
|
||||
@@ -70,13 +66,11 @@ def classify_command_layer(command: str, repo_dir: str) -> CliLayer:
|
||||
home = os.path.expanduser("~")
|
||||
|
||||
# OS package managers
|
||||
if command_abs.startswith("/usr/") or command_abs.startswith("/bin/"):
|
||||
if command_abs.startswith(("/usr/", "/bin/")):
|
||||
return CliLayer.OS_PACKAGES
|
||||
|
||||
# Nix store / profile
|
||||
if command_abs.startswith("/nix/store/") or command_abs.startswith(
|
||||
os.path.join(home, ".nix-profile")
|
||||
):
|
||||
if command_abs.startswith(("/nix/store/", os.path.join(home, ".nix-profile"))):
|
||||
return CliLayer.NIX
|
||||
|
||||
# User-local bin
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# src/pkgmgr/actions/install/pipeline.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installation pipeline orchestration for repositories.
|
||||
@@ -8,8 +6,8 @@ Installation pipeline orchestration for repositories.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Set
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
@@ -24,8 +22,8 @@ from pkgmgr.core.command.resolve import resolve_command_for_repo
|
||||
|
||||
@dataclass
|
||||
class CommandState:
|
||||
command: Optional[str]
|
||||
layer: Optional[CliLayer]
|
||||
command: str | None
|
||||
layer: CliLayer | None
|
||||
|
||||
|
||||
class CommandResolver:
|
||||
@@ -83,7 +81,7 @@ class InstallationPipeline:
|
||||
else:
|
||||
repo.pop("command", None)
|
||||
|
||||
provided_capabilities: Set[str] = set()
|
||||
provided_capabilities: set[str] = set()
|
||||
|
||||
for installer in self._installers:
|
||||
layer_name = getattr(installer, "layer", None)
|
||||
|
||||
@@ -9,19 +9,20 @@ Public API:
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from .types import Repository, MirrorMap
|
||||
from .list_cmd import list_mirrors
|
||||
|
||||
from .diff_cmd import diff_mirrors
|
||||
from .list_cmd import list_mirrors
|
||||
from .merge_cmd import merge_mirrors
|
||||
from .setup_cmd import setup_mirrors
|
||||
from .types import MirrorMap, Repository
|
||||
from .visibility_cmd import set_mirror_visibility
|
||||
|
||||
__all__ = [
|
||||
"Repository",
|
||||
"MirrorMap",
|
||||
"list_mirrors",
|
||||
"Repository",
|
||||
"diff_mirrors",
|
||||
"list_mirrors",
|
||||
"merge_mirrors",
|
||||
"setup_mirrors",
|
||||
"set_mirror_visibility",
|
||||
"setup_mirrors",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
@@ -12,7 +10,7 @@ from .types import MirrorMap, RepoMirrorContext, Repository
|
||||
def build_context(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
) -> RepoMirrorContext:
|
||||
"""
|
||||
Build a RepoMirrorContext for a single repository.
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .context import build_context
|
||||
from .printing import print_header
|
||||
from .types import Repository
|
||||
|
||||
|
||||
def diff_mirrors(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
) -> None:
|
||||
"""
|
||||
Show differences between config mirrors and MIRRORS file.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Set
|
||||
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitAddRemoteError,
|
||||
GitAddRemotePushUrlError,
|
||||
@@ -12,6 +10,7 @@ from pkgmgr.core.git.commands import (
|
||||
add_remote_push_url,
|
||||
set_remote_url,
|
||||
)
|
||||
from pkgmgr.core.git.errors import GitRunError
|
||||
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
|
||||
|
||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||
@@ -38,13 +37,10 @@ def _is_git_remote_url(url: str) -> bool:
|
||||
if u.startswith("ssh://"):
|
||||
return True
|
||||
|
||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
||||
return True
|
||||
|
||||
return False
|
||||
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||
|
||||
|
||||
def build_default_ssh_url(repo: Repository) -> Optional[str]:
|
||||
def build_default_ssh_url(repo: Repository) -> str | None:
|
||||
provider = repo.get("provider")
|
||||
account = repo.get("account")
|
||||
name = repo.get("repository")
|
||||
@@ -66,7 +62,7 @@ def _git_mirrors_only(m: MirrorMap) -> MirrorMap:
|
||||
def determine_primary_remote_url(
|
||||
repo: Repository,
|
||||
ctx: RepoMirrorContext,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""
|
||||
Priority order (GIT URLS ONLY):
|
||||
1. origin from resolved mirrors (if it is a git URL)
|
||||
@@ -80,7 +76,7 @@ def determine_primary_remote_url(
|
||||
return origin
|
||||
|
||||
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
|
||||
for _, url in mirrors.items():
|
||||
for url in mirrors.values():
|
||||
if url and _is_git_remote_url(url):
|
||||
return url
|
||||
|
||||
@@ -116,7 +112,7 @@ def _ensure_additional_push_urls(
|
||||
Non-git URLs (like PyPI) are ignored and will never land in git config.
|
||||
"""
|
||||
git_only = _git_mirrors_only(mirrors)
|
||||
desired: Set[str] = {u for u in git_only.values() if u and u != primary}
|
||||
desired: set[str] = {u for u in git_only.values() if u and u != primary}
|
||||
if not desired:
|
||||
return
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
||||
return mirrors
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .context import build_context
|
||||
from .printing import print_header, print_named_mirrors
|
||||
from .types import Repository
|
||||
|
||||
|
||||
def list_mirrors(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
source: str = "all",
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -11,13 +10,12 @@ from .context import build_context
|
||||
from .io import write_mirrors_file
|
||||
from .types import MirrorMap, Repository
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
||||
def _repo_key(repo: Repository) -> tuple[str, str, str]:
|
||||
"""
|
||||
Normalised key for identifying a repository in config files.
|
||||
"""
|
||||
@@ -28,7 +26,7 @@ def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
||||
)
|
||||
|
||||
|
||||
def _load_user_config(path: str) -> Dict[str, object]:
|
||||
def _load_user_config(path: str) -> dict[str, object]:
|
||||
"""
|
||||
Load a user config YAML file as dict.
|
||||
Non-dicts yield {}.
|
||||
@@ -37,10 +35,10 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -50,13 +48,13 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
||||
|
||||
|
||||
def merge_mirrors(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
source: str,
|
||||
target: str,
|
||||
preview: bool = False,
|
||||
user_config_path: Optional[str] = None,
|
||||
user_config_path: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Merge mirrors between config and MIRRORS file.
|
||||
@@ -74,8 +72,8 @@ def merge_mirrors(
|
||||
"""
|
||||
|
||||
# Load user config once if we intend to write to it.
|
||||
user_cfg: Optional[Dict[str, object]] = None
|
||||
user_cfg_path_expanded: Optional[str] = None
|
||||
user_cfg: dict[str, object] | None = None
|
||||
user_cfg_path_expanded: str | None = None
|
||||
|
||||
if target == "config" and user_config_path and not preview:
|
||||
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
||||
@@ -130,7 +128,7 @@ def merge_mirrors(
|
||||
|
||||
repos = user_cfg.get("repositories")
|
||||
target_key = _repo_key(repo)
|
||||
existing_repo: Optional[Repository] = None
|
||||
existing_repo: Repository | None = None
|
||||
|
||||
# Find existing repo entry
|
||||
for entry in repos:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
||||
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
||||
|
||||
@@ -64,7 +62,7 @@ def ensure_remote_repository_for_url(
|
||||
def ensure_remote_repository(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||
@@ -23,9 +21,7 @@ def _is_git_remote_url(url: str) -> bool:
|
||||
return True
|
||||
if u.startswith("ssh://"):
|
||||
return True
|
||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
||||
return True
|
||||
return False
|
||||
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||
|
||||
|
||||
def _provider_hint_from_host(host: str) -> str | None:
|
||||
@@ -89,7 +85,7 @@ def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
|
||||
def _setup_local_mirrors_for_repo(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool,
|
||||
) -> None:
|
||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||
@@ -106,7 +102,7 @@ def _setup_local_mirrors_for_repo(
|
||||
def _setup_remote_mirrors_for_repo(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool,
|
||||
ensure_remote: bool,
|
||||
ensure_visibility: str | None,
|
||||
@@ -195,9 +191,9 @@ def _setup_remote_mirrors_for_repo(
|
||||
|
||||
|
||||
def setup_mirrors(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool = False,
|
||||
local: bool = True,
|
||||
remote: bool = True,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
MirrorMap = Dict[str, str]
|
||||
Repository = dict[str, Any]
|
||||
MirrorMap = dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
|
||||
def hostport_from_git_url(url: str) -> tuple[str, str | None]:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
return "", None
|
||||
@@ -58,7 +57,7 @@ def _strip_dot_git(name: str) -> str:
|
||||
return n
|
||||
|
||||
|
||||
def parse_repo_from_git_url(url: str) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
def parse_repo_from_git_url(url: str) -> tuple[str, str | None, str | None]:
|
||||
"""
|
||||
Parse (host, owner, repo_name) from common Git remote URLs.
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||
|
||||
@@ -20,9 +18,7 @@ def _is_git_remote_url(url: str) -> bool:
|
||||
return True
|
||||
if u.startswith("ssh://"):
|
||||
return True
|
||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
||||
return True
|
||||
return False
|
||||
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||
|
||||
|
||||
def _provider_hint_from_host(host: str) -> str | None:
|
||||
@@ -66,9 +62,9 @@ def _apply_visibility_for_url(
|
||||
|
||||
|
||||
def set_mirror_visibility(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
*,
|
||||
visibility: str,
|
||||
preview: bool = False,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.command.run import run_command
|
||||
import sys
|
||||
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
|
||||
def exec_proxy_command(
|
||||
proxy_prefix: str,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Backwards-compatible facade for the release file update helpers.
|
||||
|
||||
@@ -13,23 +10,23 @@ Keep this package stable so existing imports continue to work, e.g.:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .editor import _open_editor_for_changelog
|
||||
from .pyproject import update_pyproject_version
|
||||
from .flake import update_flake_version
|
||||
from .pkgbuild import update_pkgbuild_version
|
||||
from .rpm_spec import update_spec_version
|
||||
from .changelog_md import update_changelog
|
||||
from .debian import _get_debian_author, update_debian_changelog
|
||||
from .editor import _open_editor_for_changelog
|
||||
from .flake import update_flake_version
|
||||
from .pkgbuild import update_pkgbuild_version
|
||||
from .pyproject import update_pyproject_version
|
||||
from .rpm_changelog import update_spec_changelog
|
||||
from .rpm_spec import update_spec_version
|
||||
|
||||
__all__ = [
|
||||
"_get_debian_author",
|
||||
"_open_editor_for_changelog",
|
||||
"update_pyproject_version",
|
||||
"update_changelog",
|
||||
"update_debian_changelog",
|
||||
"update_flake_version",
|
||||
"update_pkgbuild_version",
|
||||
"update_spec_version",
|
||||
"update_changelog",
|
||||
"_get_debian_author",
|
||||
"update_debian_changelog",
|
||||
"update_pyproject_version",
|
||||
"update_spec_changelog",
|
||||
"update_spec_version",
|
||||
]
|
||||
|
||||
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal file
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
|
||||
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
||||
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
class ChangelogLintError(RuntimeError):
|
||||
"""Raised when a changelog entry cannot be made markdown-lint clean."""
|
||||
|
||||
|
||||
def transform_changelog_message(text: str) -> str:
|
||||
"""Normalise a free-form release message into the changelog house style.
|
||||
|
||||
A leading ``#`` heading becomes a bold line of its own (markdown
|
||||
headings inside an entry body would collide with the ``## [version]``
|
||||
structure), and inline ``code`` spans become ``*italic*`` so the entry
|
||||
stays free of backticks.
|
||||
"""
|
||||
text = _INLINE_CODE.sub(r"*\1*", text)
|
||||
|
||||
out: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
heading = _HEADING.match(line)
|
||||
if heading:
|
||||
if out and out[-1].strip():
|
||||
out.append("")
|
||||
out.append(f"**{heading.group(1).strip()}**")
|
||||
out.append("")
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
joined = _MULTI_BLANK.sub("\n\n", "\n".join(out))
|
||||
return joined.strip()
|
||||
|
||||
|
||||
def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
|
||||
"""Return markdown-lint findings for *entry* (empty list when clean).
|
||||
|
||||
The entry is checked inside a minimal ``# Changelog`` document so the
|
||||
result reflects only the entry, not pre-existing issues in the target
|
||||
file. ``markdownlint-cli2`` is used when available (picking up the
|
||||
target repository's own config, i.e. the same rules the repo enforces);
|
||||
otherwise a small built-in check runs.
|
||||
"""
|
||||
document = f"# Changelog\n\n{entry.strip()}\n"
|
||||
if shutil.which("markdownlint-cli2"):
|
||||
return _markdownlint(reference_path, document)
|
||||
return _builtin_lint(document)
|
||||
|
||||
|
||||
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
suffix=".md", prefix=".pkgmgr-changelog-", dir=directory
|
||||
)
|
||||
name = os.path.basename(tmp_path)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(document)
|
||||
proc = subprocess.run(
|
||||
["markdownlint-cli2", name],
|
||||
cwd=directory,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return []
|
||||
output = f"{proc.stdout or ''}{proc.stderr or ''}"
|
||||
findings = [
|
||||
line.strip().replace(name, "changelog entry")
|
||||
for line in output.splitlines()
|
||||
if _FINDING.search(line)
|
||||
]
|
||||
return findings or [output.strip() or "markdown-lint reported an error"]
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(tmp_path)
|
||||
|
||||
|
||||
def _builtin_lint(document: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for number, line in enumerate(document.split("\n"), start=1):
|
||||
if line != line.rstrip():
|
||||
errors.append(f"line {number}: trailing whitespace (MD009)")
|
||||
if "`" in line:
|
||||
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
||||
if "\n\n\n" in document:
|
||||
errors.append("multiple consecutive blank lines (MD012)")
|
||||
return errors
|
||||
@@ -1,62 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from .changelog_lint import (
|
||||
ChangelogLintError,
|
||||
lint_changelog_entry,
|
||||
transform_changelog_message,
|
||||
)
|
||||
from .editor import _open_editor_for_changelog
|
||||
|
||||
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
|
||||
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
|
||||
|
||||
|
||||
def _insert_after_h1(existing: str, entry: str) -> str:
|
||||
"""Place *entry* after the H1 (and any intro prose), above the first H2.
|
||||
|
||||
If the file has no H1 we synthesise ``# Changelog`` so the resulting
|
||||
document is markdown-lint-clean (MD041 first-line-h1).
|
||||
If the file has no H2 yet we append *entry* after the H1 block.
|
||||
Existing behaviour for legacy headerless files (file starts with
|
||||
``## ``) is preserved: *entry* is prepended unchanged.
|
||||
"""
|
||||
if not existing.strip():
|
||||
return f"# Changelog\n\n{entry}"
|
||||
|
||||
if not H1_RE.search(existing):
|
||||
# Legacy layout: file starts with `## [version]` and has no H1.
|
||||
# Synthesise the H1 so the merged file is lint-clean.
|
||||
return f"# Changelog\n\n{entry}{existing.lstrip()}"
|
||||
|
||||
# File has an H1. Find the first H2 (existing release section).
|
||||
h2_match = H2_RE.search(existing)
|
||||
if h2_match is None:
|
||||
# H1 + optional intro but no release entries yet — append entry
|
||||
# after a single blank line.
|
||||
suffix = (
|
||||
""
|
||||
if existing.endswith("\n\n")
|
||||
else ("\n" if existing.endswith("\n") else "\n\n")
|
||||
)
|
||||
return f"{existing}{suffix}{entry}"
|
||||
|
||||
# Insert new entry just before the first H2.
|
||||
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
|
||||
tail = existing[h2_match.start() :]
|
||||
return f"{head}{entry}{tail}"
|
||||
|
||||
|
||||
def update_changelog(
|
||||
changelog_path: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
preview: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Prepend a new release section to CHANGELOG.md with the new version,
|
||||
current date, and a message.
|
||||
"""
|
||||
today = date.today().isoformat()
|
||||
"""Insert a new release entry into CHANGELOG.md.
|
||||
|
||||
if message is None:
|
||||
if preview:
|
||||
message = "Automated release."
|
||||
else:
|
||||
print(
|
||||
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
|
||||
The entry is placed after the documents H1 heading (creating one if
|
||||
missing) and above any existing release entries, so the result stays
|
||||
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
|
||||
"""
|
||||
today = date.today().isoformat() # noqa: DTZ011 - changelog dates are local calendar dates
|
||||
|
||||
def _entry_for(raw: str) -> tuple[str, str]:
|
||||
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
|
||||
return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
|
||||
|
||||
def _print_findings(findings: list[str]) -> None:
|
||||
print("\n[ERROR] Changelog entry is not markdown-lint clean:")
|
||||
for finding in findings:
|
||||
print(f" - {finding}")
|
||||
print()
|
||||
|
||||
if message is not None:
|
||||
body, entry = _entry_for(message)
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
if findings:
|
||||
_print_findings(findings)
|
||||
raise ChangelogLintError(
|
||||
"Provided changelog message is not markdown-lint clean."
|
||||
)
|
||||
editor_message = _open_editor_for_changelog()
|
||||
if not editor_message:
|
||||
message = "Automated release."
|
||||
else:
|
||||
message = editor_message
|
||||
|
||||
header = f"## [{new_version}] - {today}\n"
|
||||
header += f"\n* {message}\n\n"
|
||||
elif preview or not sys.stdin.isatty():
|
||||
body, entry = _entry_for(message or f"Release {new_version}")
|
||||
else:
|
||||
attempt: str | None = None
|
||||
while True:
|
||||
print(
|
||||
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
|
||||
"bold, `code` becomes italic.\n"
|
||||
)
|
||||
raw = _open_editor_for_changelog(attempt)
|
||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
if not findings:
|
||||
break
|
||||
_print_findings(findings)
|
||||
attempt = body
|
||||
print("[INFO] Re-opening the editor so you can fix the entry...")
|
||||
|
||||
changelog = ""
|
||||
if os.path.exists(changelog_path):
|
||||
try:
|
||||
with open(changelog_path, "r", encoding="utf-8") as f:
|
||||
with open(changelog_path, encoding="utf-8") as f:
|
||||
changelog = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||
changelog = ""
|
||||
else:
|
||||
changelog = ""
|
||||
|
||||
new_changelog = header + "\n" + changelog if changelog else header
|
||||
new_changelog = _insert_after_h1(changelog, entry)
|
||||
|
||||
print("\n================ CHANGELOG ENTRY ================")
|
||||
print(header.rstrip())
|
||||
print(entry.rstrip())
|
||||
print("=================================================\n")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
|
||||
return message
|
||||
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
|
||||
return body
|
||||
|
||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"Updated CHANGELOG.md with version {new_version}")
|
||||
return message
|
||||
return body
|
||||
|
||||
@@ -2,12 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pkgmgr.core.git.queries import get_config_value
|
||||
|
||||
|
||||
def _get_debian_author() -> Tuple[str, str]:
|
||||
def _get_debian_author() -> tuple[str, str]:
|
||||
name = os.environ.get("DEBFULLNAME")
|
||||
email = os.environ.get("DEBEMAIL")
|
||||
|
||||
@@ -33,7 +32,7 @@ def update_debian_changelog(
|
||||
debian_changelog_path: str,
|
||||
package_name: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
if not os.path.exists(debian_changelog_path):
|
||||
@@ -47,10 +46,17 @@ def update_debian_changelog(
|
||||
author_name, author_email = _get_debian_author()
|
||||
|
||||
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
|
||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||
body = (
|
||||
message.strip()
|
||||
if message and message.strip()
|
||||
else f"Automated release {new_version}."
|
||||
)
|
||||
indented_body = "\n".join(
|
||||
f" {line}" if line.strip() else line for line in body.split("\n")
|
||||
)
|
||||
stanza = (
|
||||
f"{first_line}\n\n"
|
||||
f" * {body_line}\n\n"
|
||||
f"{indented_body}\n\n"
|
||||
f" -- {author_name} <{author_email}> {date_str}\n\n"
|
||||
)
|
||||
|
||||
@@ -62,9 +68,9 @@ def update_debian_changelog(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(debian_changelog_path, "r", encoding="utf-8") as f:
|
||||
with open(debian_changelog_path, encoding="utf-8") as f:
|
||||
existing = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read debian/changelog: {exc}")
|
||||
existing = ""
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||
def _open_editor_for_changelog(initial_message: str | None = None) -> str:
|
||||
editor = os.environ.get("EDITOR", "nano")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
@@ -16,9 +16,10 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||
) as tmp:
|
||||
tmp_path = tmp.name
|
||||
tmp.write(
|
||||
"# Write the changelog entry for this release.\n"
|
||||
"# Lines starting with '#' will be ignored.\n"
|
||||
"# Empty result will fall back to a generic message.\n\n"
|
||||
"; Write the changelog entry for this release.\n"
|
||||
"; Lines starting with ';' are ignored.\n"
|
||||
"; A leading '#' becomes bold; `code` becomes italic.\n"
|
||||
"; Empty result will fall back to a generic message.\n\n"
|
||||
)
|
||||
if initial_message:
|
||||
tmp.write(initial_message.strip() + "\n")
|
||||
@@ -33,13 +34,11 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||
)
|
||||
|
||||
try:
|
||||
with open(tmp_path, "r", encoding="utf-8") as f:
|
||||
with open(tmp_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
|
||||
lines = [line for line in content.splitlines() if not line.strip().startswith(";")]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@@ -12,9 +12,9 @@ def update_flake_version(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(flake_path, "r", encoding="utf-8") as f:
|
||||
with open(flake_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read flake.nix: {exc}")
|
||||
return
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ def update_pkgbuild_version(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||
with open(pkgbuild_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read PKGBUILD: {exc}")
|
||||
return
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ def update_pyproject_version(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||
with open(pyproject_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except OSError as exc:
|
||||
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .debian import _get_debian_author
|
||||
|
||||
@@ -11,7 +10,7 @@ def update_spec_changelog(
|
||||
spec_path: str,
|
||||
package_name: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
if not os.path.exists(spec_path):
|
||||
@@ -19,9 +18,9 @@ def update_spec_changelog(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read spec file for changelog update: {exc}")
|
||||
return
|
||||
|
||||
@@ -30,11 +29,18 @@ def update_spec_changelog(
|
||||
date_str = now.strftime("%a %b %d %Y")
|
||||
|
||||
author_name, author_email = _get_debian_author()
|
||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||
body = (
|
||||
message.strip()
|
||||
if message and message.strip()
|
||||
else f"Automated release {new_version}."
|
||||
)
|
||||
dashed_body = "\n".join(
|
||||
f"- {line}" if line.strip() else line for line in body.split("\n")
|
||||
)
|
||||
|
||||
stanza = (
|
||||
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
|
||||
f"- {body_line}\n\n"
|
||||
f"{dashed_body}\n\n"
|
||||
)
|
||||
|
||||
marker = "%changelog"
|
||||
@@ -57,7 +63,7 @@ def update_spec_changelog(
|
||||
try:
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeEncodeError) as exc:
|
||||
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
|
||||
return
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ def update_spec_version(
|
||||
return
|
||||
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read spec file: {exc}")
|
||||
return
|
||||
|
||||
|
||||
73
src/pkgmgr/actions/release/package_name.py
Normal file
73
src/pkgmgr/actions/release/package_name.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Resolve the distro-package name for a release.
|
||||
|
||||
The release flow writes the package identifier into `debian/changelog`,
|
||||
the RPM `%changelog` stanza, etc. Historically pkgmgr derived this
|
||||
identifier from the repository folder name (`os.path.basename(repo_root)`),
|
||||
which silently breaks when the repo is renamed but the existing packaging
|
||||
files still ship the legacy name. Renaming the folder must not change the
|
||||
distro-package identity — `apt`, `pacman`, `dnf`, and every downstream
|
||||
manifest pin the old name.
|
||||
|
||||
The resolver therefore walks the existing packaging files in priority
|
||||
order and only falls back to the folder name when none of them ship an
|
||||
explicit name.
|
||||
|
||||
Priority:
|
||||
1. `debian/control` `Package:` field (most authoritative — dpkg-source
|
||||
refuses to build if changelog and control disagree)
|
||||
2. `packaging/arch/PKGBUILD` `pkgname=` value
|
||||
3. RPM spec `Name:` field
|
||||
4. Repository folder basename (legacy fallback)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from pkgmgr.core.repository.paths import RepoPaths
|
||||
|
||||
_DEBIAN_PACKAGE_RE = re.compile(r"^Package:\s*(\S+)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_NAME_RE = re.compile(r"^pkgname=([^\s#]+)\s*$", re.MULTILINE)
|
||||
_RPM_NAME_RE = re.compile(r"^Name:\s*(\S+)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _read(path: str | None) -> str:
|
||||
if not path or not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract(pattern: re.Pattern[str], text: str) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
value = match.group(1).strip().strip('"').strip("'")
|
||||
return value or None
|
||||
|
||||
|
||||
def resolve_package_name(paths: RepoPaths) -> str:
|
||||
"""Return the distro-package name for the repo, with a folder fallback.
|
||||
|
||||
The fallback uses `os.path.basename(paths.repo_dir)` so behaviour is
|
||||
backwards-compatible for repos that ship no packaging metadata yet.
|
||||
"""
|
||||
debian_name = _extract(_DEBIAN_PACKAGE_RE, _read(paths.debian_control))
|
||||
if debian_name:
|
||||
return debian_name
|
||||
|
||||
pkgbuild_name = _extract(_PKGBUILD_NAME_RE, _read(paths.arch_pkgbuild))
|
||||
if pkgbuild_name:
|
||||
return pkgbuild_name
|
||||
|
||||
rpm_name = _extract(_RPM_NAME_RE, _read(paths.rpm_spec))
|
||||
if rpm_name:
|
||||
return rpm_name
|
||||
|
||||
return os.path.basename(paths.repo_dir) or "package"
|
||||
67
src/pkgmgr/actions/release/retry.py
Normal file
67
src/pkgmgr/actions/release/retry.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Re-deploy an existing release without modifying files or creating a new tag.
|
||||
|
||||
The release workflow normally bumps versions, rewrites packaging
|
||||
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
|
||||
When a post-tag step fails mid-flight (typical examples: `git push`
|
||||
rejected, `twine upload` aborted by a broken venv, `update-latest`
|
||||
rejected by branch protection) the local tag still exists on HEAD but
|
||||
the side effects downstream are incomplete.
|
||||
|
||||
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||
release can be re-pushed without touching code or recreating tags:
|
||||
|
||||
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||
|
||||
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||
workflow is already idempotent (twine rejects duplicates per spec) and
|
||||
can be invoked independently via the `publish` subcommand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
from .git_ops import is_highest_version_tag, update_latest_tag
|
||||
|
||||
|
||||
def retry_release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Re-push the HEAD release without re-tagging or modifying any files."""
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitRunError:
|
||||
branch = "main"
|
||||
print(f"Retrying release push on branch: {branch}")
|
||||
|
||||
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
|
||||
if not tags:
|
||||
raise RuntimeError(
|
||||
"No version tag on HEAD. Nothing to retry — "
|
||||
"run `pkgmgr release <type>` first to create a release."
|
||||
)
|
||||
tag = max(tags, key=SemVer.parse)
|
||||
print(f"Re-pushing existing tag: {tag}")
|
||||
|
||||
run(["push", "origin", branch, tag], preview=preview)
|
||||
|
||||
try:
|
||||
if is_highest_version_tag(tag):
|
||||
update_latest_tag(tag, preview=preview)
|
||||
else:
|
||||
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
|
||||
except GitRunError as exc:
|
||||
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Retry push for {tag} would now complete.")
|
||||
return
|
||||
|
||||
print(f"Retry push completed for {tag}.")
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Version discovery and bumping helpers for the release workflow.
|
||||
"""
|
||||
@@ -10,10 +7,10 @@ from __future__ import annotations
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
find_latest_version,
|
||||
bump_major,
|
||||
bump_minor,
|
||||
bump_patch,
|
||||
find_latest_version,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
@@ -24,7 +23,9 @@ from .git_ops import (
|
||||
is_highest_version_tag,
|
||||
update_latest_tag,
|
||||
)
|
||||
from .package_name import resolve_package_name
|
||||
from .prompts import confirm_proceed_release, should_delete_branch
|
||||
from .retry import retry_release
|
||||
from .versioning import bump_semver, determine_current_version
|
||||
|
||||
|
||||
@@ -32,7 +33,7 @@ def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
force: bool = False,
|
||||
@@ -85,12 +86,11 @@ def _release_impl(
|
||||
else:
|
||||
print("[INFO] No RPM spec file found. Skipping spec version update.")
|
||||
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
effective_message: str | None = message
|
||||
if isinstance(changelog_message, str) and changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
package_name = resolve_package_name(paths)
|
||||
|
||||
if paths.debian_changelog:
|
||||
update_debian_changelog(
|
||||
@@ -186,7 +186,7 @@ def _release_impl(
|
||||
print(f"[INFO] Deleting branch {branch} after successful release...")
|
||||
try:
|
||||
close_branch(name=branch, base_branch="main", cwd=".")
|
||||
except Exception as exc:
|
||||
except (RuntimeError, GitRunError) as exc:
|
||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||
|
||||
|
||||
@@ -194,11 +194,16 @@ def release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
retry: bool = False,
|
||||
) -> None:
|
||||
if retry:
|
||||
retry_release(pyproject_path=pyproject_path, preview=preview)
|
||||
return
|
||||
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
|
||||
@@ -3,29 +3,29 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
from typing import Any, Callable
|
||||
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
RepoRef = Tuple[str, str]
|
||||
OpResult = Tuple[bool, str]
|
||||
Repository = dict[str, Any]
|
||||
RepoRef = tuple[str, str]
|
||||
OpResult = tuple[bool, str]
|
||||
RepoOp = Callable[[str], OpResult]
|
||||
|
||||
|
||||
def resolve_repos(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
) -> List[RepoRef]:
|
||||
all_repos: list[Repository],
|
||||
) -> list[RepoRef]:
|
||||
"""
|
||||
Resolve ``(identifier, repo_dir)`` pairs for ``selected_repos``.
|
||||
|
||||
Repositories whose directory does not exist on disk are reported and
|
||||
skipped, matching the prior behavior of pull/push handlers.
|
||||
"""
|
||||
resolved: List[RepoRef] = []
|
||||
resolved: list[RepoRef] = []
|
||||
for repo in selected_repos:
|
||||
ident = get_repo_identifier(repo, all_repos)
|
||||
rd = get_repo_dir(repositories_base_dir, repo)
|
||||
@@ -37,7 +37,7 @@ def resolve_repos(
|
||||
|
||||
|
||||
def run_on_repos(
|
||||
repos: List[RepoRef],
|
||||
repos: list[RepoRef],
|
||||
op: RepoOp,
|
||||
*,
|
||||
jobs: int,
|
||||
@@ -55,7 +55,7 @@ def run_on_repos(
|
||||
return
|
||||
|
||||
effective_jobs = max(1, min(jobs, len(repos)))
|
||||
failed: List[Tuple[str, str]] = []
|
||||
failed: list[tuple[str, str]] = []
|
||||
|
||||
if effective_jobs == 1:
|
||||
for ident, rd in repos:
|
||||
@@ -83,9 +83,7 @@ def run_on_repos(
|
||||
|
||||
if failed:
|
||||
if effective_jobs > 1:
|
||||
print(
|
||||
f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:"
|
||||
)
|
||||
print(f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:")
|
||||
for ident, _msg in failed:
|
||||
print(f" - {ident}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.core.git.commands import clone as git_clone, GitCloneError
|
||||
from pkgmgr.core.git.commands import GitCloneError
|
||||
from pkgmgr.core.git.commands import clone as git_clone
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
def _build_clone_url(repo: Repository, clone_mode: str) -> Optional[str]:
|
||||
def _build_clone_url(repo: Repository, clone_mode: str) -> str | None:
|
||||
provider = repo.get("provider")
|
||||
account = repo.get("account")
|
||||
name = repo.get("repository")
|
||||
@@ -33,9 +34,9 @@ def _build_clone_url(repo: Repository, clone_mode: str) -> Optional[str]:
|
||||
|
||||
|
||||
def clone_repos(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
all_repos: list[Repository],
|
||||
preview: bool,
|
||||
no_verification: bool,
|
||||
clone_mode: str,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from .service import CreateRepoService
|
||||
|
||||
RepositoryConfig = Dict[str, Any]
|
||||
RepositoryConfig = dict[str, Any]
|
||||
|
||||
__all__ = [
|
||||
"CreateRepoService",
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, Any, Set
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.core.command.alias import generate_alias
|
||||
from pkgmgr.core.config.save import save_user_config
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
class ConfigRepoWriter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_merged: Dict[str, Any],
|
||||
config_merged: dict[str, Any],
|
||||
user_config_path: str,
|
||||
bin_dir: str,
|
||||
):
|
||||
@@ -43,7 +43,7 @@ class ConfigRepoWriter:
|
||||
):
|
||||
return repo
|
||||
|
||||
existing_aliases: Set[str] = {
|
||||
existing_aliases: set[str] = {
|
||||
str(r.get("alias")) for r in repositories if r.get("alias")
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class ConfigRepoWriter:
|
||||
return repo
|
||||
|
||||
if os.path.exists(self.user_config_path):
|
||||
with open(self.user_config_path, "r", encoding="utf-8") as f:
|
||||
with open(self.user_config_path, encoding="utf-8") as f:
|
||||
user_cfg = yaml.safe_load(f) or {}
|
||||
else:
|
||||
user_cfg = {}
|
||||
|
||||
@@ -10,10 +10,12 @@ from pkgmgr.core.git.commands import (
|
||||
push_upstream,
|
||||
)
|
||||
|
||||
DEFAULT_BRANCH = "main"
|
||||
|
||||
|
||||
class GitBootstrapper:
|
||||
def init_repo(self, repo_dir: str, preview: bool) -> None:
|
||||
init(cwd=repo_dir, preview=preview)
|
||||
init(initial_branch=DEFAULT_BRANCH, cwd=repo_dir, preview=preview)
|
||||
add_all(cwd=repo_dir, preview=preview)
|
||||
try:
|
||||
commit("Initial commit", cwd=repo_dir, preview=preview)
|
||||
@@ -21,15 +23,8 @@ class GitBootstrapper:
|
||||
print(f"[WARN] Initial commit failed (continuing): {exc}")
|
||||
|
||||
def push_default_branch(self, repo_dir: str, preview: bool) -> None:
|
||||
branch_move(DEFAULT_BRANCH, cwd=repo_dir, preview=preview)
|
||||
try:
|
||||
branch_move("main", cwd=repo_dir, preview=preview)
|
||||
push_upstream("origin", "main", cwd=repo_dir, preview=preview)
|
||||
return
|
||||
except GitPushUpstreamError:
|
||||
pass
|
||||
|
||||
try:
|
||||
branch_move("master", cwd=repo_dir, preview=preview)
|
||||
push_upstream("origin", "master", cwd=repo_dir, preview=preview)
|
||||
push_upstream("origin", DEFAULT_BRANCH, cwd=repo_dir, preview=preview)
|
||||
except GitPushUpstreamError as exc:
|
||||
print(f"[WARN] Push failed: {exc}")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.mirror.io import write_mirrors_file
|
||||
from pkgmgr.actions.mirror.setup_cmd import setup_mirrors
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
class MirrorBootstrapper:
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoParts:
|
||||
host: str
|
||||
port: Optional[str]
|
||||
port: str | None
|
||||
owner: str
|
||||
name: str
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .model import RepoParts
|
||||
@@ -50,7 +49,7 @@ def _parse_git_url(url: str) -> RepoParts:
|
||||
return RepoParts(host=host, port=port, owner=owner, name=name)
|
||||
|
||||
|
||||
def _split_host_port(host: str) -> Tuple[str, str | None]:
|
||||
def _split_host_port(host: str) -> tuple[str, str | None]:
|
||||
if ":" in host:
|
||||
h, p = host.split(":", 1)
|
||||
return h, p or None
|
||||
@@ -58,7 +57,7 @@ def _split_host_port(host: str) -> Tuple[str, str | None]:
|
||||
|
||||
|
||||
def _strip_git_suffix(name: str) -> str:
|
||||
return name[:-4] if name.endswith(".git") else name
|
||||
return name.removesuffix(".git")
|
||||
|
||||
|
||||
def _ensure_valid_repo_name(name: str) -> None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from typing import Any
|
||||
|
||||
from .model import RepoParts
|
||||
|
||||
@@ -38,7 +38,7 @@ class CreateRepoPlanner:
|
||||
*,
|
||||
author_name: str,
|
||||
author_email: str,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": self.parts.host,
|
||||
"port": self.parts.port,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.core.git.queries import get_config_value
|
||||
|
||||
from .parser import parse_identifier
|
||||
from .planner import CreateRepoPlanner
|
||||
from .config_writer import ConfigRepoWriter
|
||||
from .templates import TemplateRenderer
|
||||
from .git_bootstrap import GitBootstrapper
|
||||
from .mirrors import MirrorBootstrapper
|
||||
from .parser import parse_identifier
|
||||
from .planner import CreateRepoPlanner
|
||||
from .templates import TemplateRenderer
|
||||
|
||||
|
||||
class CreateRepoService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_merged: Dict[str, Any],
|
||||
config_merged: dict[str, Any],
|
||||
user_config_path: str,
|
||||
bin_dir: str,
|
||||
):
|
||||
|
||||
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.core.git.queries import get_repo_root
|
||||
|
||||
try:
|
||||
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
||||
except Exception as exc: # pragma: no cover
|
||||
except ImportError as exc: # pragma: no cover
|
||||
Environment = None # type: ignore
|
||||
FileSystemLoader = None # type: ignore
|
||||
StrictUndefined = None # type: ignore
|
||||
@@ -25,7 +25,7 @@ class TemplateRenderer:
|
||||
self,
|
||||
*,
|
||||
repo_dir: str,
|
||||
context: Dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
preview: bool,
|
||||
) -> None:
|
||||
if preview:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import shutil
|
||||
import os
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
import shutil
|
||||
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
|
||||
def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False):
|
||||
@@ -27,7 +28,7 @@ def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False
|
||||
print(
|
||||
f"Deleted repository directory '{repo_dir}' for {repo_identifier}."
|
||||
)
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
print(f"Error deleting '{repo_dir}' for {repo_identifier}: {e}")
|
||||
else:
|
||||
print(f"Skipped deletion of '{repo_dir}' for {repo_identifier}.")
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Pretty-print repository list with status, categories, tags and path.
|
||||
|
||||
@@ -16,9 +13,9 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
from textwrap import wrap
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
@@ -30,7 +27,7 @@ MAGENTA = "\033[35m"
|
||||
GREY = "\033[90m"
|
||||
|
||||
|
||||
def _compile_maybe_regex(pattern: str) -> Optional[re.Pattern[str]]:
|
||||
def _compile_maybe_regex(pattern: str) -> re.Pattern[str] | None:
|
||||
"""
|
||||
If pattern is of the form /.../, return a compiled regex (case-insensitive).
|
||||
Otherwise return None.
|
||||
@@ -89,7 +86,7 @@ def _compute_status(
|
||||
"""
|
||||
Compute a human-readable status string, e.g. 'present,alias,ignored'.
|
||||
"""
|
||||
parts: List[str] = []
|
||||
parts: list[str] = []
|
||||
|
||||
exists = os.path.isdir(repo_dir)
|
||||
if exists:
|
||||
@@ -129,7 +126,7 @@ def _color_status(status_padded: str) -> str:
|
||||
pad_spaces = len(status_padded) - len(core)
|
||||
|
||||
plain_parts = core.split(",") if core else []
|
||||
colored_parts: List[str] = []
|
||||
colored_parts: list[str] = []
|
||||
|
||||
for raw_part in plain_parts:
|
||||
name = raw_part.strip()
|
||||
@@ -157,12 +154,12 @@ def _color_status(status_padded: str) -> str:
|
||||
|
||||
|
||||
def list_repositories(
|
||||
repositories: List[Repository],
|
||||
repositories: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
binaries_dir: str,
|
||||
search_filter: str = "",
|
||||
status_filter: str = "",
|
||||
extra_tags: Optional[List[str]] = None,
|
||||
extra_tags: list[str] | None = None,
|
||||
show_description: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -189,7 +186,7 @@ def list_repositories(
|
||||
extra_tags = []
|
||||
|
||||
search_regex = _compile_maybe_regex(search_filter)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build rows
|
||||
@@ -209,17 +206,7 @@ def list_repositories(
|
||||
continue
|
||||
|
||||
if search_filter:
|
||||
haystack = " ".join(
|
||||
[
|
||||
identifier,
|
||||
alias,
|
||||
provider,
|
||||
account,
|
||||
description,
|
||||
homepage,
|
||||
repo_dir,
|
||||
]
|
||||
)
|
||||
haystack = f"{identifier} {alias} {provider} {account} {description} {homepage} {repo_dir}"
|
||||
if search_regex:
|
||||
if not search_regex.search(haystack):
|
||||
continue
|
||||
@@ -227,13 +214,13 @@ def list_repositories(
|
||||
if search_filter.lower() not in haystack.lower():
|
||||
continue
|
||||
|
||||
categories: List[str] = []
|
||||
categories: list[str] = []
|
||||
categories.extend(map(str, repo.get("category_files", [])))
|
||||
if repo.get("category"):
|
||||
categories.append(str(repo["category"]))
|
||||
|
||||
yaml_tags: List[str] = list(map(str, repo.get("tags", [])))
|
||||
display_tags: List[str] = sorted(set(yaml_tags + list(map(str, extra_tags))))
|
||||
yaml_tags: list[str] = list(map(str, repo.get("tags", [])))
|
||||
display_tags: list[str] = sorted(set(yaml_tags + list(map(str, extra_tags))))
|
||||
|
||||
rows.append(
|
||||
{
|
||||
|
||||
@@ -2,18 +2,18 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.repository._parallel import RepoRef, run_on_repos
|
||||
from pkgmgr.core.git.commands import pull_args, GitPullArgsError
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git.commands import GitPullArgsError, pull_args
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
def _pull_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||
def _pull_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]:
|
||||
try:
|
||||
pull_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return (True, "")
|
||||
@@ -25,19 +25,22 @@ def _verify_one(
|
||||
repo: Repository,
|
||||
repo_dir: str,
|
||||
no_verification: bool,
|
||||
) -> Tuple[bool, bool, List[str]]:
|
||||
) -> tuple[bool, bool, list[str]]:
|
||||
"""Returns (has_verified_info, verified_ok, errors)."""
|
||||
verified_ok, errors, _commit, _key = verify_repository(
|
||||
repo, repo_dir, mode="pull", no_verification=no_verification,
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="pull",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
return (bool(repo.get("verified")), verified_ok, errors)
|
||||
|
||||
|
||||
def _verify_all(
|
||||
candidates: List[Tuple[Repository, str, str]],
|
||||
candidates: list[tuple[Repository, str, str]],
|
||||
no_verification: bool,
|
||||
jobs: int,
|
||||
) -> List[Tuple[str, str, bool, bool, List[str]]]:
|
||||
) -> list[tuple[str, str, bool, bool, list[str]]]:
|
||||
"""
|
||||
Verify all candidates (parallel if ``jobs > 1``), preserving input order.
|
||||
|
||||
@@ -56,16 +59,14 @@ def _verify_all(
|
||||
for repo, _ident, rd in candidates
|
||||
]
|
||||
results = [f.result() for f in futures]
|
||||
return [
|
||||
(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)
|
||||
]
|
||||
return [(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)]
|
||||
|
||||
|
||||
def pull_with_verification(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
extra_args: List[str],
|
||||
all_repos: list[Repository],
|
||||
extra_args: list[str],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
jobs: int = 1,
|
||||
@@ -79,7 +80,7 @@ def pull_with_verification(
|
||||
- Approved repos are then pulled in parallel when ``jobs > 1``.
|
||||
- On any pull failure, prints a summary and exits with status 1.
|
||||
"""
|
||||
candidates: List[Tuple[Repository, str, str]] = []
|
||||
candidates: list[tuple[Repository, str, str]] = []
|
||||
for repo in selected_repos:
|
||||
ident = get_repo_identifier(repo, all_repos)
|
||||
rd = get_repo_dir(repositories_base_dir, repo)
|
||||
@@ -93,9 +94,14 @@ def pull_with_verification(
|
||||
|
||||
verify_results = _verify_all(candidates, no_verification, jobs)
|
||||
|
||||
approved: List[RepoRef] = []
|
||||
approved: list[RepoRef] = []
|
||||
for ident, rd, has_verified_info, verified_ok, errors in verify_results:
|
||||
if not preview and not no_verification and has_verified_info and not verified_ok:
|
||||
if (
|
||||
not preview
|
||||
and not no_verification
|
||||
and has_verified_info
|
||||
and not verified_ok
|
||||
):
|
||||
print(f"Warning: Verification failed for {ident}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.repository._parallel import (
|
||||
resolve_repos,
|
||||
run_on_repos,
|
||||
)
|
||||
from pkgmgr.core.git.commands import push_args, GitPushArgsError
|
||||
from pkgmgr.core.git.commands import GitPushArgsError, push_args
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
def _push_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||
def _push_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]:
|
||||
try:
|
||||
push_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return (True, "")
|
||||
@@ -20,10 +20,10 @@ def _push_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool
|
||||
|
||||
|
||||
def push_in_parallel(
|
||||
selected_repos: List[Repository],
|
||||
selected_repos: list[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
extra_args: List[str],
|
||||
all_repos: list[Repository],
|
||||
extra_args: list[str],
|
||||
preview: bool,
|
||||
jobs: int = 1,
|
||||
) -> None:
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pkgmgr.actions.update.manager import UpdateManager
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, List, Tuple
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.update.system_updater import SystemUpdater
|
||||
|
||||
@@ -37,7 +35,7 @@ class UpdateManager:
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
failures: List[Tuple[str, str]] = []
|
||||
failures: list[tuple[str, str]] = []
|
||||
|
||||
for repo in list(selected_repos):
|
||||
identifier = get_repo_identifier(repo, all_repos)
|
||||
@@ -59,7 +57,7 @@ class UpdateManager:
|
||||
f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing..."
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
|
||||
failures.append((identifier, f"pull failed: {exc}"))
|
||||
if not quiet:
|
||||
print(
|
||||
@@ -90,7 +88,7 @@ class UpdateManager:
|
||||
f"[Warning] update: install failed for {identifier} (exit={code}). Continuing..."
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
|
||||
failures.append((identifier, f"install failed: {exc}"))
|
||||
if not quiet:
|
||||
print(
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def read_os_release(path: str = "/etc/os-release") -> Dict[str, str]:
|
||||
def read_os_release(path: str = "/etc/os-release") -> dict[str, str]:
|
||||
"""
|
||||
Parse /etc/os-release into a dict. Returns empty dict if missing.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
|
||||
result: Dict[str, str] = {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
result: dict[str, str] = {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
@@ -37,7 +33,7 @@ class OSReleaseInfo:
|
||||
pretty_name: str = ""
|
||||
|
||||
@staticmethod
|
||||
def load() -> "OSReleaseInfo":
|
||||
def load() -> OSReleaseInfo:
|
||||
data = read_os_release()
|
||||
return OSReleaseInfo(
|
||||
id=(data.get("ID") or "").lower(),
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -6,8 +5,8 @@ import os
|
||||
from pkgmgr.core.config.load import load_config
|
||||
|
||||
from .context import CLIContext
|
||||
from .parser import create_parser
|
||||
from .dispatch import dispatch_command
|
||||
from .parser import create_parser
|
||||
|
||||
__all__ = ["CLIContext", "create_parser", "dispatch_command", "main"]
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
from .repos import handle_repos_command
|
||||
from .config import handle_config
|
||||
from .tools import handle_tools_command
|
||||
from .release import handle_release
|
||||
from .publish import handle_publish
|
||||
from .version import handle_version
|
||||
from .make import handle_make
|
||||
from .changelog import handle_changelog
|
||||
from .archive import handle_archive
|
||||
from .branch import handle_branch
|
||||
from .changelog import handle_changelog
|
||||
from .code_scanning import handle_code_scanning
|
||||
from .config import handle_config
|
||||
from .make import handle_make
|
||||
from .mirror import handle_mirror_command
|
||||
from .publish import handle_publish
|
||||
from .release import handle_release
|
||||
from .repos import handle_repos_command
|
||||
from .tools import handle_tools_command
|
||||
from .version import handle_version
|
||||
|
||||
__all__ = [
|
||||
"handle_repos_command",
|
||||
"handle_config",
|
||||
"handle_tools_command",
|
||||
"handle_release",
|
||||
"handle_publish",
|
||||
"handle_version",
|
||||
"handle_make",
|
||||
"handle_changelog",
|
||||
"handle_archive",
|
||||
"handle_branch",
|
||||
"handle_changelog",
|
||||
"handle_code_scanning",
|
||||
"handle_config",
|
||||
"handle_make",
|
||||
"handle_mirror_command",
|
||||
"handle_publish",
|
||||
"handle_release",
|
||||
"handle_repos_command",
|
||||
"handle_tools_command",
|
||||
"handle_version",
|
||||
]
|
||||
|
||||
76
src/pkgmgr/cli/commands/archive.py
Normal file
76
src/pkgmgr/cli/commands/archive.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pkgmgr.actions.archive import ArchivePlan, run_archive
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
|
||||
|
||||
def _print_summary(
|
||||
directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
|
||||
) -> None:
|
||||
print(f"[archive] Directory: {directory}")
|
||||
print(f"[archive] README: {readme}")
|
||||
print(f"[archive] Files to process: {len(plan.archived)}")
|
||||
print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
|
||||
print(f"[archive] New archive entries: {len(plan.new_entries)}")
|
||||
print(f"[archive] Dry-run: {dry_run}")
|
||||
|
||||
|
||||
def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
|
||||
if plan.skipped_incomplete:
|
||||
print(
|
||||
"[archive] SKIP: files with unchecked `- [ ]` items "
|
||||
"(not archived, not deleted):"
|
||||
)
|
||||
for path, count in plan.skipped_incomplete:
|
||||
suffix = "s" if count != 1 else ""
|
||||
rel = _rel_or_abs(path, cwd)
|
||||
print(f" - {rel} ({count} unchecked item{suffix})")
|
||||
if plan.skipped_without_h1:
|
||||
print("[archive] WARN: skipped files without an H1 heading:")
|
||||
for path in plan.skipped_without_h1:
|
||||
print(f" - {_rel_or_abs(path, cwd)}")
|
||||
|
||||
|
||||
def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
|
||||
verb = "would archive" if dry_run else "archived"
|
||||
rm_verb = "would delete" if dry_run else "deleted"
|
||||
for path, title in plan.archived:
|
||||
rel = _rel_or_abs(path, cwd)
|
||||
print(f"[archive] {verb}: {rel} -> '{title}'")
|
||||
if not dry_run:
|
||||
print(f"[archive] {rm_verb}: {rel}")
|
||||
|
||||
|
||||
def _rel_or_abs(path: Path, cwd: Path) -> str:
|
||||
try:
|
||||
return path.resolve().relative_to(cwd).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def handle_archive(args, _ctx: CLIContext) -> None:
|
||||
directory = Path(args.directory).resolve()
|
||||
readme = (
|
||||
Path(args.readme).resolve()
|
||||
if args.readme
|
||||
else (directory / "README.md").resolve()
|
||||
)
|
||||
|
||||
try:
|
||||
plan = run_archive(
|
||||
directory=directory,
|
||||
readme_path=readme,
|
||||
dry_run=args.dry_run,
|
||||
include_template=args.include_template,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"[archive] ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
cwd = Path.cwd().resolve()
|
||||
_print_summary(directory, readme, plan, args.dry_run)
|
||||
_print_skips(plan, cwd)
|
||||
_print_actions(plan, cwd, args.dry_run)
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.branch import close_branch, drop_branch, open_branch
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.branch import open_branch, close_branch, drop_branch
|
||||
|
||||
|
||||
def handle_branch(args, ctx: CLIContext) -> None:
|
||||
|
||||
@@ -2,23 +2,23 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from pkgmgr.actions.changelog import generate_changelog
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.git import GitRunError
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import extract_semver_from_tags
|
||||
from pkgmgr.actions.changelog import generate_changelog
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
Repository = dict[str, Any]
|
||||
|
||||
|
||||
def _find_previous_and_current_tag(
|
||||
tags: List[str],
|
||||
target_tag: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
tags: list[str],
|
||||
target_tag: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Given a list of tags and an optional target tag, determine
|
||||
(previous_tag, current_tag) on the SemVer axis.
|
||||
@@ -65,7 +65,7 @@ def _find_previous_and_current_tag(
|
||||
def handle_changelog(
|
||||
args,
|
||||
ctx: CLIContext,
|
||||
selected: List[Repository],
|
||||
selected: list[Repository],
|
||||
) -> None:
|
||||
"""
|
||||
Handle the 'changelog' command.
|
||||
@@ -94,7 +94,7 @@ def handle_changelog(
|
||||
if not repo_dir:
|
||||
try:
|
||||
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
|
||||
except Exception:
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
repo_dir = None
|
||||
|
||||
identifier = get_repo_identifier(repo, ctx.all_repositories)
|
||||
@@ -114,12 +114,12 @@ def handle_changelog(
|
||||
|
||||
try:
|
||||
tags = get_tags(cwd=repo_dir)
|
||||
except Exception as exc:
|
||||
except GitRunError as exc:
|
||||
print(f"[ERROR] Could not read git tags: {exc}")
|
||||
tags = []
|
||||
|
||||
from_ref: Optional[str] = None
|
||||
to_ref: Optional[str] = None
|
||||
from_ref: str | None = None
|
||||
to_ref: str | None = None
|
||||
|
||||
if range_arg:
|
||||
# Explicit range provided
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user