Refine installer capability integration tests and documentation

- Adjust install_repos integration test to patch resolve_command_for_repo
  in the pipeline module and tighten DummyInstaller overrides
- Rewrite recursive capability integration tests to focus on layer
  ordering and capability shadowing across Makefile, Python, Nix
  and OS-package installers
- Extend recursive capabilities markdown with hierarchy diagram,
  capability matrix, scenario matrix and link to the external
  setup controller schema

https://chatgpt.com/share/69399857-4d84-800f-a636-6bcd1ab5e192
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-10 17:23:33 +01:00
parent a7fd37d646
commit e290043089
3 changed files with 178 additions and 223 deletions

View File

@@ -2,140 +2,99 @@
# -*- coding: utf-8 -*-
"""
Integration tests for the recursive / layered capability handling in pkgmgr.
Integration tests for recursive capability resolution and installer shadowing.
We focus on the interaction between:
These tests verify that, given different repository layouts (Makefile, pyproject,
flake.nix, PKGBUILD), only the expected installers are executed based on the
capabilities provided by higher layers.
- MakefileInstaller (layer: "makefile")
- PythonInstaller (layer: "python")
- NixFlakeInstaller (layer: "nix")
- ArchPkgbuildInstaller (layer: "os-packages")
Layer order (strongest → weakest):
The core idea:
- Each installer declares logical capabilities for its layer via
discover_capabilities() and the global CAPABILITY_MATCHERS.
- install_repos() tracks which capabilities have already been provided
by earlier installers (in INSTALLERS order).
- If an installer only provides capabilities that are already covered
by previous installers, it is skipped.
These tests use *real* capability detection (based on repo files like
flake.nix, pyproject.toml, Makefile, PKGBUILD), but patch the installers'
run() methods so that no real external commands are executed.
Scenarios:
1. Only Makefile with install target
→ MakefileInstaller runs, all good.
2. Python + Makefile (no "make install" in pyproject.toml)
→ PythonInstaller provides only python-runtime
→ MakefileInstaller provides make-install
→ Both run, since their capabilities are disjoint.
3. Python + Makefile (pyproject.toml mentions "make install")
→ PythonInstaller provides {python-runtime, make-install}
→ MakefileInstaller provides {make-install}
→ MakefileInstaller is skipped (capabilities already covered).
4. Nix + Python + Makefile
- flake.nix hints:
* buildPythonApplication (python-runtime)
* make install (make-install)
→ NixFlakeInstaller provides {python-runtime, make-install, nix-flake}
→ PythonInstaller and MakefileInstaller are skipped.
5. OS packages + Nix + Python + Makefile
- PKGBUILD contains:
* "pip install ." (python-runtime via os-packages)
* "make install" (make-install via os-packages)
* "nix profile" (nix-flake via os-packages)
→ ArchPkgbuildInstaller provides all capabilities
→ All lower layers are skipped.
OS-PACKAGES > NIX > PYTHON > MAKEFILE
"""
import os
import shutil
import tempfile
import unittest
from typing import List, Sequence, Tuple
from unittest.mock import patch
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
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import (
ArchPkgbuildInstaller,
)
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
InstallerSpec = Tuple[str, object]
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
def setUp(self) -> None:
# Temporary base directory for this test class
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-integration-")
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-recursive-caps-")
self.bin_dir = os.path.join(self.tmp_root, "bin")
os.makedirs(self.bin_dir, exist_ok=True)
def tearDown(self) -> None:
shutil.rmtree(self.tmp_root)
# ------------------------------------------------------------------
# Helper: create a new repo directory for a scenario
# ------------------------------------------------------------------
# ------------------------------------------------------------------ helpers
def _new_repo(self) -> str:
repo_dir = tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
return repo_dir
# ------------------------------------------------------------------
# Helper: run install_repos() with a custom installer list
# and record which installers actually ran.
# ------------------------------------------------------------------
def _run_with_installers(self, repo_dir: str, installers, selected_repos=None):
"""
Run install_repos() with a given INSTALLERS list and a single
dummy repo; return the list of installer labels that actually ran.
Create a fresh temporary repo directory under self.tmp_root.
"""
return tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
The installers' supports() are forced to True so that only the
capability-shadowing logic decides whether they are skipped.
The installers' run() methods are patched to avoid real commands.
def _run_with_installers(
self,
repo_dir: str,
installers: Sequence[InstallerSpec],
selected_repos=None,
) -> List[str]:
"""
Run install_repos() with a custom INSTALLERS list and capture which
installer labels actually run.
NOTE:
We patch resolve_command_for_repo() to always return a dummy
command path so that command resolution does not interfere with
capability-layering tests.
We override each installer's supports() to always return True and
override run() to append its label to called_installers.
"""
if selected_repos is None:
repo = {}
repo = {"repository": "dummy"}
selected_repos = [repo]
all_repos = [repo]
else:
all_repos = selected_repos
called_installers: list[str] = []
called_installers: List[str] = []
# Prepare patched instances with recording run() and always-supports.
patched_installers = []
for label, inst in installers:
def always_supports(self, ctx):
return True
def make_run(label_name):
def make_run(label_name: str):
def _run(self, ctx):
called_installers.append(label_name)
return _run
inst.supports = always_supports.__get__(inst, inst.__class__)
inst.run = make_run(label).__get__(inst, inst.__class__)
inst.supports = always_supports.__get__(inst, inst.__class__) # type: ignore[assignment]
inst.run = make_run(label).__get__(inst, inst.__class__) # type: ignore[assignment]
patched_installers.append(inst)
with patch.object(install_mod, "INSTALLERS", patched_installers), \
patch.object(install_mod, "get_repo_identifier", return_value="dummy-repo"), \
patch.object(install_mod, "get_repo_dir", return_value=repo_dir), \
patch.object(install_mod, "verify_repository", return_value=(True, [], None, None)), \
patch.object(install_mod, "create_ink"), \
patch.object(install_mod, "clone_repos"), \
patch.object(install_mod, "resolve_command_for_repo", return_value="/bin/dummy"):
with patch.object(install_mod, "INSTALLERS", patched_installers), patch.object(
install_mod, "get_repo_identifier", return_value="dummy-repo"
), patch.object(
install_mod, "get_repo_dir", return_value=repo_dir
), patch.object(
install_mod, "verify_repository", return_value=(True, [], None, None)
), patch.object(
install_mod, "clone_repos"
):
install_repos(
selected_repos=selected_repos,
repositories_base_dir=self.tmp_root,
@@ -144,25 +103,25 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
no_verification=True,
preview=False,
quiet=False,
clone_mode="shallow",
clone_mode="ssh",
update_dependencies=False,
)
return called_installers
# ----------------------------------------------------------------- scenarios
# ------------------------------------------------------------------
# Scenario 1: Only Makefile with install target
# ------------------------------------------------------------------
def test_only_makefile_installer_runs(self) -> None:
"""
With only a Makefile present, only the MakefileInstaller should run.
"""
repo_dir = self._new_repo()
# Makefile: detect a real 'install' target for makefile layer.
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'installing from Makefile'\n")
f.write("install:\n\t@echo 'make install'\n")
mk_inst = MakefileInstaller()
installers = [("makefile", mk_inst)]
installers: Sequence[InstallerSpec] = [("makefile", mk_inst)]
called = self._run_with_installers(repo_dir, installers)
@@ -172,110 +131,85 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
"With only a Makefile, the MakefileInstaller should run exactly once.",
)
# ------------------------------------------------------------------
# Scenario 2: Python + Makefile, but pyproject.toml does NOT mention 'make install'
# → capabilities are disjoint, both installers should run.
# ------------------------------------------------------------------
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
"""
If Python and Makefile have disjoint capabilities, both installers run.
"""
repo_dir = self._new_repo()
# pyproject.toml: basic Python project, no 'make install' string.
# pyproject.toml without any explicit "make install" hint
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
"[project]\n"
"name = 'dummy'\n"
)
f.write("name = 'dummy'\n")
# Makefile: install target for makefile layer.
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'installing from Makefile'\n")
f.write("install:\n\t@echo 'make install'\n")
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
# Order: Python first, then Makefile
installers = [
installers: Sequence[InstallerSpec] = [
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
# Both should have run because:
# - Python provides {python-runtime}
# - Makefile provides {make-install}
self.assertEqual(
called,
["python", "makefile"],
"PythonInstaller and MakefileInstaller should both run when their capabilities are disjoint.",
"PythonInstaller and MakefileInstaller should both run when their "
"capabilities are disjoint.",
)
# ------------------------------------------------------------------
# Scenario 3: Python + Makefile, pyproject.toml mentions 'make install'
# → PythonInstaller provides {python-runtime, make-install}
# MakefileInstaller only {make-install}
# → MakefileInstaller must be skipped.
# ------------------------------------------------------------------
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
"""
If the Python layer advertises a 'make-install' capability (pyproject
explicitly hints at 'make install'), the Makefile layer must be skipped.
"""
repo_dir = self._new_repo()
# pyproject.toml: Python project with 'make install' hint.
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
"[project]\n"
"name = 'dummy'\n"
"\n"
"# Hint for MakeInstallCapability on layer 'python'\n"
"make install\n"
)
# Makefile: install target, but should be shadowed by Python.
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'installing from Makefile'\n")
f.write("install:\n\t@echo 'make install'\n")
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers = [
installers: Sequence[InstallerSpec] = [
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
# Python should run, Makefile should be skipped because its only
# capability (make-install) is already provided by Python.
self.assertIn("python", called, "PythonInstaller should have run.")
self.assertNotIn(
"makefile",
called,
"MakefileInstaller should be skipped because its 'make-install' capability "
"is already provided by Python.",
"MakefileInstaller should be skipped because its 'make-install' "
"capability is already provided by Python.",
)
# ------------------------------------------------------------------
# Scenario 4: Nix + Python + Makefile
# flake.nix provides python-runtime + make-install + nix-flake
# → Nix shadows both Python and Makefile.
# ------------------------------------------------------------------
def test_nix_shadows_python_and_makefile(self) -> None:
"""
If a Nix flake advertises both python-runtime and make-install
capabilities, Python and Makefile installers must be skipped.
"""
repo_dir = self._new_repo()
# pyproject.toml: generic Python project
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
"[project]\n"
"name = 'dummy'\n"
)
f.write("name = 'dummy'\n")
# Makefile: install target
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'installing from Makefile'\n")
f.write("install:\n\t@echo 'make install'\n")
# flake.nix: hints for both python-runtime and make-install on layer 'nix'
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write(
"{\n"
' description = "integration test flake";\n'
"}\n"
"\n"
@@ -289,8 +223,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers = [
installers: Sequence[InstallerSpec] = [
("nix", nix_inst),
("python", py_inst),
("makefile", mk_inst),
@@ -298,47 +231,35 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
called = self._run_with_installers(repo_dir, installers)
# Nix must run, Python and Makefile must be skipped:
# - Nix provides {python-runtime, make-install, nix-flake}
# - Python provides {python-runtime}
# - Makefile provides {make-install}
self.assertIn("nix", called, "NixFlakeInstaller should have run.")
self.assertNotIn(
"python",
called,
"PythonInstaller should be skipped because its python-runtime capability "
"is already provided by Nix.",
"PythonInstaller should be skipped because its python-runtime "
"capability is already provided by Nix.",
)
self.assertNotIn(
"makefile",
called,
"MakefileInstaller should be skipped because its make-install capability "
"is already provided by Nix.",
"MakefileInstaller should be skipped because its make-install "
"capability is already provided by Nix.",
)
# ------------------------------------------------------------------
# Scenario 5: OS packages + Nix + Python + Makefile
# PKGBUILD provides python-runtime + make-install + nix-flake
# → ArchPkgbuildInstaller shadows everything below.
# ------------------------------------------------------------------
def test_os_packages_shadow_nix_python_and_makefile(self) -> None:
"""
If an OS package layer (PKGBUILD) advertises all capabilities,
all lower layers (Nix, Python, Makefile) must be skipped.
"""
repo_dir = self._new_repo()
# pyproject.toml: enough to signal a Python project
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
"[project]\n"
"name = 'dummy'\n"
)
f.write("name = 'dummy'\n")
# Makefile: install target
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'installing from Makefile'\n")
f.write("install:\n\t@echo 'make install'\n")
# flake.nix: as before
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write(
"{\n"
' description = "integration test flake";\n'
"}\n"
"\n"
@@ -346,13 +267,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
"make install\n"
)
# PKGBUILD: contains patterns for all three capabilities on layer 'os-packages':
# - "pip install ." → python-runtime
# - "make install" → make-install
# - "nix profile" → nix-flake
with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
f.write(
"pkgname=dummy\n"
"pkgver=0.1\n"
"pkgrel=1\n"
"pkgdesc='dummy pkg for integration test'\n"
@@ -376,8 +292,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers = [
installers: Sequence[InstallerSpec] = [
("os-packages", os_inst),
("nix", nix_inst),
("python", py_inst),
@@ -386,11 +301,6 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
called = self._run_with_installers(repo_dir, installers)
# ArchPkgbuildInstaller must run, and everything below must be skipped:
# - os-packages provides {python-runtime, make-install, nix-flake}
# - nix provides {python-runtime, make-install, nix-flake}
# - python provides {python-runtime}
# - makefile provides {make-install}
self.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
self.assertNotIn(
"nix",