Refactor pkgmgr installers, introduce capability-based execution, and replace manifest layer

References:
- Current ChatGPT conversation: https://chatgpt.com/share/6935d6d7-0ae4-800f-988a-44a50c17ba48
- Extended discussion: https://chatgpt.com/share/6935d734-fd84-800f-9755-290902b8cee8

Summary:
This commit performs a major cleanup and modernization of the installation pipeline:

1. Introduced a new capability-detection subsystem:
   - Capabilities (python-runtime, make-install, nix-flake) are detected per installer/layer.
   - Installers run only when they add new capabilities.
   - Prevents duplicated work such as Python installers running when Nix already provides the runtime.

2. Removed deprecated pkgmgr.yml manifest installer:
   - Dependency resolution is now delegated entirely to real package managers (Nix, pip, make, distro build tools).
   - Simplifies layering and avoids unnecessary recursion.

3. Reworked OS-specific installers:
   - Arch PKGBUILD now uses 'makepkg --syncdeps --cleanbuild --install --noconfirm'.
   - Debian installer now builds proper .deb packages via dpkg-buildpackage + installs them.
   - RPM installer now builds packages using rpmbuild and installs them via rpm.

4. Switched from remote GitHub flakes to local-flake execution:
   - Wrapper now executes: nix run /usr/lib/package-manager#pkgmgr
   - Avoids lock-file write attempts and improves reliability in CI.

5. Added bash -i based integration test:
   - Correctly sources ~/.bashrc and evaluates alias + venv activation.
   - ‘pkgmgr --help’ is now printed for debugging without failing tests.

6. Updated unit tests across all installers:
   - Removed references to manifest installer.
   - Adjusted expectations for new behaviors (makepkg, dpkg-buildpackage, rpmbuild).
   - Added capability subsystem tests.

7. Improved flake.nix packaging logic:
   - The entire project source tree is copied into the runtime closure.
   - pkgmgr wrapper now executes runpy inside the packaged directory.

Together, these changes create a predictable, layered, capability-driven installer pipeline with consistent behavior across Arch, Debian, RPM, Nix, and Python layers.
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-07 20:36:39 +01:00
parent 5134fd5273
commit 16a9d55d4f
28 changed files with 984 additions and 632 deletions

View File

@@ -1,5 +1,6 @@
import runpy
import sys
import os
import unittest
import subprocess
@@ -34,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"):
@@ -44,6 +45,44 @@ def remove_pkgmgr_from_nix_profile() -> None:
)
def pkgmgr_help_debug() -> None:
"""
Run `pkgmgr --help` after installation *inside an interactive bash shell*,
print its output and return code, but never fail the test.
Reason:
- The installer adds venv/alias setup into shell rc files (~/.bashrc, ~/.zshrc)
- Those changes are only applied in a new interactive shell session.
"""
print("\n--- PKGMGR HELP (after installation, via bash -i) ---")
# Simulate a fresh interactive bash, so ~/.bashrc gets sourced
proc = subprocess.run(
["bash", "-i", "-c", "pkgmgr --help"],
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
if stdout:
print(stdout)
if stderr:
print("stderr:", stderr)
print(f"returncode: {proc.returncode}")
print("--- END ---\n")
# 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.
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None:
# Debug before cleanup
@@ -65,7 +104,11 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
"shallow",
"--no-verification",
]
# Führt die Installation via main.py aus
runpy.run_module("main", run_name="__main__")
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
pkgmgr_help_debug()
finally:
sys.argv = original_argv

View File

@@ -26,39 +26,69 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
)
self.installer = ArchPkgbuildInstaller()
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_supports_true_when_pacman_and_pkgbuild_exist(self, mock_which, mock_exists):
@patch("shutil.which")
def test_supports_true_when_tools_and_pkgbuild_exist(
self, mock_which, mock_exists, mock_geteuid
):
def which_side_effect(name):
if name in ("pacman", "makepkg"):
return f"/usr/bin/{name}"
return None
mock_which.side_effect = which_side_effect
self.assertTrue(self.installer.supports(self.ctx))
mock_which.assert_called_with("pacman")
calls = [c.args[0] for c in mock_which.call_args_list]
self.assertIn("pacman", calls)
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("os.path.exists", return_value=True)
@patch("shutil.which")
def test_supports_false_when_running_as_root(
self, mock_which, mock_exists, mock_geteuid
):
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("os.path.exists", return_value=False)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists):
@patch("shutil.which")
def test_supports_false_when_pkgbuild_missing(
self, mock_which, mock_exists, mock_geteuid
):
mock_which.return_value = "/usr/bin/pacman"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command")
@patch("subprocess.check_output", return_value="python\ngit\n")
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_run_installs_all_packages_and_uses_clean_bash(
self, mock_which, mock_exists, mock_check_output, mock_run_command
@patch("shutil.which")
def test_run_builds_and_installs_with_makepkg(
self, mock_which, mock_exists, mock_geteuid, mock_run_command
):
def which_side_effect(name):
if name in ("pacman", "makepkg"):
return f"/usr/bin/{name}"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
# subprocess.check_output call
args, kwargs = mock_check_output.call_args
cmd_list = args[0]
self.assertEqual(cmd_list[0], "bash")
self.assertIn("--noprofile", cmd_list)
self.assertIn("--norc", cmd_list)
# pacman install command
cmd = mock_run_command.call_args[0][0]
self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm "))
self.assertIn("python", cmd)
self.assertIn("git", cmd)
self.assertEqual(
cmd,
"makepkg --syncdeps --cleanbuild --install --noconfirm",
)
self.assertEqual(
mock_run_command.call_args[1].get("cwd"),
self.ctx.repo_dir,
)
if __name__ == "__main__":

View File

@@ -1,7 +1,8 @@
# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py
import os
import unittest
from unittest.mock import patch, mock_open
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller
@@ -26,40 +27,53 @@ class TestDebianControlInstaller(unittest.TestCase):
self.installer = DebianControlInstaller()
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/apt-get")
@patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage")
def test_supports_true(self, mock_which, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value=None)
def test_supports_false_without_apt(self, mock_which, mock_exists):
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("builtins.open", new_callable=mock_open, read_data="""
Build-Depends: python3, git (>= 2.0)
Depends: curl | wget
""")
@patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"])
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/apt-get")
def test_run_installs_parsed_packages(
@patch("shutil.which")
def test_run_builds_and_installs_debs(
self,
mock_which,
mock_exists,
mock_file,
mock_run_command
mock_glob,
mock_run_command,
):
# dpkg-buildpackage + apt-get vorhanden
def which_side_effect(name):
if name == "dpkg-buildpackage":
return "/usr/bin/dpkg-buildpackage"
if name == "apt-get":
return "/usr/bin/apt-get"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
# First call: apt-get update
self.assertIn("apt-get update", mock_run_command.call_args_list[0][0][0])
cmds = [c[0][0] for c in mock_run_command.call_args_list]
# Second call: install packages
install_cmd = mock_run_command.call_args_list[1][0][0]
self.assertIn("apt-get install -y", install_cmd)
self.assertIn("python3", install_cmd)
self.assertIn("git", install_cmd)
self.assertIn("curl", install_cmd)
# 1) apt-get update
self.assertTrue(any("apt-get update" in cmd for cmd in cmds))
# 2) apt-get build-dep ./
self.assertTrue(any("apt-get build-dep -y ./ " in cmd or
"apt-get build-dep -y ./"
in cmd for cmd in cmds))
# 3) dpkg-buildpackage -b -us -uc
self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds))
# 4) dpkg -i ../*.deb
self.assertTrue(any(cmd.startswith("sudo dpkg -i ") for cmd in cmds))
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
import unittest
from unittest.mock import patch, mock_open
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller
@@ -26,34 +26,67 @@ class TestRpmSpecInstaller(unittest.TestCase):
self.installer = RpmSpecInstaller()
@patch("glob.glob", return_value=["/tmp/repo/test.spec"])
@patch("shutil.which", return_value="/usr/bin/dnf")
@patch("shutil.which")
def test_supports_true(self, mock_which, mock_glob):
def which_side_effect(name):
if name == "rpmbuild":
return "/usr/bin/rpmbuild"
if name == "dnf":
return "/usr/bin/dnf"
return None
mock_which.side_effect = which_side_effect
self.assertTrue(self.installer.supports(self.ctx))
@patch("glob.glob", return_value=[])
@patch("shutil.which", return_value="/usr/bin/dnf")
@patch("shutil.which")
def test_supports_false_missing_spec(self, mock_which, mock_glob):
mock_which.return_value = "/usr/bin/rpmbuild"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.rpm_spec.run_command")
@patch("builtins.open", new_callable=mock_open, read_data="""
BuildRequires: python3-devel, git >= 2.0
Requires: curl
""")
@patch("glob.glob", return_value=["/tmp/repo/test.spec"])
@patch("shutil.which", return_value="/usr/bin/dnf")
@patch("os.path.exists", return_value=True)
def test_run_installs_parsed_dependencies(
self, mock_exists, mock_which, mock_glob, mock_file, mock_run_command
@patch("glob.glob")
@patch("shutil.which")
def test_run_builds_and_installs_rpms(
self,
mock_which,
mock_glob,
mock_run_command,
):
# glob.glob wird zweimal benutzt: einmal für *.spec, einmal für gebaute RPMs
def glob_side_effect(pattern, recursive=False):
if pattern.endswith("*.spec"):
return ["/tmp/repo/package-manager.spec"]
if "rpmbuild/RPMS" in pattern:
return ["/home/user/rpmbuild/RPMS/x86_64/package-manager-0.1.1.rpm"]
return []
mock_glob.side_effect = glob_side_effect
def which_side_effect(name):
if name == "rpmbuild":
return "/usr/bin/rpmbuild"
if name == "dnf":
return "/usr/bin/dnf"
if name == "rpm":
return "/usr/bin/rpm"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
install_cmd = mock_run_command.call_args_list[0][0][0]
cmds = [c[0][0] for c in mock_run_command.call_args_list]
self.assertIn("dnf install -y", install_cmd)
self.assertIn("python3-devel", install_cmd)
self.assertIn("git", install_cmd)
self.assertIn("curl", install_cmd)
# 1) builddep
self.assertTrue(any("builddep -y" in cmd for cmd in cmds))
# 2) rpmbuild -ba
self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds))
# 3) rpm -i …
self.assertTrue(any(cmd.startswith("sudo rpm -i ") for cmd in cmds))
if __name__ == "__main__":

View File

@@ -1,87 +0,0 @@
# tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py
import os
import unittest
from unittest.mock import patch, mock_open
from pkgmgr.context import RepoContext
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller
class TestPkgmgrManifestInstaller(unittest.TestCase):
def setUp(self):
self.repo = {"name": "test-repo"}
self.ctx = RepoContext(
repo=self.repo,
identifier="test-id",
repo_dir="/tmp/repo",
repositories_base_dir="/tmp",
bin_dir="/bin",
all_repos=[self.repo],
no_verification=False,
preview=False,
quiet=False,
clone_mode="ssh",
update_dependencies=True,
)
self.installer = PkgmgrManifestInstaller()
@patch("os.path.exists", return_value=True)
def test_supports_true_when_manifest_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
manifest_path = os.path.join(self.ctx.repo_dir, "pkgmgr.yml")
mock_exists.assert_called_with(manifest_path)
@patch("os.path.exists", return_value=False)
def test_supports_false_when_manifest_missing(self, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.pkgmgr_manifest.run_command")
@patch("builtins.open", new_callable=mock_open, read_data="""
version: 1
author: "Kevin"
url: "https://example.com"
description: "Test repo"
dependencies:
- repository: github:user/repo1
version: main
reason: "Core dependency"
- repository: github:user/repo2
""")
@patch("os.path.exists", return_value=True)
def test_run_installs_dependencies_and_pulls_when_update_enabled(
self, mock_exists, mock_file, mock_run_command
):
self.installer.run(self.ctx)
# First call: pkgmgr pull github:user/repo1 github:user/repo2
# Then calls to pkgmgr install ...
cmds = [call_args[0][0] for call_args in mock_run_command.call_args_list]
self.assertIn(
"pkgmgr pull github:user/repo1 github:user/repo2",
cmds,
)
self.assertIn(
"pkgmgr install github:user/repo1 --version main --dependencies --clone-mode ssh",
cmds,
)
# For repo2: no version but dependencies + clone_mode
self.assertIn(
"pkgmgr install github:user/repo2 --dependencies --clone-mode ssh",
cmds,
)
@patch("pkgmgr.installers.pkgmgr_manifest.run_command")
@patch("builtins.open", new_callable=mock_open, read_data="{}")
@patch("os.path.exists", return_value=True)
def test_run_no_dependencies_no_command_called(
self, mock_exists, mock_file, mock_run_command
):
self.ctx.update_dependencies = True
self.installer.run(self.ctx)
mock_run_command.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -30,19 +30,12 @@ class TestPythonInstaller(unittest.TestCase):
def test_supports_true_when_pyproject_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", side_effect=lambda path: path.endswith("requirements.txt"))
def test_supports_true_when_requirements_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=False)
def test_supports_false_when_no_python_files(self, mock_exists):
def test_supports_false_when_no_pyproject(self, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.python.run_command")
@patch(
"os.path.exists",
side_effect=lambda path: path.endswith("pyproject.toml")
)
@patch("os.path.exists", side_effect=lambda path: path.endswith("pyproject.toml"))
def test_run_installs_project_from_pyproject(self, mock_exists, mock_run_command):
self.installer.run(self.ctx)
cmd = mock_run_command.call_args[0][0]
@@ -52,20 +45,6 @@ class TestPythonInstaller(unittest.TestCase):
self.ctx.repo_dir,
)
@patch("pkgmgr.installers.python.run_command")
@patch(
"os.path.exists",
side_effect=lambda path: path.endswith("requirements.txt")
)
def test_run_installs_dependencies_from_requirements(self, mock_exists, mock_run_command):
self.installer.run(self.ctx)
cmd = mock_run_command.call_args[0][0]
self.assertIn("pip install -r requirements.txt", cmd)
self.assertEqual(
mock_run_command.call_args[1].get("cwd"),
self.ctx.repo_dir,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,76 @@
# tests/unit/pkgmgr/test_capabilities.py
import os
import unittest
from unittest.mock import patch, mock_open
from pkgmgr.capabilities import (
PythonRuntimeCapability,
MakeInstallCapability,
NixFlakeCapability,
)
class DummyCtx:
"""Minimal RepoContext stub with just repo_dir."""
def __init__(self, repo_dir: str):
self.repo_dir = repo_dir
class TestCapabilities(unittest.TestCase):
def setUp(self):
self.ctx = DummyCtx("/tmp/repo")
@patch("pkgmgr.capabilities.os.path.exists")
def test_python_runtime_python_layer_pyproject(self, mock_exists):
cap = PythonRuntimeCapability()
def exists_side_effect(path):
return path.endswith("pyproject.toml")
mock_exists.side_effect = exists_side_effect
self.assertTrue(cap.applies_to_layer("python"))
self.assertTrue(cap.is_provided(self.ctx, "python"))
@patch("pkgmgr.capabilities._read_text_if_exists")
@patch("pkgmgr.capabilities.os.path.exists")
def test_python_runtime_nix_layer_flake(self, mock_exists, mock_read):
cap = PythonRuntimeCapability()
def exists_side_effect(path):
return path.endswith("flake.nix")
mock_exists.side_effect = exists_side_effect
mock_read.return_value = "buildPythonApplication something"
self.assertTrue(cap.applies_to_layer("nix"))
self.assertTrue(cap.is_provided(self.ctx, "nix"))
@patch("pkgmgr.capabilities.os.path.exists", return_value=True)
@patch(
"builtins.open",
new_callable=mock_open,
read_data="install:\n\t echo 'installing'\n",
)
def test_make_install_makefile_layer(self, mock_file, mock_exists):
cap = MakeInstallCapability()
self.assertTrue(cap.applies_to_layer("makefile"))
self.assertTrue(cap.is_provided(self.ctx, "makefile"))
@patch("pkgmgr.capabilities.os.path.exists")
def test_nix_flake_capability_on_nix_layer(self, mock_exists):
cap = NixFlakeCapability()
def exists_side_effect(path):
return path.endswith("flake.nix")
mock_exists.side_effect = exists_side_effect
self.assertTrue(cap.applies_to_layer("nix"))
self.assertTrue(cap.is_provided(self.ctx, "nix"))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,13 +1,18 @@
from pkgmgr.run_command import run_command
# tests/unit/pkgmgr/test_install_repos.py
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.context import RepoContext
import pkgmgr.install_repos as install_module
from pkgmgr.installers.base import BaseInstaller
class DummyInstaller:
class DummyInstaller(BaseInstaller):
"""Simple installer for testing orchestration."""
layer = None # keine speziellen Capabilities
def __init__(self):
self.calls = []