Compare commits

...

9 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
6a838ee84f Release version 0.7.8 2025-12-09 21:03:24 +01:00
Kevin Veen-Birkenbach
4285bf4a54 Fix: release now skips missing pyproject.toml without failing
- Updated update_pyproject_version() to gracefully skip missing or unreadable pyproject.toml
- Added corresponding unit test ensuring missing file triggers no exception and no file creation
- Updated test wording for spec changelog section
- Ref: adjustments discussed in ChatGPT conversation (2025-12-09) - https://chatgpt.com/share/69388024-93e4-800f-a09f-bf78a6b9a53f
2025-12-09 21:02:01 +01:00
Kevin Veen-Birkenbach
640b1042c2 git commit -m "Harden installers for Nix, OS packages and Docker CA handling
- NixFlakeInstaller:
  - Skip when running inside a Nix dev shell (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_NIX_FLAKE_INSTALLER kill-switch for CI/debugging.
  - Ensure run() respects supports() and handles preview/allow_failure cleanly.

- DebianControlInstaller:
  - Introduce _privileged_prefix() to handle sudo vs. root vs. no elevation.
  - Avoid hard-coded sudo usage and degrade gracefully when neither sudo nor
    root is available.
  - Improve messaging around build-dep and .deb installation.

- RpmSpecInstaller:
  - Prepare rpmbuild tree and source tarball in ~/rpmbuild/SOURCES based on
    Name/Version from the spec file.
  - Reuse a helper to resolve the rpmbuild topdir.
  - Install built RPMs via dnf/yum when available, falling back to rpm -Uvh
    to avoid file conflicts during upgrades.

- PythonInstaller:
  - Skip pip-based installation inside Nix dev shells (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_PYTHON_INSTALLER kill-switch.
  - Make pip command resolution explicit and overridable via PKGMGR_PIP.
  - Type-hint supports() and run() with RepoContext/InstallContext.

- Docker entrypoint:
  - Add robust CA bundle detection for Nix, Git, Python requests and curl.
  - Export NIX_SSL_CERT_FILE, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and
    GIT_SSL_CAINFO from a single detected CA path.
  - Improve logging and section comments in the entrypoint script."

https://chatgpt.com/share/69387df8-bda0-800f-a053-aa9e2999dc84
2025-12-09 20:52:07 +01:00
Kevin Veen-Birkenbach
9357c4632e Release version 0.7.7 2025-12-09 17:54:41 +01:00
Kevin Veen-Birkenbach
ca5d0d22f3 feat(test): make unittest pattern configurable and pass TEST_PATTERN into containers
This update introduces a configurable TEST_PATTERN variable in the Makefile,
allowing selective execution of unit, integration, and E2E tests without
modifying scripts.

Key changes:
- Add TEST_PATTERN (default: test_*.py) to Makefile and export it.
- Inject TEST_PATTERN into all test containers via `-e TEST_PATTERN=...`.
- Update test-unit.sh, test-integration.sh, and test-e2e.sh to use
  `-p "$TEST_PATTERN"` instead of a hardcoded pattern.
- Ensure flexible test selection via:
      make test-e2e TEST_PATTERN=test_install_pkgmgr_shallow.py

This enables fast debugging, selective test runs, and better developer
experience while keeping full compatibility with CI defaults.

https://chatgpt.com/share/69385400-2f14-800f-b093-bb03c8ef9c7f
2025-12-09 17:53:10 +01:00
Kevin Veen-Birkenbach
3875338fb7 Release version 0.7.6 2025-12-09 17:14:22 +01:00
Kevin Veen-Birkenbach
196f55c58e feat(repository/pull): improve verification logic and add full unit test suite
This commit enhances the behaviour of pull_with_verification() and adds a
comprehensive unit test suite covering all control flows.

Changes:
- Added `preview` parameter to fully disable interaction and execution.
- Improved verification logic:
  * Prompt only when not in preview, verification is enabled,
    verification info exists, and verification failed.
  * Skip prompts entirely when --no-verification is set.
- More explicit construction of `git pull` command with optional extra args.
- Improved messaging and formatting for clarity.
- Ensured directory existence is checked before any verification logic.
- Added detailed comments explaining logic and conditions.

Tests:
- New file tests/unit/pkgmgr/actions/repos/test_pull_with_verification.py
- Covers:
  * Preview mode (no input, no subprocess)
  * Verification failure – user rejects
  * Verification failure – user accepts
  * Verification success – immediate git call
  * Missing repository directory – skip silently
  * --no-verification flag bypasses prompts
  * Command formatting with extra args
- Uses systematic mocking for identifier, repo-dir, verify_repository(),
  subprocess.run(), and user input.

This significantly strengthens correctness, UX, and test coverage of the
repository pull workflow.

https://chatgpt.com/share/69384aaa-0c80-800f-b4b4-64e6fbdebd3b
2025-12-09 17:12:23 +01:00
Kevin Veen-Birkenbach
9a149715f6 Release version 0.7.5 2025-12-09 16:45:45 +01:00
Kevin Veen-Birkenbach
bf40533469 fix(init-nix): ensure /nix is always owned by nix:nixbld in container root mode
In GitHub's Fedora-based CI containers the directory /nix may already exist
(e.g. from the base image or a previous build layer) and is often owned by
root:root. In this situation the Nix single-user installer aborts with:

    "directory /nix exists, but is not writable by you"

This caused the container build to fail during `init-nix.sh`, leaving no
working `nix` binary on PATH. As a result, the runtime wrapper
(pkmgr-wrapper.sh) reported:

    "[pkgmgr-wrapper] ERROR: 'nix' binary not found on PATH."

Local runs did not show the issue because a previous installation had already
created /nix with correct ownership.

This commit makes container-mode Nix initialization fully idempotent:

  • If /nix does not exist → create it with owner nix:nixbld (existing logic).
  • If /nix exists but has wrong owner/group → forcibly chown -R nix:nixbld.
  • A warning is emitted if /nix remains non-writable after correction.

This guarantees that the Nix installer always has writable access to /nix
and prevents the installer from aborting in CI. As a result, `pkgmgr --help`
works again inside Fedora CI containers.

https://chatgpt.com/share/69384149-9dc8-800f-8148-55817ece8e21
2025-12-09 16:33:22 +01:00
21 changed files with 903 additions and 121 deletions

View File

@@ -1,3 +1,23 @@
## [0.7.8] - 2025-12-09
* Missing pyproject.toml doesn't lead to an error during release
## [0.7.7] - 2025-12-09
* Added TEST_PATTERN parameter to execute dedicated tests
## [0.7.6] - 2025-12-09
* Fixed pull --preview bug in e2e test
## [0.7.5] - 2025-12-09
* Fixed wrong directory permissions for nix
## [0.7.4] - 2025-12-09
* Fixed missing build in test workflow -> Tests pass now

View File

@@ -12,7 +12,7 @@ NIX_CACHE_VOLUME := pkgmgr_nix_cache
# Distro list and base images
# (kept for documentation/reference; actual build logic is in scripts/build)
# ------------------------------------------------------------
DISTROS := arch debian ubuntu fedora centos
DISTROS := arch debian ubuntu fedora centos
BASE_IMAGE_ARCH := archlinux:latest
BASE_IMAGE_DEBIAN := debian:stable-slim
BASE_IMAGE_UBUNTU := ubuntu:latest
@@ -27,6 +27,10 @@ export BASE_IMAGE_UBUNTU
export BASE_IMAGE_FEDORA
export BASE_IMAGE_CENTOS
# PYthon Unittest Pattern
TEST_PATTERN := test_*.py
export TEST_PATTERN
# ------------------------------------------------------------
# PKGMGR setup (developer wrapper -> scripts/installation/main.sh)
# ------------------------------------------------------------

View File

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

24
debian/changelog vendored
View File

@@ -1,3 +1,27 @@
package-manager (0.7.8-1) unstable; urgency=medium
* Missing pyproject.toml doesn't lead to an error during release
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:03:24 +0100
package-manager (0.7.7-1) unstable; urgency=medium
* Added TEST_PATTERN parameter to execute dedicated tests
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:54:38 +0100
package-manager (0.7.6-1) unstable; urgency=medium
* Fixed pull --preview bug in e2e test
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:14:19 +0100
package-manager (0.7.5-1) unstable; urgency=medium
* Fixed wrong directory permissions for nix
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:45:42 +0100
package-manager (0.7.4-1) unstable; urgency=medium
* Fixed missing build in test workflow -> Tests pass now

View File

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

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.7.4
Version: 0.7.8
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -77,6 +77,18 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.8-1
- Missing pyproject.toml doesn't lead to an error during release
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.7-1
- Added TEST_PATTERN parameter to execute dedicated tests
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.6-1
- Fixed pull --preview bug in e2e test
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.5-1
- Fixed wrong directory permissions for nix
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.4-1
- Fixed missing build in test workflow -> Tests pass now

View File

@@ -85,7 +85,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
# File update helpers (pyproject + extra packaging + changelog)
# ---------------------------------------------------------------------------
def update_pyproject_version(
pyproject_path: str,
new_version: str,
@@ -99,13 +98,25 @@ def update_pyproject_version(
version = "X.Y.Z"
and replaces the version part with the given new_version string.
If the file does not exist, it is skipped without failing the release.
"""
if not os.path.exists(pyproject_path):
print(
f"[INFO] pyproject.toml not found at: {pyproject_path}, "
"skipping version update."
)
return
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)
except OSError as exc:
print(
f"[WARN] Could not read pyproject.toml at {pyproject_path}: {exc}. "
"Skipping version update."
)
return
pattern = r'^(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(

View File

@@ -13,6 +13,13 @@ Behavior:
* Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`.
- Failure installing `pkgmgr` is treated as fatal.
- Failure installing `default` is logged as an error/warning but does not abort.
Special handling for dev shells / CI:
- If IN_NIX_SHELL is set (e.g. inside `nix develop`), the installer is
disabled. In that environment the flake outputs are already provided
by the dev shell and we must not touch the user profile.
- If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
"""
import os
@@ -36,14 +43,45 @@ class NixFlakeInstaller(BaseInstaller):
FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager"
def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the flake outputs are already available, and touching the user
profile (nix profile install/remove) is undesirable.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool:
"""
Only support repositories that:
- Have a flake.nix
- Are NOT inside a Nix dev shell (IN_NIX_SHELL unset),
- Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1,
- Have a flake.nix,
- And have the `nix` command available.
"""
# 1) Skip when running inside a dev shell flake is already active.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping NixFlakeInstaller. "
"Flake outputs are provided by the development shell."
)
return False
# 2) Optional global kill-switch for CI or debugging.
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 "
"NixFlakeInstaller is disabled."
)
return False
# 3) Nix must be available.
if shutil.which("nix") is None:
return False
# 4) flake.nix must exist in the repository.
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
return os.path.exists(flake_path)
@@ -76,6 +114,14 @@ class NixFlakeInstaller(BaseInstaller):
Any failure installing `pkgmgr` is treated as fatal (SystemExit).
A failure installing `default` is logged but does not abort.
"""
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in run(); "
"skipping Nix flake profile installation."
)
return
# Reuse supports() to keep logic in one place
if not self.supports(ctx): # type: ignore[arg-type]
return
@@ -91,7 +137,12 @@ class NixFlakeInstaller(BaseInstaller):
try:
# For 'default' we don't want the process to exit on error
allow_failure = output == "default"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure)
run_command(
cmd,
cwd=ctx.repo_dir,
preview=ctx.preview,
allow_failure=allow_failure,
)
print(f"Nix flake output '{output}' successfully installed.")
except SystemExit as e:
print(f"[Error] Failed to install Nix flake output '{output}': {e}")

View File

@@ -17,7 +17,6 @@ apt/dpkg tooling are available.
import glob
import os
import shutil
from typing import List
from pkgmgr.actions.repository.install.context import RepoContext
@@ -68,6 +67,32 @@ class DebianControlInstaller(BaseInstaller):
pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern))
def _privileged_prefix(self) -> str | None:
"""
Determine how to run privileged commands:
- If 'sudo' is available, return 'sudo '.
- If we are running as root (e.g. inside CI/container), return ''.
- Otherwise, return None, meaning we cannot safely elevate.
Callers are responsible for handling the None case (usually by
warning and skipping automatic installation).
"""
sudo_path = shutil.which("sudo")
is_root = False
try:
is_root = os.geteuid() == 0
except AttributeError: # pragma: no cover - non-POSIX platforms
# On non-POSIX systems, fall back to assuming "not root".
is_root = False
if sudo_path is not None:
return "sudo "
if is_root:
return ""
return None
def _install_build_dependencies(self, ctx: RepoContext) -> None:
"""
Install build dependencies using `apt-get build-dep ./`.
@@ -86,12 +111,25 @@ class DebianControlInstaller(BaseInstaller):
)
return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic build-dep installation for Debian. "
"Please install build dependencies from debian/control manually."
)
return
# Update package lists first for reliable build-dep resolution.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
run_command(
f"{prefix}apt-get update",
cwd=ctx.repo_dir,
preview=ctx.preview,
)
# Install build dependencies based on debian/control in the current tree.
# `apt-get build-dep ./` uses the source in the current directory.
builddep_cmd = "sudo apt-get build-dep -y ./"
builddep_cmd = f"{prefix}apt-get build-dep -y ./"
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
@@ -101,7 +139,7 @@ class DebianControlInstaller(BaseInstaller):
Steps:
1. apt-get build-dep ./ (automatic build dependency installation)
2. dpkg-buildpackage -b -us -uc
3. sudo dpkg -i ../*.deb
3. sudo dpkg -i ../*.deb (or plain dpkg -i when running as root)
"""
control_path = self._control_path(ctx)
if not os.path.exists(control_path):
@@ -123,7 +161,17 @@ class DebianControlInstaller(BaseInstaller):
)
return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic .deb installation. "
"You can manually install the following files with dpkg -i:\n "
+ "\n ".join(debs)
)
return
# 4) Install .deb files
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs)
install_cmd = prefix + "dpkg -i " + " ".join(os.path.basename(d) for d in debs)
parent = os.path.dirname(ctx.repo_dir)
run_command(install_cmd, cwd=parent, preview=ctx.preview)

View File

@@ -7,8 +7,10 @@ Installer for RPM-based packages defined in *.spec files.
This installer:
1. Installs build dependencies via dnf/yum builddep (where available)
2. Uses rpmbuild to build RPMs from the provided .spec file
3. Installs the resulting RPMs via `rpm -i`
2. Prepares a source tarball in ~/rpmbuild/SOURCES based on the .spec
3. Uses rpmbuild to build RPMs from the provided .spec file
4. Installs the resulting RPMs via the system package manager (dnf/yum)
or rpm as a fallback.
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
"""
@@ -16,8 +18,8 @@ It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
import glob
import os
import shutil
from typing import List, Optional
import tarfile
from typing import List, Optional, Tuple
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
@@ -59,6 +61,117 @@ class RpmSpecInstaller(BaseInstaller):
return None
return matches[0]
# ------------------------------------------------------------------
# Helpers for preparing rpmbuild topdir and source tarball
# ------------------------------------------------------------------
def _rpmbuild_topdir(self) -> str:
"""
Return the rpmbuild topdir that rpmbuild will use by default.
By default this is: ~/rpmbuild
In the self-install tests, $HOME is set to /tmp/pkgmgr-self-install,
so this becomes /tmp/pkgmgr-self-install/rpmbuild which matches the
paths in the RPM build logs.
"""
home = os.path.expanduser("~")
return os.path.join(home, "rpmbuild")
def _ensure_rpmbuild_tree(self, topdir: str) -> None:
"""
Ensure the standard rpmbuild directory tree exists:
<topdir>/
BUILD/
BUILDROOT/
RPMS/
SOURCES/
SPECS/
SRPMS/
"""
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]]:
"""
Parse Name and Version from the given .spec file.
Returns (name, version) or None if either cannot be determined.
"""
name = None
version = None
with open(spec_path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
# Ignore comments
if not line or line.startswith("#"):
continue
lower = line.lower()
if lower.startswith("name:"):
# e.g. "Name: package-manager"
parts = line.split(":", 1)
if len(parts) == 2:
name = parts[1].strip()
elif lower.startswith("version:"):
# e.g. "Version: 0.7.7"
parts = line.split(":", 1)
if len(parts) == 2:
version = parts[1].strip()
if name and version:
break
if not name or not version:
print(
"[Warning] Could not determine Name/Version from spec file "
f"'{spec_path}'. Skipping RPM source tarball preparation."
)
return None
return name, version
def _prepare_source_tarball(self, ctx: RepoContext, spec_path: str) -> None:
"""
Prepare a source tarball in <HOME>/rpmbuild/SOURCES that matches
the Name/Version in the .spec file.
"""
parsed = self._parse_name_version(spec_path)
if parsed is None:
return
name, version = parsed
topdir = self._rpmbuild_topdir()
self._ensure_rpmbuild_tree(topdir)
build_dir = os.path.join(topdir, "BUILD")
sources_dir = os.path.join(topdir, "SOURCES")
source_root = os.path.join(build_dir, f"{name}-{version}")
tarball_path = os.path.join(sources_dir, f"{name}-{version}.tar.gz")
# Clean any previous build directory for this name/version.
if os.path.exists(source_root):
shutil.rmtree(source_root)
# Copy the repository tree into BUILD/<name>-<version>.
shutil.copytree(ctx.repo_dir, source_root)
# Create the tarball with the top-level directory <name>-<version>.
if os.path.exists(tarball_path):
os.remove(tarball_path)
with tarfile.open(tarball_path, "w:gz") as tar:
tar.add(source_root, arcname=f"{name}-{version}")
print(
f"[INFO] Prepared RPM source tarball at '{tarball_path}' "
f"from '{ctx.repo_dir}'."
)
# ------------------------------------------------------------------
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
@@ -77,26 +190,13 @@ class RpmSpecInstaller(BaseInstaller):
By default, rpmbuild outputs RPMs into:
~/rpmbuild/RPMS/*/*.rpm
"""
home = os.path.expanduser("~")
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm")
topdir = self._rpmbuild_topdir()
pattern = os.path.join(topdir, "RPMS", "**", "*.rpm")
return sorted(glob.glob(pattern, recursive=True))
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
"""
Install build dependencies for the given .spec file.
Strategy (best-effort):
1. If dnf is available:
sudo dnf builddep -y <spec>
2. Else if yum-builddep is available:
sudo yum-builddep -y <spec>
3. Else if yum is available:
sudo yum-builddep -y <spec> # Some systems provide it via yum plugin
4. Otherwise: print a warning and skip automatic builddep install.
Any failure in builddep installation is treated as fatal (SystemExit),
consistent with other installer steps.
"""
spec_basename = os.path.basename(spec_path)
@@ -105,7 +205,6 @@ class RpmSpecInstaller(BaseInstaller):
elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None:
# Some distributions ship yum-builddep as a plugin.
cmd = f"sudo yum-builddep -y {spec_basename}"
else:
print(
@@ -114,33 +213,17 @@ class RpmSpecInstaller(BaseInstaller):
)
return
# Run builddep in the repository directory so relative spec paths work.
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
"""
Build and install RPM-based packages.
Install or upgrade the built RPMs.
Steps:
1. dnf/yum builddep <spec> (automatic build dependency installation)
2. rpmbuild -ba path/to/spec
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
Strategy:
- Prefer dnf install -y <rpms> (handles upgrades cleanly)
- Else yum install -y <rpms>
- Else fallback to rpm -Uvh <rpms> (upgrade/replace existing)
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 2) Build RPMs
# Use the full spec path, but run in the repo directory.
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Find built RPMs
rpms = self._find_built_rpms()
if not rpms:
print(
"[Warning] No RPM files found after rpmbuild. "
@@ -148,13 +231,52 @@ class RpmSpecInstaller(BaseInstaller):
)
return
# 4) Install RPMs
if shutil.which("rpm") is None:
dnf = shutil.which("dnf")
yum = shutil.which("yum")
rpm = shutil.which("rpm")
if dnf is not None:
install_cmd = "sudo dnf install -y " + " ".join(rpms)
elif yum is not None:
install_cmd = "sudo yum install -y " + " ".join(rpms)
elif rpm is not None:
# Fallback: use rpm in upgrade mode so an existing older
# version is replaced instead of causing file conflicts.
install_cmd = "sudo rpm -Uvh " + " ".join(rpms)
else:
print(
"[Warning] rpm binary not found on PATH. "
"[Warning] No suitable RPM installer (dnf/yum/rpm) found. "
"Cannot install built RPMs."
)
return
install_cmd = "sudo rpm -i " + " ".join(rpms)
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Build and install RPM-based packages.
Steps:
1. Prepare source tarball in ~/rpmbuild/SOURCES matching Name/Version
2. dnf/yum builddep <spec> (automatic build dependency installation)
3. rpmbuild -ba path/to/spec
4. Install built RPMs via dnf/yum (or rpm as fallback)
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Prepare source tarball so rpmbuild finds Source0 in SOURCES.
self._prepare_source_tarball(ctx, spec_path)
# 2) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 3) Build RPMs
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 4) Find and install built RPMs
rpms = self._find_built_rpms()
self._install_built_rpms(ctx, rpms)

View File

@@ -11,15 +11,28 @@ Strategy:
3. "pip" from PATH as last resort
- If pyproject.toml exists: pip install .
All installation failures are treated as fatal errors (SystemExit).
All installation failures are treated as fatal errors (SystemExit),
except when we explicitly skip the installer:
- If IN_NIX_SHELL is set, we assume Python is managed by Nix and
skip this installer entirely.
- If PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
"""
from __future__ import annotations
import os
import sys
from typing import TYPE_CHECKING
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
if TYPE_CHECKING:
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install import InstallContext
class PythonInstaller(BaseInstaller):
"""Install Python projects and dependencies via pip."""
@@ -27,19 +40,54 @@ class PythonInstaller(BaseInstaller):
# Logical layer name, used by capability matchers.
layer = "python"
def supports(self, ctx) -> bool:
def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the Python environment is already provided by Nix, so we must not
attempt an additional pip-based installation.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool:
"""
Return True if this installer should handle the given repository.
Only pyproject.toml is supported as the single source of truth
for Python dependencies and packaging metadata.
The installer is *disabled* when:
- IN_NIX_SHELL is set (Python managed by Nix dev shell), or
- PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set.
"""
# 1) Skip in Nix dev shells Python is managed by the flake/devShell.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping PythonInstaller. "
"Python runtime is provided by the Nix dev shell."
)
return False
# 2) Optional global kill-switch.
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_PYTHON_INSTALLER=1 "
"PythonInstaller is disabled."
)
return False
repo_dir = ctx.repo_dir
return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
def _pip_cmd(self) -> str:
"""
Resolve the pip command to use.
Order:
1) PKGMGR_PIP (explicit override)
2) sys.executable -m pip
3) plain "pip"
"""
explicit = os.environ.get("PKGMGR_PIP", "").strip()
if explicit:
@@ -50,12 +98,23 @@ class PythonInstaller(BaseInstaller):
return "pip"
def run(self, ctx) -> None:
def run(self, ctx: "InstallContext") -> None:
"""
Install Python project defined via pyproject.toml.
Any pip failure is propagated as SystemExit.
"""
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in PythonInstaller.run(); "
"skipping pip-based installation."
)
return
if not self.supports(ctx): # type: ignore[arg-type]
return
pip_cmd = self._pip_cmd()
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")

View File

@@ -1,35 +1,57 @@
import os
import subprocess
import sys
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,
repositories_base_dir,
all_repos,
extra_args,
no_verification,
preview:bool):
preview: bool,
) -> None:
"""
Executes "git pull" for each repository with verification.
Uses the verify_repository function in "pull" mode.
If verification fails (and verification info is set) and --no-verification is not enabled,
the user is prompted to confirm the pull.
Execute `git pull` for each repository with verification.
- Uses verify_repository() in "pull" mode.
- If verification fails (and verification info is set) and
--no-verification is not enabled, the user is prompted to confirm
the pull.
- In preview mode, no interactive prompts are performed and no
Git commands are executed; only the would-be command is printed.
"""
for repo in selected_repos:
repo_identifier = get_repo_identifier(repo, all_repos)
repo_dir = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(repo_dir):
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
continue
verified_info = repo.get("verified")
verified_ok, errors, commit_hash, signing_key = verify_repository(repo, repo_dir, mode="pull", no_verification=no_verification)
verified_ok, errors, commit_hash, signing_key = verify_repository(
repo,
repo_dir,
mode="pull",
no_verification=no_verification,
)
if not no_verification and verified_info and not verified_ok:
# Only prompt the user if:
# - we are NOT in preview mode
# - verification is enabled
# - the repo has verification info configured
# - verification failed
if (
not preview
and not no_verification
and verified_info
and not verified_ok
):
print(f"Warning: Verification failed for {repo_identifier}:")
for err in errors:
print(f" - {err}")
@@ -37,12 +59,19 @@ def pull_with_verification(
if choice != "y":
continue
full_cmd = f"git pull {' '.join(extra_args)}"
# Build the git pull command (include extra args if present)
args_part = " ".join(extra_args) if extra_args else ""
full_cmd = f"git pull{(' ' + args_part) if args_part else ''}"
if preview:
# Preview mode: only show the command, do not execute or prompt.
print(f"[Preview] In '{repo_dir}': {full_cmd}")
else:
print(f"Running in '{repo_dir}': {full_cmd}")
result = subprocess.run(full_cmd, cwd=repo_dir, shell=True)
if result.returncode != 0:
print(f"'git pull' for {repo_identifier} failed with exit code {result.returncode}.")
print(
f"'git pull' for {repo_identifier} failed "
f"with exit code {result.returncode}."
)
sys.exit(result.returncode)

View File

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

View File

@@ -2,28 +2,59 @@
set -euo pipefail
# ---------------------------------------------------------------------------
# Ensure Nix has access to a valid CA bundle (TLS trust store)
# Detect and export a valid CA bundle so Nix, Git, curl and Python tooling
# can successfully perform HTTPS requests on all distros (Debian, Ubuntu,
# Fedora, RHEL, CentOS, etc.)
# ---------------------------------------------------------------------------
if [[ -z "${NIX_SSL_CERT_FILE:-}" ]]; then
if [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then
# Debian/Ubuntu-style path
export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
elif [[ -f /etc/pki/tls/certs/ca-bundle.crt ]]; then
# Fedora/RHEL/CentOS-style path
export NIX_SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
else
echo "[docker] WARNING: No CA bundle found for Nix (NIX_SSL_CERT_FILE not set)."
echo "[docker] HTTPS access for Nix flakes may fail."
fi
detect_ca_bundle() {
# Common CA bundle locations across major Linux distributions
local candidates=(
/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
/etc/ssl/cert.pem # Some distros
/etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle
/etc/ssl/ca-bundle.pem # Generic fallback
)
for path in "${candidates[@]}"; do
if [[ -f "$path" ]]; then
echo "$path"
return 0
fi
done
return 1
}
# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect
CA_BUNDLE="${NIX_SSL_CERT_FILE:-}"
if [[ -z "${CA_BUNDLE}" ]]; then
CA_BUNDLE="$(detect_ca_bundle || true)"
fi
if [[ -n "${CA_BUNDLE}" ]]; then
# Export for Nix (critical)
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
# Export for Git, Python requests, curl, etc.
export SSL_CERT_FILE="${CA_BUNDLE}"
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
export GIT_SSL_CAINFO="${CA_BUNDLE}"
echo "[docker] Using CA bundle: ${CA_BUNDLE}"
else
echo "[docker] WARNING: No CA certificate bundle found."
echo "[docker] HTTPS access for Nix flakes and other tools may fail."
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[docker] Starting package-manager container"
# Distro info for logging
# ---------------------------------------------------------------------------
# Log distribution info
# ---------------------------------------------------------------------------
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
@@ -34,9 +65,9 @@ fi
echo "[docker] Using /src as working directory"
cd /src
# ------------------------------------------------------------
# DEV mode: build/install package-manager from current /src
# ------------------------------------------------------------
# ---------------------------------------------------------------------------
# DEV mode: rebuild package-manager from the mounted /src tree
# ---------------------------------------------------------------------------
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
@@ -49,9 +80,9 @@ if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
fi
fi
# ------------------------------------------------------------
# Hand-off to pkgmgr / arbitrary command
# ------------------------------------------------------------
# ---------------------------------------------------------------------------
# Hand off to pkgmgr or arbitrary command
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
echo "[docker] No arguments provided. Showing pkgmgr help..."
exec pkgmgr --help

View File

@@ -97,11 +97,32 @@ if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
useradd -m -r -g nixbld -s /usr/bin/bash nix
fi
# Create /nix directory and hand it to nix user (prevents installer sudo prompt)
# Ensure /nix exists and is writable by the "nix" user.
#
# In some base images (or previous runs), /nix may already exist and be
# owned by root. In that case the Nix single-user installer will abort with:
#
# "directory /nix exists, but is not writable by you"
#
# To keep container runs idempotent and robust, we always enforce
# ownership nix:nixbld here.
if [[ ! -d /nix ]]; then
echo "[init-nix] Creating /nix with owner nix:nixbld..."
mkdir -m 0755 /nix
chown nix:nixbld /nix
else
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
echo "[init-nix] /nix already exists with owner ${current_owner}:${current_group} fixing to nix:nixbld..."
chown -R nix:nixbld /nix
else
echo "[init-nix] /nix already exists with correct owner nix:nixbld."
fi
if [[ ! -w /nix ]]; then
echo "[init-nix] WARNING: /nix is still not writable after chown; Nix installer may fail."
fi
fi
# Run Nix single-user installer as "nix"

View File

@@ -18,6 +18,7 @@ for distro in $DISTROS; do
$MOUNT_NIX \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--workdir /src \
--entrypoint bash \
"package-manager-test-$distro" \
@@ -51,6 +52,6 @@ for distro in $DISTROS; do
nix develop .#default --no-write-lock-file -c \
python3 -m unittest discover \
-s /src/tests/e2e \
-p "test_*.py";
-p "$TEST_PATTERN";
'
done

View File

@@ -10,6 +10,7 @@ docker run --rm \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
--workdir /src \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--entrypoint bash \
"package-manager-test-arch" \
-c '
@@ -19,5 +20,5 @@ docker run --rm \
python -m unittest discover \
-s tests/integration \
-t /src \
-p "test_*.py";
-p "$TEST_PATTERN";
'

View File

@@ -10,6 +10,7 @@ docker run --rm \
-v "pkgmgr_nix_cache:/root/.cache/nix" \
--workdir /src \
-e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \
--entrypoint bash \
"package-manager-test-arch" \
-c '
@@ -19,5 +20,5 @@ docker run --rm \
python -m unittest discover \
-s tests/unit \
-t /src \
-p "test_*.py";
-p "$TEST_PATTERN";
'

View File

@@ -35,7 +35,7 @@ def remove_pkgmgr_from_nix_profile() -> None:
prints a descriptive format without an index column inside the container.
Instead, we directly try to remove possible names:
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'package-manager' (the name mentioned in Nix's own error hints)
"""
for spec in ("pkgmgr", "package-manager"):
@@ -76,29 +76,47 @@ def pkgmgr_help_debug() -> None:
print(f"returncode: {proc.returncode}")
print("--- END ---\n")
if proc.returncode != 0:
raise AssertionError(f"'pkgmgr --help' failed with exit code {proc.returncode}")
# Wichtig: Hier KEIN AssertionError mehr das ist reine Debug-Ausgabe.
# Falls du später hart testen willst, kannst du optional:
# if proc.returncode != 0:
# self.fail("...")
# aber aktuell nur Sichtprüfung.
# Important: this is **debug-only**. Do NOT fail the test here.
# If you ever want to hard-assert on this, you can add an explicit
# assertion in the test method instead of here.
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None:
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
"""
End-to-end test that runs "python main.py install pkgmgr ..." inside
the test container.
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
We isolate HOME into /tmp/pkgmgr-self-install so that:
- ~/.config/pkgmgr points to an isolated test config area
- ~/Repositories is owned by the current user inside the container
(avoiding Nix's 'repository path is not owned by current user' error)
"""
# Use a dedicated HOME for this test to avoid permission/ownership issues
temp_home = "/tmp/pkgmgr-self-install"
os.makedirs(temp_home, exist_ok=True)
original_argv = sys.argv
original_environ = os.environ.copy()
try:
# Isolate HOME so that ~ expands to /tmp/pkgmgr-self-install
os.environ["HOME"] = temp_home
# Optional: ensure XDG_* also use the temp HOME for extra isolation
os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(temp_home, ".config"))
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(temp_home, ".cache"))
os.environ.setdefault("XDG_DATA_HOME", os.path.join(temp_home, ".local", "share"))
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
sys.argv = [
"python",
"install",
@@ -107,13 +125,18 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
"shallow",
"--no-verification",
]
# Führt die Installation via main.py aus
# Run installation via main.py
runpy.run_module("main", run_name="__main__")
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
# After successful installation: run `pkgmgr --help` for debug
pkgmgr_help_debug()
finally:
sys.argv = original_argv
# Restore full environment
os.environ.clear()
os.environ.update(original_environ)
if __name__ == "__main__":

View File

@@ -80,6 +80,21 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
self.assertNotEqual(cm.exception.code, 0)
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
"""
If pyproject.toml does not exist, the function must not raise
and must not create the file. It should simply be skipped.
"""
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
self.assertFalse(os.path.exists(path))
# Must not raise an exception
update_pyproject_version(path, "1.2.3", preview=False)
# Still no file created
self.assertFalse(os.path.exists(path))
class TestUpdateFlakeVersion(unittest.TestCase):
def test_update_flake_version_normal(self) -> None:
@@ -352,11 +367,11 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Neue Stanza muss nach %changelog stehen
# New stanza must appear after the %changelog marker
self.assertIn("%changelog", content)
self.assertIn("Fedora changelog entry", content)
self.assertIn("Test Maintainer <test@example.com>", content)
# Alte Einträge müssen erhalten bleiben
# Old entries must still be present
self.assertIn("Old Maintainer <old@example.com>", content)
def test_update_spec_changelog_preview_does_not_write(self) -> None:
@@ -396,7 +411,7 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Im Preview-Modus darf nichts verändert werden
# In preview mode, the spec file must not change
self.assertEqual(content, original)

View File

@@ -0,0 +1,309 @@
import io
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.actions.repository.pull import pull_with_verification
class TestPullWithVerification(unittest.TestCase):
"""
Comprehensive unit tests for pull_with_verification().
These tests verify:
- Preview mode behaviour
- Verification logic (prompting, bypassing, skipping)
- subprocess.run invocation
- Repository directory existence checks
- Handling of extra git pull arguments
"""
def _setup_mocks(self, mock_exists, mock_get_repo_id, mock_get_repo_dir,
mock_verify, exists=True, verified_ok=True,
errors=None, verified_info=True):
"""Helper to configure repetitive mock behavior."""
repo = {
"name": "pkgmgr",
"verified": {"gpg_keys": ["ABCDEF"]} if verified_info else None,
}
mock_exists.return_value = exists
mock_get_repo_id.return_value = "pkgmgr"
mock_get_repo_dir.return_value = "/fake/base/pkgmgr"
mock_verify.return_value = (
verified_ok,
errors or [],
"deadbeef", # commit hash
"ABCDEF", # signing key
)
return repo
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_preview_mode_non_interactive(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
Preview mode must NEVER request user input and must NEVER execute git.
It must only print the preview command.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
exists=True,
verified_ok=False,
errors=["bad signature"],
verified_info=True,
)
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=["--ff-only"],
no_verification=False,
preview=True,
)
output = buf.getvalue()
self.assertIn(
"[Preview] In '/fake/base/pkgmgr': git pull --ff-only",
output,
)
mock_input.assert_not_called()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_failure_user_declines(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification fails and preview=False, the user is prompted.
If the user declines ('n'), no git command is executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["signature invalid"],
)
mock_input.return_value = "n"
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
mock_input.assert_called_once()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_failure_user_accepts_runs_git(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification fails and the user accepts ('y'),
then the git pull should be executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["invalid"],
)
mock_input.return_value = "y"
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
mock_subprocess.assert_called_once()
mock_input.assert_called_once()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_verification_success_no_prompt(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If verification is successful, the user should NOT be prompted,
and git pull should run immediately.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=True,
)
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=["--rebase"],
no_verification=False,
preview=False,
)
mock_input.assert_not_called()
mock_subprocess.assert_called_once()
cmd = mock_subprocess.call_args[0][0]
self.assertIn("git pull --rebase", cmd)
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_directory_missing_skips_repo(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If the repository directory does not exist, the repo must be skipped
silently and no git command executed.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
exists=False,
)
buf = io.StringIO()
with patch("sys.stdout", new=buf):
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=False,
preview=False,
)
output = buf.getvalue()
self.assertIn("not found", output)
mock_input.assert_not_called()
mock_subprocess.assert_not_called()
# ---------------------------------------------------------------------
@patch("pkgmgr.actions.repository.pull.subprocess.run")
@patch("pkgmgr.actions.repository.pull.verify_repository")
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
@patch("pkgmgr.actions.repository.pull.os.path.exists")
@patch("builtins.input")
def test_no_verification_flag_skips_prompt(
self,
mock_input,
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
mock_subprocess,
):
"""
If no_verification=True, verification failures must NOT prompt.
Git pull should run directly.
"""
repo = self._setup_mocks(
mock_exists,
mock_get_repo_id,
mock_get_repo_dir,
mock_verify,
verified_ok=False,
errors=["invalid"],
)
mock_subprocess.return_value = MagicMock(returncode=0)
pull_with_verification(
selected_repos=[repo],
repositories_base_dir="/fake/base",
all_repos=[repo],
extra_args=[],
no_verification=True,
preview=False,
)
mock_input.assert_not_called()
mock_subprocess.assert_called_once()