Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
80329b85fb Release version 0.7.1 2025-12-09 15:26:56 +01:00
Kevin Veen-Birkenbach
44ff0a6cd9 Release version 0.7.0 2025-12-09 15:21:06 +01:00
Kevin Veen-Birkenbach
e00b1a7b69 Solved import bug 2025-12-09 15:03:31 +01:00
Kevin Veen-Birkenbach
14f0188efd Solved e2e naming bugs 2025-12-09 15:02:04 +01:00
Kevin Veen-Birkenbach
a4efb847ba Cleaned Up tests 2025-12-09 14:33:32 +01:00
Kevin Veen-Birkenbach
d50891dfe5 Refactor: Restructure pkgmgr into actions/, core/, and cli/ (full module breakup)
This commit introduces a large-scale structural refactor of the pkgmgr
codebase. All functionality has been moved from the previous flat
top-level layout into three clearly separated namespaces:

  • pkgmgr.actions      – high-level operations invoked by the CLI
  • pkgmgr.core         – pure logic, helpers, repository utilities,
                          versioning, git helpers, config IO, and
                          command resolution
  • pkgmgr.cli          – parser, dispatch, context, and command
                          handlers

Key improvements:
  - Moved all “branch”, “release”, “changelog”, repo-management
    actions, installer pipelines, and proxy execution logic into
    pkgmgr.actions.<domain>.
  - Reworked installer structure under
        pkgmgr.actions.repository.install.installers
    including OS-package installers, Nix, Python, and Makefile.
  - Consolidated all low-level functionality under pkgmgr.core:
        • git helpers → core/git
        • config load/save → core/config
        • repository helpers → core/repository
        • versioning & semver → core/version
        • command helpers (alias, resolve, run, ink) → core/command
  - Replaced pkgmgr.cli_core with pkgmgr.cli and updated all imports.
  - Added minimal __init__.py files for clean package exposure.
  - Updated all E2E, integration, and unit tests with new module paths.
  - Fixed patch targets so mocks point to the new structure.
  - Ensured backward compatibility at the CLI boundary (pkgmgr entry point unchanged).

This refactor produces a cleaner, layered architecture:
  - `core` = logic
  - `actions` = orchestrated behaviour
  - `cli` = user interface

Reference: ChatGPT-assisted refactor discussion
https://chatgpt.com/share/6938221c-e24c-800f-8317-7732cedf39b9
2025-12-09 14:20:19 +01:00
124 changed files with 1609 additions and 582 deletions

View File

@@ -1,3 +1,13 @@
## [0.7.1] - 2025-12-09
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
## [0.7.0] - 2025-12-09
* Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff)
## [0.6.0] - 2025-12-09
* Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile.

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=0.6.0
pkgver=0.7.1
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

12
debian/changelog vendored
View File

@@ -1,3 +1,15 @@
package-manager (0.7.1-1) unstable; urgency=medium
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:26:54 +0100
package-manager (0.7.0-1) unstable; urgency=medium
* Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff)
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:21:03 +0100
package-manager (0.6.0-1) unstable; urgency=medium
* Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile.

View File

@@ -31,7 +31,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "0.6.0";
version = "0.7.1";
# Use the git repo as source
src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.6.0
Version: 0.7.1
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake

View File

@@ -6,14 +6,14 @@
High-level helpers for branch-related operations.
This module encapsulates the actual Git logic so the CLI layer
(pkgmgr.cli_core.commands.branch) stays thin and testable.
(pkgmgr.cli.commands.branch) stays thin and testable.
"""
from __future__ import annotations
from typing import Optional
from pkgmgr.git_utils import run_git, GitError, get_current_branch
from pkgmgr.core.git import run_git, GitError, get_current_branch
def open_branch(

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
from typing import Optional
from pkgmgr.git_utils import run_git, GitError
from pkgmgr.core.git import run_git, GitError
def generate_changelog(

View File

@@ -26,8 +26,8 @@ import os
import subprocess
from typing import Any, Dict
from pkgmgr.generate_alias import generate_alias
from pkgmgr.save_user_config import save_user_config
from pkgmgr.core.command.alias import generate_alias
from pkgmgr.core.config.save import save_user_config
def config_init(

View File

@@ -1,5 +1,5 @@
import yaml
from .load_config import load_config
from pkgmgr.core.config.load import load_config
def show_config(selected_repos, user_config_path, full_config=False):
"""Display configuration for one or more repositories, or the entire merged config."""

View File

@@ -1,7 +1,7 @@
import os
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.run_command import run_command
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
def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir, all_repos, proxy_command: str, extra_args, preview: bool):

View File

@@ -39,9 +39,9 @@ import tempfile
from datetime import date, datetime
from typing import Optional, Tuple
from pkgmgr.git_utils import get_tags, get_current_branch, GitError
from pkgmgr.branch_commands import close_branch
from pkgmgr.versioning import (
from pkgmgr.core.git import get_tags, get_current_branch, GitError
from pkgmgr.actions.branch import close_branch
from pkgmgr.core.version.semver import (
SemVer,
find_latest_version,
bump_major,

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Release helper for pkgmgr (public entry point).
This package provides the high-level `release()` function used by the
pkgmgr CLI to perform versioned releases:
- Determine the next semantic version based on existing Git tags.
- Update pyproject.toml with the new version.
- Update additional packaging files (flake.nix, PKGBUILD,
debian/changelog, RPM spec) where present.
- Prepend a basic entry to CHANGELOG.md.
- Move the floating 'latest' tag to the newly created release tag so
the newest release is always marked as latest.
Additional behaviour:
- If `preview=True` (from --preview), no files are written and no
Git commands are executed. Instead, a detailed summary of the
planned changes and commands is printed.
- If `preview=False` and not forced, the release is executed in two
phases:
1) Preview-only run (dry-run).
2) Interactive confirmation, then real release if confirmed.
This confirmation can be skipped with the `force=True` flag.
- Before creating and pushing tags, main/master is updated from origin
when the release is performed on one of these branches.
- If `close=True` is used and the current branch is not main/master,
the branch will be closed via branch_commands.close_branch() after
a successful release.
"""
from __future__ import annotations
import os
import sys
from typing import Optional
from pkgmgr.core.git import get_current_branch, GitError
from pkgmgr.actions.branch import close_branch
from .versioning import determine_current_version, bump_semver
from .git_ops import run_git_command, sync_branch_with_remote, update_latest_tag
from .files import (
update_pyproject_version,
update_flake_version,
update_pkgbuild_version,
update_spec_version,
update_changelog,
update_debian_changelog,
)
# ---------------------------------------------------------------------------
# Internal implementation (single-phase, preview or real)
# ---------------------------------------------------------------------------
def _release_impl(
pyproject_path: str = "pyproject.toml",
changelog_path: str = "CHANGELOG.md",
release_type: str = "patch",
message: Optional[str] = None,
preview: bool = False,
close: bool = False,
) -> None:
"""
Internal implementation that performs a single-phase release.
"""
current_ver = determine_current_version()
new_ver = bump_semver(current_ver, release_type)
new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True)
mode = "PREVIEW" if preview else "REAL"
print(f"Release mode: {mode}")
print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})")
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
# Update core project metadata and packaging files
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
changelog_message = update_changelog(
changelog_path,
new_ver_str,
message=message,
preview=preview,
)
flake_path = os.path.join(repo_root, "flake.nix")
update_flake_version(flake_path, new_ver_str, preview=preview)
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
spec_path = os.path.join(repo_root, "package-manager.spec")
update_spec_version(spec_path, new_ver_str, preview=preview)
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
effective_message = changelog_message.strip()
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
package_name = os.path.basename(repo_root) or "package-manager"
update_debian_changelog(
debian_changelog_path,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
commit_msg = f"Release version {new_ver_str}"
tag_msg = effective_message or commit_msg
# Determine branch and ensure it is up to date if main/master
try:
branch = get_current_branch() or "main"
except GitError:
branch = "main"
print(f"Releasing on branch: {branch}")
# Ensure main/master are up-to-date from origin before creating and
# pushing tags. For other branches we only log the intent.
sync_branch_with_remote(branch, preview=preview)
files_to_add = [
pyproject_path,
changelog_path,
flake_path,
pkgbuild_path,
spec_path,
debian_changelog_path,
]
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
if preview:
for path in existing_files:
print(f"[PREVIEW] Would run: git add {path}")
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
print(f"[PREVIEW] Would run: git push origin {branch}")
print("[PREVIEW] Would run: git push origin --tags")
# Also update the floating 'latest' tag to the new highest SemVer.
update_latest_tag(new_tag, preview=True)
if close and branch not in ("main", "master"):
print(
f"[PREVIEW] Would also close branch {branch} after the release "
"(close=True and branch is not main/master)."
)
elif close:
print(
f"[PREVIEW] close=True but current branch is {branch}; "
"no branch would be closed."
)
print("Preview completed. No changes were made.")
return
for path in existing_files:
run_git_command(f"git add {path}")
run_git_command(f'git commit -am "{commit_msg}"')
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
run_git_command(f"git push origin {branch}")
run_git_command("git push origin --tags")
# Move 'latest' to the new release tag so the newest SemVer is always
# marked as latest. This is best-effort and must not break the release.
try:
update_latest_tag(new_tag, preview=False)
except GitError as exc: # pragma: no cover
print(
f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}\n"
"[WARN] The release itself completed successfully; only the "
"'latest' tag was not updated."
)
print(f"Release {new_ver_str} completed.")
if close:
if branch in ("main", "master"):
print(
f"[INFO] close=True but current branch is {branch}; "
"nothing to close."
)
return
print(
f"[INFO] Closing branch {branch} after successful release "
"(close=True and branch is not main/master)..."
)
try:
close_branch(name=branch, base_branch="main", cwd=".")
except Exception as exc: # pragma: no cover
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
# ---------------------------------------------------------------------------
# Public release entry point
# ---------------------------------------------------------------------------
def release(
pyproject_path: str = "pyproject.toml",
changelog_path: str = "CHANGELOG.md",
release_type: str = "patch",
message: Optional[str] = None,
preview: bool = False,
force: bool = False,
close: bool = False,
) -> None:
"""
High-level release entry point.
Modes:
- preview=True:
* Single-phase PREVIEW only.
- preview=False, force=True:
* Single-phase REAL release, no interactive preview.
- preview=False, force=False:
* Two-phase flow (intended default for interactive CLI use).
"""
if preview:
_release_impl(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=release_type,
message=message,
preview=True,
close=close,
)
return
if force:
_release_impl(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=release_type,
message=message,
preview=False,
close=close,
)
return
if not sys.stdin.isatty():
_release_impl(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=release_type,
message=message,
preview=False,
close=close,
)
return
print("[INFO] Running preview before actual release...\n")
_release_impl(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=release_type,
message=message,
preview=True,
close=close,
)
try:
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n[INFO] Release aborted (no confirmation).")
return
if answer not in ("y", "yes"):
print("Release aborted by user. No changes were made.")
return
print("\n[INFO] Running REAL release...\n")
_release_impl(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=release_type,
message=message,
preview=False,
close=close,
)
__all__ = ["release"]

View File

@@ -0,0 +1,444 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
File and metadata update helpers for the release workflow.
Responsibilities:
- Update pyproject.toml with the new version.
- Update flake.nix, PKGBUILD, RPM spec files where present.
- Prepend release entries to CHANGELOG.md.
- Maintain debian/changelog entries, including maintainer metadata.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
from datetime import date, datetime
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Editor helper for interactive changelog messages
# ---------------------------------------------------------------------------
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
"""
Open $EDITOR (fallback 'nano') so the user can enter a changelog message.
The temporary file is pre-filled with commented instructions and an
optional initial_message. Lines starting with '#' are ignored when the
message is read back.
Returns the final message (may be empty string if user leaves it blank).
"""
editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
mode="w+",
delete=False,
encoding="utf-8",
) 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"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
try:
subprocess.call([editor, tmp_path])
except FileNotFoundError:
print(
f"[WARN] Editor {editor!r} not found; proceeding without "
"interactive changelog message."
)
try:
with open(tmp_path, "r", encoding="utf-8") as f:
content = f.read()
finally:
try:
os.remove(tmp_path)
except OSError:
pass
lines = [
line for line in content.splitlines()
if not line.strip().startswith("#")
]
return "\n".join(lines).strip()
# ---------------------------------------------------------------------------
# File update helpers (pyproject + extra packaging + changelog)
# ---------------------------------------------------------------------------
def update_pyproject_version(
pyproject_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in pyproject.toml with the new version.
The function looks for a line matching:
version = "X.Y.Z"
and replaces the version part with the given new_version string.
"""
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"[ERROR] pyproject.toml not found at: {pyproject_path}")
sys.exit(1)
pattern = r'^(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
flags=re.MULTILINE,
)
if count == 0:
print("[ERROR] Could not find version line in pyproject.toml")
sys.exit(1)
if preview:
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
return
with open(pyproject_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated pyproject.toml version to {new_version}")
def update_flake_version(
flake_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in flake.nix, if present.
"""
if not os.path.exists(flake_path):
print("[INFO] flake.nix not found, skipping.")
return
try:
with open(flake_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read flake.nix: {exc}")
return
pattern = r'(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
)
if count == 0:
print("[WARN] No version assignment found in flake.nix, skipping.")
return
if preview:
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
return
with open(flake_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated flake.nix version to {new_version}")
def update_pkgbuild_version(
pkgbuild_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in PKGBUILD, if present.
Expects:
pkgver=1.2.3
pkgrel=1
"""
if not os.path.exists(pkgbuild_path):
print("[INFO] PKGBUILD not found, skipping.")
return
try:
with open(pkgbuild_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No pkgver line found in PKGBUILD.")
new_content = content
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
new_content, rel_count = re.subn(
rel_pattern,
lambda m: f"{m.group(1)}1",
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No pkgrel line found in PKGBUILD.")
if preview:
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
return
with open(pkgbuild_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
def update_spec_version(
spec_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in an RPM spec file, if present.
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file: {exc}")
return
ver_pattern = r"^(Version:\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No 'Version:' line found in spec file.")
rel_pattern = r"^(Release:\s*)(.+)$"
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
rest = m.group(2).strip()
match = re.match(r"^(\d+)(.*)$", rest)
if match:
suffix = match.group(2)
else:
suffix = ""
return f"{m.group(1)}1{suffix}"
new_content, rel_count = re.subn(
rel_pattern,
_release_repl,
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No 'Release:' line found in spec file.")
if preview:
print(
f"[PREVIEW] Would update spec file "
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
)
return
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(
f"Updated spec file {os.path.basename(spec_path)} "
f"to Version: {new_version}, Release: 1..."
)
def update_changelog(
changelog_path: str,
new_version: str,
message: Optional[str] = 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()
if message is None:
if preview:
message = "Automated release."
else:
print(
"\n[INFO] No release message provided, opening editor for "
"changelog entry...\n"
)
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"
if os.path.exists(changelog_path):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
return message
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
# ---------------------------------------------------------------------------
# Debian changelog helpers (with Git config fallback for maintainer)
# ---------------------------------------------------------------------------
def _get_git_config_value(key: str) -> Optional[str]:
"""
Try to read a value from `git config --get <key>`.
"""
try:
result = subprocess.run(
["git", "config", "--get", key],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
value = result.stdout.strip()
return value or None
def _get_debian_author() -> Tuple[str, str]:
"""
Determine the maintainer name/email for debian/changelog entries.
"""
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
if not name:
name = os.environ.get("GIT_AUTHOR_NAME")
if not email:
email = os.environ.get("GIT_AUTHOR_EMAIL")
if not name:
name = _get_git_config_value("user.name")
if not email:
email = _get_git_config_value("user.email")
if not name:
name = "Unknown Maintainer"
if not email:
email = "unknown@example.com"
return name, email
def update_debian_changelog(
debian_changelog_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
"""
Prepend a new entry to debian/changelog, if it exists.
"""
if not os.path.exists(debian_changelog_path):
print("[INFO] debian/changelog not found, skipping.")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
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}."
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n"
)
if preview:
print(
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
f"{stanza}"
)
return
try:
with open(debian_changelog_path, "r", encoding="utf-8") as f:
existing = f.read()
except Exception as exc:
print(f"[WARN] Could not read debian/changelog: {exc}")
existing = ""
new_content = stanza + existing
with open(debian_changelog_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated debian/changelog with version {debian_version}")

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Git-related helpers for the release workflow.
Responsibilities:
- Run Git (or shell) commands with basic error reporting.
- Ensure main/master are synchronized with origin before tagging.
- Maintain the floating 'latest' tag that always points to the newest
release tag.
"""
from __future__ import annotations
import subprocess
from pkgmgr.core.git import GitError
def run_git_command(cmd: str) -> None:
"""
Run a Git (or shell) command with basic error reporting.
The command is executed via the shell, primarily for readability
when printed (as in 'git commit -am "msg"').
"""
print(f"[GIT] {cmd}")
try:
subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as exc:
print(f"[ERROR] Git command failed: {cmd}")
print(f" Exit code: {exc.returncode}")
if exc.stdout:
print("--- stdout ---")
print(exc.stdout)
if exc.stderr:
print("--- stderr ---")
print(exc.stderr)
raise GitError(f"Git command failed: {cmd}") from exc
def sync_branch_with_remote(branch: str, preview: bool = False) -> None:
"""
Ensure the local main/master branch is up-to-date before tagging.
Behaviour:
- For main/master: run 'git fetch origin' and 'git pull origin <branch>'.
- For all other branches: only log that no automatic sync is performed.
"""
if branch not in ("main", "master"):
print(
f"[INFO] Skipping automatic git pull for non-main/master branch "
f"{branch}."
)
return
print(
f"[INFO] Updating branch {branch} from origin before creating tags..."
)
if preview:
print("[PREVIEW] Would run: git fetch origin")
print(f"[PREVIEW] Would run: git pull origin {branch}")
return
run_git_command("git fetch origin")
run_git_command(f"git pull origin {branch}")
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
"""
Move the floating 'latest' tag to the newly created release tag.
Implementation details:
- We explicitly dereference the tag object via `<tag>^{}` so that
'latest' always points at the underlying commit, not at another tag.
- We create/update 'latest' as an annotated tag with a short message so
Git configurations that enforce annotated/signed tags do not fail
with "no tag message".
"""
target_ref = f"{new_tag}^{{}}"
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
if preview:
print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} "
f'-m "Floating latest tag for {new_tag}"')
print("[PREVIEW] Would run: git push origin latest --force")
return
run_git_command(
f'git tag -f -a latest {target_ref} '
f'-m "Floating latest tag for {new_tag}"'
)
run_git_command("git push origin latest --force")

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Version discovery and bumping helpers for the release workflow.
"""
from __future__ import annotations
from pkgmgr.core.git import get_tags
from pkgmgr.core.version.semver import (
SemVer,
find_latest_version,
bump_major,
bump_minor,
bump_patch,
)
def determine_current_version() -> SemVer:
"""
Determine the current semantic version from Git tags.
Behaviour:
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
- Otherwise, use the latest SemVer tag as current version.
"""
tags = get_tags()
if not tags:
return SemVer(0, 0, 0)
latest = find_latest_version(tags)
if latest is None:
return SemVer(0, 0, 0)
_tag, ver = latest
return ver
def bump_semver(current: SemVer, release_type: str) -> SemVer:
"""
Bump the given SemVer according to the release type.
release_type must be one of: "major", "minor", "patch".
"""
if release_type == "major":
return bump_major(current)
if release_type == "minor":
return bump_minor(current)
if release_type == "patch":
return bump_patch(current)
raise ValueError(f"Unknown release type: {release_type!r}")

View File

@@ -1,8 +1,8 @@
import subprocess
import os
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.verify import verify_repository
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
def clone_repos(
selected_repos,

View File

@@ -2,8 +2,8 @@ import os
import subprocess
import sys
import yaml
from pkgmgr.generate_alias import generate_alias
from pkgmgr.save_user_config import save_user_config
from pkgmgr.core.command.alias import generate_alias
from pkgmgr.core.config.save import save_user_config
def create_repo(identifier, config_merged, user_config_path, bin_dir, remote=False, preview=False):
"""

View File

@@ -1,7 +1,7 @@
import os
import sys
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
def deinstall_repos(selected_repos, repositories_base_dir, bin_dir, all_repos, preview=False):
for repo in selected_repos:

View File

@@ -1,7 +1,7 @@
import shutil
import os
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
def delete_repos(selected_repos, repositories_base_dir, all_repos, preview=False):
for repo in selected_repos:

View File

@@ -21,23 +21,23 @@ focused installer classes.
import os
from typing import List, Dict, Any
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.create_ink import create_ink
from pkgmgr.verify import verify_repository
from pkgmgr.clone_repos import clone_repos
from pkgmgr.context import RepoContext
from pkgmgr.resolve_command import resolve_command_for_repo
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.command.ink import create_ink
from pkgmgr.core.repository.verify import verify_repository
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.core.command.resolve import resolve_command_for_repo
# Installer implementations
from pkgmgr.installers.os_packages import (
from pkgmgr.actions.repository.install.installers.os_packages import (
ArchPkgbuildInstaller,
DebianControlInstaller,
RpmSpecInstaller,
)
from pkgmgr.installers.nix_flake import NixFlakeInstaller
from pkgmgr.installers.python import PythonInstaller
from pkgmgr.installers.makefile import MakefileInstaller
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
# Layering:

View File

@@ -38,7 +38,7 @@ from abc import ABC, abstractmethod
from typing import Iterable, TYPE_CHECKING
if TYPE_CHECKING:
from pkgmgr.context import RepoContext
from pkgmgr.actions.repository.install.context import RepoContext
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer package for pkgmgr.
This exposes all installer classes so users can import them directly from
pkgmgr.actions.repository.install.installers.
"""
from pkgmgr.actions.repository.install.installers.base import BaseInstaller # noqa: F401
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller # noqa: F401
from pkgmgr.actions.repository.install.installers.python import PythonInstaller # noqa: F401
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller # noqa: F401
# OS-specific installers
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401

View File

@@ -8,8 +8,8 @@ Base interface for all installer components in the pkgmgr installation pipeline.
from abc import ABC, abstractmethod
from typing import Set
from pkgmgr.context import RepoContext
from pkgmgr.capabilities import CAPABILITY_MATCHERS
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.capabilities import CAPABILITY_MATCHERS
class BaseInstaller(ABC):

View File

@@ -12,9 +12,9 @@ installation step.
import os
import re
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
class MakefileInstaller(BaseInstaller):

View File

@@ -19,12 +19,12 @@ import os
import shutil
from typing import TYPE_CHECKING
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
if TYPE_CHECKING:
from pkgmgr.context import RepoContext
from pkgmgr.install_repos import InstallContext
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install import InstallContext
class NixFlakeInstaller(BaseInstaller):

View File

@@ -3,9 +3,9 @@
import os
import shutil
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
class ArchPkgbuildInstaller(BaseInstaller):

View File

@@ -20,9 +20,9 @@ import shutil
from typing import List
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
class DebianControlInstaller(BaseInstaller):

View File

@@ -19,9 +19,9 @@ import shutil
from typing import List, Optional
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
class RpmSpecInstaller(BaseInstaller):

View File

@@ -17,8 +17,8 @@ All installation failures are treated as fatal errors (SystemExit).
import os
import sys
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
class PythonInstaller(BaseInstaller):

View File

@@ -1,9 +1,9 @@
import os
import subprocess
import sys
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.verify import verify_repository
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
def pull_with_verification(
selected_repos,

View File

@@ -1,9 +1,9 @@
import sys
import shutil
from .exec_proxy_command import exec_proxy_command
from .run_command import run_command
from .get_repo_identifier import get_repo_identifier
from pkgmgr.actions.proxy import exec_proxy_command
from pkgmgr.core.command.run import run_command
from pkgmgr.core.repository.identifier import get_repo_identifier
def status_repos(

View File

@@ -1,8 +1,8 @@
import sys
import shutil
from pkgmgr.pull_with_verification import pull_with_verification
from pkgmgr.install_repos import install_repos
from pkgmgr.actions.repository.pull import pull_with_verification
from pkgmgr.actions.repository.install import install_repos
def update_repos(
@@ -54,7 +54,7 @@ def update_repos(
)
if system_update:
from pkgmgr.run_command import run_command
from pkgmgr.core.command.run import run_command
# Nix: upgrade all profile entries (if Nix is available)
if shutil.which("nix") is not None:

13
pkgmgr/cli.py → pkgmgr/cli/__init__.py Executable file → Normal file
View File

@@ -1,13 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import sys
from pkgmgr.load_config import load_config
from pkgmgr.cli_core import CLIContext, create_parser, dispatch_command
from pkgmgr.core.config.load import load_config
from .context import CLIContext
from .parser import create_parser
from .dispatch import dispatch_command
__all__ = ["CLIContext", "create_parser", "dispatch_command", "main"]
# User config lives in the home directory:
# ~/.config/pkgmgr/config.yaml

View File

@@ -1,10 +1,9 @@
# pkgmgr/cli_core/commands/branch.py
from __future__ import annotations
import sys
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.branch_commands import open_branch, close_branch
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.branch import open_branch, close_branch
def handle_branch(args, ctx: CLIContext) -> None:

View File

@@ -4,12 +4,12 @@ import os
import sys
from typing import Any, Dict, List, Optional, Tuple
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.git_utils import get_tags
from pkgmgr.versioning import SemVer, extract_semver_from_tags
from pkgmgr.changelog import generate_changelog
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.git import get_tags
from pkgmgr.core.version.semver import SemVer, extract_semver_from_tags
from pkgmgr.actions.changelog import generate_changelog
Repository = Dict[str, Any]

View File

@@ -11,13 +11,13 @@ from typing import Any, Dict
import yaml
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.config_init import config_init
from pkgmgr.interactive_add import interactive_add
from pkgmgr.resolve_repos import resolve_repos
from pkgmgr.save_user_config import save_user_config
from pkgmgr.show_config import show_config
from pkgmgr.run_command import run_command
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.config.init import config_init
from pkgmgr.actions.config.add import interactive_add
from pkgmgr.core.repository.resolve import resolve_repos
from pkgmgr.core.config.save import save_user_config
from pkgmgr.actions.config.show import show_config
from pkgmgr.core.command.run import run_command
def _load_user_config(user_config_path: str) -> Dict[str, Any]:

View File

@@ -3,8 +3,8 @@ from __future__ import annotations
import sys
from typing import Any, Dict, List
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.exec_proxy_command import exec_proxy_command
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.proxy import exec_proxy_command
Repository = Dict[str, Any]

View File

@@ -1,4 +1,3 @@
# pkgmgr/cli_core/commands/release.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -6,14 +5,14 @@
Release command wiring for the pkgmgr CLI.
This module implements the `pkgmgr release` subcommand on top of the
generic selection logic from cli_core.dispatch. It does not define its
own subparser; the CLI surface is configured in cli_core.parser.
generic selection logic from cli.dispatch. It does not define its
own subparser; the CLI surface is configured in cli.parser.
Responsibilities:
- Take the parsed argparse.Namespace for the `release` command.
- Use the list of selected repositories provided by dispatch_command().
- Optionally list affected repositories when --list is set.
- For each selected repository, run pkgmgr.release.release(...) in
- For each selected repository, run pkgmgr.actions.release.release(...) in
the context of that repository directory.
"""
@@ -22,10 +21,10 @@ from __future__ import annotations
import os
from typing import Any, Dict, List
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.release import release as run_release
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.actions.release import release as run_release
Repository = Dict[str, Any]
@@ -46,7 +45,7 @@ def handle_release(
3) For each selected repository:
- Resolve its identifier and local directory.
- Change into that directory.
- Call pkgmgr.release.release(...) with the parsed options.
- Call pkgmgr.actions.release.release(...) with the parsed options.
"""
if not selected:
print("[pkgmgr] No repositories selected for release.")

View File

@@ -6,16 +6,16 @@ from __future__ import annotations
import sys
from typing import Any, Dict, List
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.install_repos import install_repos
from pkgmgr.deinstall_repos import deinstall_repos
from pkgmgr.delete_repos import delete_repos
from pkgmgr.update_repos import update_repos
from pkgmgr.status_repos import status_repos
from pkgmgr.list_repositories import list_repositories
from pkgmgr.run_command import run_command
from pkgmgr.create_repo import create_repo
from pkgmgr.get_selected_repos import get_selected_repos
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.repository.install import install_repos
from pkgmgr.actions.repository.deinstall import deinstall_repos
from pkgmgr.actions.repository.delete import delete_repos
from pkgmgr.actions.repository.update import update_repos
from pkgmgr.actions.repository.status import status_repos
from pkgmgr.actions.repository.list import list_repositories
from pkgmgr.core.command.run import run_command
from pkgmgr.actions.repository.create import create_repo
from pkgmgr.core.repository.selected import get_selected_repos
Repository = Dict[str, Any]

View File

@@ -5,9 +5,9 @@ import os
from typing import Any, Dict, List
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.run_command import run_command
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.command.run import run_command
from pkgmgr.core.repository.identifier import get_repo_identifier
Repository = Dict[str, Any]

View File

@@ -4,12 +4,12 @@ import os
import sys
from typing import Any, Dict, List, Optional, Tuple
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.git_utils import get_tags
from pkgmgr.versioning import SemVer, find_latest_version
from pkgmgr.version_sources import (
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.git import get_tags
from pkgmgr.core.version.semver import SemVer, find_latest_version
from pkgmgr.core.version.source import (
read_pyproject_version,
read_flake_version,
read_pkgbuild_version,

View File

@@ -7,12 +7,12 @@ import os
import sys
from typing import List, Dict, Any
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.cli_core.proxy import maybe_handle_proxy
from pkgmgr.get_selected_repos import get_selected_repos
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.cli.context import CLIContext
from pkgmgr.cli.proxy import maybe_handle_proxy
from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.cli_core.commands import (
from pkgmgr.cli.commands import (
handle_repos_command,
handle_tools_command,
handle_release,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import argparse
from pkgmgr.cli_core.proxy import register_proxy_commands
from pkgmgr.cli.proxy import register_proxy_commands
class SortedSubParsersAction(argparse._SubParsersAction):

View File

@@ -8,12 +8,12 @@ import os
import sys
from typing import Dict, List, Any
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.clone_repos import clone_repos
from pkgmgr.exec_proxy_command import exec_proxy_command
from pkgmgr.pull_with_verification import pull_with_verification
from pkgmgr.get_selected_repos import get_selected_repos
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.proxy import exec_proxy_command
from pkgmgr.actions.repository.pull import pull_with_verification
from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
PROXY_COMMANDS: Dict[str, List[str]] = {

View File

@@ -1,5 +0,0 @@
from .context import CLIContext
from .parser import create_parser
from .dispatch import dispatch_command
__all__ = ["CLIContext", "create_parser", "dispatch_command"]

View File

@@ -2,8 +2,8 @@
# -*- coding: utf-8 -*-
import os
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
def create_ink(repo, repositories_base_dir, bin_dir, all_repos,

View File

View File

View File

@@ -7,7 +7,7 @@ import os
import re
from typing import Any, Dict, List, Sequence
from pkgmgr.resolve_repos import resolve_repos
from pkgmgr.core.repository.resolve import resolve_repos
Repository = Dict[str, Any]

View File

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer package for pkgmgr.
This exposes all installer classes so users can import them directly from
pkgmgr.installers.
"""
from pkgmgr.installers.base import BaseInstaller # noqa: F401
from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401
from pkgmgr.installers.python import PythonInstaller # noqa: F401
from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401
# OS-specific installers
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "package-manager"
version = "0.6.0"
version = "0.7.1"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -31,7 +31,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
finally:
sys.argv = original_argv
@patch("pkgmgr.cli_core.commands.branch.open_branch")
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch) -> None:
"""
`pkgmgr branch open feature/test --base develop` must forward
@@ -47,7 +47,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli_core.commands.branch.open_branch")
@patch("pkgmgr.cli.commands.branch.open_branch")
def test_branch_open_without_name_uses_default_base(
self,
mock_open_branch,
@@ -68,7 +68,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
# close subcommand
# ------------------------------------------------------------------
@patch("pkgmgr.cli_core.commands.branch.close_branch")
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_with_name_and_base(self, mock_close_branch) -> None:
"""
`pkgmgr branch close feature/test --base develop` must forward
@@ -84,7 +84,7 @@ class TestIntegrationBranchCommands(unittest.TestCase):
self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.cli_core.commands.branch.close_branch")
@patch("pkgmgr.cli.commands.branch.close_branch")
def test_branch_close_without_name_uses_default_base(
self,
mock_close_branch,

View File

@@ -1,4 +1,3 @@
# tests/e2e/test_integration_changelog_commands.py
from __future__ import annotations
import os
@@ -6,7 +5,7 @@ import runpy
import sys
import unittest
from test_integration_version_commands import (
from test_version_commands import (
_load_pkgmgr_repo_dir,
PROJECT_ROOT,
)
@@ -54,7 +53,7 @@ class TestIntegrationChangelogCommands(unittest.TestCase):
sys.argv = ["pkgmgr", "changelog"] + list(extra_args)
try:
runpy.run_module("pkgmgr.cli", run_name="__main__")
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else str(exc.code)
if code != 0:

View File

@@ -14,7 +14,7 @@ import runpy
import sys
import unittest
from test_integration_install_pkgmgr_shallow import (
from test_install_pkgmgr_shallow import (
nix_profile_list_debug,
remove_pkgmgr_from_nix_profile,
pkgmgr_help_debug,

View File

@@ -5,7 +5,7 @@ import runpy
import sys
import unittest
from test_integration_version_commands import PROJECT_ROOT
from test_version_commands import PROJECT_ROOT
class TestIntegrationListCommands(unittest.TestCase):

View File

@@ -15,7 +15,7 @@ import runpy
import sys
import unittest
from test_integration_version_commands import _load_pkgmgr_repo_dir
from test_version_commands import _load_pkgmgr_repo_dir
class TestIntegrationMakeCommands(unittest.TestCase):

View File

@@ -5,7 +5,7 @@ import runpy
import sys
import unittest
from test_integration_version_commands import PROJECT_ROOT
from test_version_commands import PROJECT_ROOT
class TestIntegrationProxyCommands(unittest.TestCase):

View File

@@ -6,7 +6,7 @@ End-to-end style integration tests for the `pkgmgr release` CLI command.
These tests exercise the real top-level entry point (main.py) and mock
the high-level helper used by the CLI wiring
(pkgmgr.cli_core.commands.release.run_release) to ensure that argument
(pkgmgr.cli.commands.release.run_release) to ensure that argument
parsing and dispatch behave as expected, in particular the new `close`
flag.
@@ -52,8 +52,8 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
# Behaviour without --close
# ------------------------------------------------------------------
@patch("pkgmgr.cli_core.commands.release.run_release")
@patch("pkgmgr.cli_core.dispatch._select_repo_for_current_directory")
@patch("pkgmgr.cli.commands.release.run_release")
@patch("pkgmgr.cli.dispatch._select_repo_for_current_directory")
def test_release_without_close_flag(
self,
mock_select_repo,
@@ -95,8 +95,8 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
# Behaviour with --close
# ------------------------------------------------------------------
@patch("pkgmgr.cli_core.commands.release.run_release")
@patch("pkgmgr.cli_core.dispatch._select_repo_for_current_directory")
@patch("pkgmgr.cli.commands.release.run_release")
@patch("pkgmgr.cli.dispatch._select_repo_for_current_directory")
def test_release_with_close_flag(
self,
mock_select_repo,
@@ -133,6 +133,36 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
"close must be True when --close is given",
)
def test_release_help_runs_without_error(self) -> None:
"""
Running `pkgmgr release --help` should succeed without touching the
release helper and print a usage message for the release subcommand.
This test intentionally does not mock anything to exercise the real
CLI parser wiring in main.py.
"""
import io
import contextlib
original_argv = list(sys.argv)
buf = io.StringIO()
try:
sys.argv = ["pkgmgr", "release", "--help"]
# argparse will call sys.exit(), so we expect a SystemExit here.
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
with self.assertRaises(SystemExit) as cm:
runpy.run_module("main", run_name="__main__")
finally:
sys.argv = original_argv
# Help exit code is usually 0 (or sometimes None, which also means "no error")
self.assertIn(cm.exception.code, (0, None))
output = buf.getvalue()
# Sanity checks: release help text should be present
self.assertIn("usage:", output)
self.assertIn("pkgmgr release", output)
if __name__ == "__main__":
unittest.main()

View File

@@ -24,7 +24,7 @@ import runpy
import sys
import unittest
from test_integration_version_commands import _load_pkgmgr_repo_dir
from test_version_commands import _load_pkgmgr_repo_dir
@unittest.skip(

View File

@@ -28,7 +28,7 @@ import sys
import unittest
from typing import List
from pkgmgr.load_config import load_config
from pkgmgr.core.config.load import load_config
# Resolve project root (the repo where main.py lives, e.g. /src)
PROJECT_ROOT = os.path.abspath(

View File

@@ -1,13 +1,11 @@
# tests/integration/test_install_repos_integration.py
import os
import tempfile
import unittest
from unittest.mock import patch
import pkgmgr.install_repos as install_module
from pkgmgr.install_repos import install_repos
from pkgmgr.installers.base import BaseInstaller
import pkgmgr.actions.repository.install as install_module
from pkgmgr.actions.repository.install import install_repos
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
class DummyInstaller(BaseInstaller):
@@ -26,10 +24,10 @@ class DummyInstaller(BaseInstaller):
class TestInstallReposIntegration(unittest.TestCase):
@patch("pkgmgr.install_repos.verify_repository")
@patch("pkgmgr.install_repos.clone_repos")
@patch("pkgmgr.install_repos.get_repo_dir")
@patch("pkgmgr.install_repos.get_repo_identifier")
@patch("pkgmgr.actions.repository.install.verify_repository")
@patch("pkgmgr.actions.repository.install.clone_repos")
@patch("pkgmgr.actions.repository.install.get_repo_dir")
@patch("pkgmgr.actions.repository.install.get_repo_identifier")
def test_system_binary_vs_nix_binary(
self,
mock_get_repo_identifier,
@@ -100,8 +98,8 @@ class TestInstallReposIntegration(unittest.TestCase):
nix_tool_path = "/nix/profile/bin/repo-nix"
# Patch resolve_command_for_repo at the install_repos module level
with patch("pkgmgr.install_repos.resolve_command_for_repo") as mock_resolve, \
patch("pkgmgr.install_repos.os.path.exists") as mock_exists_install:
with patch("pkgmgr.actions.repository.install.resolve_command_for_repo") as mock_resolve, \
patch("pkgmgr.actions.repository.install.os.path.exists") as mock_exists_install:
def fake_resolve_command(repo, repo_identifier: str, repo_dir: str):
"""

View File

@@ -61,12 +61,12 @@ import tempfile
import unittest
from unittest.mock import patch
import pkgmgr.install_repos as install_mod
from pkgmgr.install_repos import install_repos
from pkgmgr.installers.nix_flake import NixFlakeInstaller
from pkgmgr.installers.python import PythonInstaller
from pkgmgr.installers.makefile import MakefileInstaller
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
import pkgmgr.actions.repository.install as install_mod
from pkgmgr.actions.repository.install import install_repos
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):

View File

View File

@@ -4,8 +4,8 @@ import os
import unittest
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
class TestArchPkgbuildInstaller(unittest.TestCase):
@@ -26,7 +26,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
)
self.installer = ArchPkgbuildInstaller()
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which")
def test_supports_true_when_tools_and_pkgbuild_exist(
@@ -46,7 +46,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
self.assertIn("makepkg", calls)
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "PKGBUILD"))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0)
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0)
@patch("os.path.exists", return_value=True)
@patch("shutil.which")
def test_supports_false_when_running_as_root(
@@ -55,7 +55,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
mock_which.return_value = "/usr/bin/pacman"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=False)
@patch("shutil.which")
def test_supports_false_when_pkgbuild_missing(
@@ -64,8 +64,8 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
mock_which.return_value = "/usr/bin/pacman"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command")
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.run_command")
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which")
def test_run_builds_and_installs_with_makepkg(

View File

@@ -4,8 +4,8 @@ import os
import unittest
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller
class TestDebianControlInstaller(unittest.TestCase):
@@ -36,7 +36,7 @@ class TestDebianControlInstaller(unittest.TestCase):
def test_supports_false_without_dpkg_buildpackage(self, mock_which, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.debian_control.run_command")
@patch("pkgmgr.actions.repository.install.installers.os_packages.debian_control.run_command")
@patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"])
@patch("os.path.exists", return_value=True)
@patch("shutil.which")

View File

@@ -3,8 +3,8 @@
import unittest
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller
class TestRpmSpecInstaller(unittest.TestCase):
@@ -45,7 +45,7 @@ class TestRpmSpecInstaller(unittest.TestCase):
mock_which.return_value = "/usr/bin/rpmbuild"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.rpm_spec.run_command")
@patch("pkgmgr.actions.repository.install.installers.os_packages.rpm_spec.run_command")
@patch("glob.glob")
@patch("shutil.which")
def test_run_builds_and_installs_rpms(

View File

@@ -1,8 +1,8 @@
# tests/unit/pkgmgr/installers/test_base.py
import unittest
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.actions.repository.install.context import RepoContext
class DummyInstaller(BaseInstaller):

View File

@@ -4,8 +4,8 @@ import os
import unittest
from unittest.mock import patch, mock_open
from pkgmgr.context import RepoContext
from pkgmgr.installers.makefile import MakefileInstaller
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
class TestMakefileInstaller(unittest.TestCase):
@@ -35,7 +35,7 @@ class TestMakefileInstaller(unittest.TestCase):
def test_supports_false_when_makefile_missing(self, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.makefile.run_command")
@patch("pkgmgr.actions.repository.install.installers.makefile.run_command")
@patch(
"builtins.open",
new_callable=mock_open,
@@ -62,7 +62,7 @@ class TestMakefileInstaller(unittest.TestCase):
self.ctx.repo_dir,
)
@patch("pkgmgr.installers.makefile.run_command")
@patch("pkgmgr.actions.repository.install.installers.makefile.run_command")
@patch(
"builtins.open",
new_callable=mock_open,

View File

@@ -3,8 +3,8 @@ import unittest
from unittest import mock
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.nix_flake import NixFlakeInstaller
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
class TestNixFlakeInstaller(unittest.TestCase):
@@ -39,7 +39,7 @@ class TestNixFlakeInstaller(unittest.TestCase):
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/nix")
@mock.patch("pkgmgr.installers.nix_flake.run_command")
@mock.patch("pkgmgr.actions.repository.install.installers.nix_flake.run_command")
def test_run_removes_old_profile_and_installs_outputs(
self,
mock_run_command,
@@ -74,7 +74,7 @@ class TestNixFlakeInstaller(unittest.TestCase):
self.assertEqual(cmds[0], remove_cmd)
@patch("shutil.which", return_value="/usr/bin/nix")
@mock.patch("pkgmgr.installers.nix_flake.run_command")
@mock.patch("pkgmgr.actions.repository.install.installers.nix_flake.run_command")
def test_ensure_old_profile_removed_ignores_systemexit(
self,
mock_run_command,

Some files were not shown because too many files have changed in this diff Show More