Files
pkgmgr/tests/integration/test_recursive_capabilities.py

318 lines
11 KiB
Python
Raw Normal View History

"""
fix(install): install a repository once, by the first hook that supports it The pipeline used to skip a second installer only when it advertised capabilities the first had already provided, so a hook with a different capability still ran on the very same project. file-dedupe was installed through pip and then again through `make install`, whose first line upgrades pip; PEP 668 refuses that, and the run went red on a repository that was already installed correctly. One hook now installs a repository and the pipeline stops there. The order of INSTALLERS is the preference: `make install` leads, because it is the hook a repository writes for itself, while the others infer an installation from a manifest. A hook that fails installed nothing, so the rule is one *successful* installation rather than one attempt: _run_installer returns a bool instead of re-raising, and the caller moves on to the next hook. To keep that fallback from turning a broken repository into a silent pass, a repository whose hooks all fail now exits with the list of what was tried. The capability bookkeeping the old skip rule needed is gone from the pipeline; discover_capabilities remains on BaseInstaller and is now unused there. Verified: 14 targeted unit and integration tests, and a full `pkgmgr update --all` sweep in the arch image with 0 installation failures. Delta measured at the consumer: repositories left without a CLI entry point, 16 before and 16 after, diff identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:58:00 +02:00
Integration tests for installer selection across repository layouts.
Layer order (strongest weakest):
OS-PACKAGES > NIX > PYTHON > MAKEFILE
"""
import os
import shutil
import tempfile
import unittest
from collections.abc import Sequence
from unittest.mock import patch
2025-12-10 17:27:26 +01:00
import pkgmgr.actions.install as install_mod
from pkgmgr.actions.install import install_repos
from pkgmgr.actions.install.installers.makefile import MakefileInstaller
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller
2025-12-10 17:27:26 +01:00
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
ArchPkgbuildInstaller,
)
2025-12-10 17:27:26 +01:00
from pkgmgr.actions.install.installers.python import PythonInstaller
InstallerSpec = tuple[str, object]
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
def setUp(self) -> None:
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)
# ------------------------------------------------------------------ helpers
def _new_repo(self) -> str:
"""
Create a fresh temporary repo directory under self.tmp_root.
"""
return tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
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.
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 = {"repository": "dummy"}
selected_repos = [repo]
all_repos = [repo]
else:
all_repos = selected_repos
called_installers: list[str] = []
patched_installers = []
for label, inst in installers:
2025-12-18 14:04:44 +01:00
def always_supports(self, ctx):
return True
def make_run(label_name: str):
def _run(self, ctx):
called_installers.append(label_name)
2025-12-18 14:04:44 +01:00
return _run
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)
2025-12-18 14:04:44 +01:00
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,
bin_dir=self.bin_dir,
all_repos=all_repos,
no_verification=True,
preview=False,
quiet=False,
clone_mode="ssh",
update_dependencies=False,
)
return called_installers
# ----------------------------------------------------------------- scenarios
def test_only_makefile_installer_runs(self) -> None:
"""
With only a Makefile present, only the MakefileInstaller should run.
"""
repo_dir = self._new_repo()
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'make install'\n")
mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [("makefile", mk_inst)]
called = self._run_with_installers(repo_dir, installers)
self.assertEqual(
called,
["makefile"],
"With only a Makefile, the MakefileInstaller should run exactly once.",
)
fix(install): install a repository once, by the first hook that supports it The pipeline used to skip a second installer only when it advertised capabilities the first had already provided, so a hook with a different capability still ran on the very same project. file-dedupe was installed through pip and then again through `make install`, whose first line upgrades pip; PEP 668 refuses that, and the run went red on a repository that was already installed correctly. One hook now installs a repository and the pipeline stops there. The order of INSTALLERS is the preference: `make install` leads, because it is the hook a repository writes for itself, while the others infer an installation from a manifest. A hook that fails installed nothing, so the rule is one *successful* installation rather than one attempt: _run_installer returns a bool instead of re-raising, and the caller moves on to the next hook. To keep that fallback from turning a broken repository into a silent pass, a repository whose hooks all fail now exits with the list of what was tried. The capability bookkeeping the old skip rule needed is gone from the pipeline; discover_capabilities remains on BaseInstaller and is now unused there. Verified: 14 targeted unit and integration tests, and a full `pkgmgr update --all` sweep in the arch image with 0 installation failures. Delta measured at the consumer: repositories left without a CLI entry point, 16 before and 16 after, diff identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:58:00 +02:00
def test_only_the_first_supported_hook_runs(self) -> None:
"""A repository with two usable hooks is installed by the first one."""
repo_dir = self._new_repo()
# 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("name = 'dummy'\n")
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'make install'\n")
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
self.assertEqual(
called,
fix(install): install a repository once, by the first hook that supports it The pipeline used to skip a second installer only when it advertised capabilities the first had already provided, so a hook with a different capability still ran on the very same project. file-dedupe was installed through pip and then again through `make install`, whose first line upgrades pip; PEP 668 refuses that, and the run went red on a repository that was already installed correctly. One hook now installs a repository and the pipeline stops there. The order of INSTALLERS is the preference: `make install` leads, because it is the hook a repository writes for itself, while the others infer an installation from a manifest. A hook that fails installed nothing, so the rule is one *successful* installation rather than one attempt: _run_installer returns a bool instead of re-raising, and the caller moves on to the next hook. To keep that fallback from turning a broken repository into a silent pass, a repository whose hooks all fail now exits with the list of what was tried. The capability bookkeeping the old skip rule needed is gone from the pipeline; discover_capabilities remains on BaseInstaller and is now unused there. Verified: 14 targeted unit and integration tests, and a full `pkgmgr update --all` sweep in the arch image with 0 installation failures. Delta measured at the consumer: repositories left without a CLI entry point, 16 before and 16 after, diff identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:58:00 +02:00
["python"],
"Only the first supported hook may install the repository.",
)
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()
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write(
"name = 'dummy'\n"
"\n"
"# Hint for MakeInstallCapability on layer 'python'\n"
"make install\n"
)
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'make install'\n")
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
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.",
)
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()
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write("name = 'dummy'\n")
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'make install'\n")
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write(
' description = "integration test flake";\n'
"}\n"
"\n"
"# Hint for PythonRuntimeCapability on layer 'nix'\n"
"buildPythonApplication something\n"
"\n"
"# Hint for MakeInstallCapability on layer 'nix'\n"
"make install\n"
)
nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
("nix", nix_inst),
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
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.",
)
self.assertNotIn(
"makefile",
called,
"MakefileInstaller should be skipped because its make-install "
"capability is already provided by Nix.",
)
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()
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write("name = 'dummy'\n")
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
f.write("install:\n\t@echo 'make install'\n")
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write(
' description = "integration test flake";\n'
"}\n"
"\n"
"buildPythonApplication something\n"
"make install\n"
)
with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
f.write(
"pkgver=0.1\n"
"pkgrel=1\n"
"pkgdesc='dummy pkg for integration test'\n"
"arch=('any')\n"
"source=()\n"
"sha256sums=()\n"
"\n"
"build() {\n"
" echo 'build phase'\n"
"}\n"
"\n"
"package() {\n"
" echo 'install via pip and make and nix'\n"
" pip install .\n"
" make install\n"
" nix profile list || true\n"
"}\n"
)
os_inst = ArchPkgbuildInstaller()
nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller()
mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
("os-packages", os_inst),
("nix", nix_inst),
("python", py_inst),
("makefile", mk_inst),
]
called = self._run_with_installers(repo_dir, installers)
self.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
self.assertNotIn(
"nix",
called,
"NixFlakeInstaller should be skipped because all its capabilities "
"are already provided by os-packages.",
)
self.assertNotIn(
"python",
called,
"PythonInstaller should be skipped because python-runtime is already "
"provided by os-packages.",
)
self.assertNotIn(
"makefile",
called,
"MakefileInstaller should be skipped because make-install is already "
"provided by os-packages.",
)
if __name__ == "__main__":
unittest.main()