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

@@ -1,3 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os import os
import tempfile import tempfile
import unittest import unittest
@@ -16,10 +19,10 @@ class DummyInstaller(BaseInstaller):
layer = None layer = None
def supports(self, ctx): def supports(self, ctx): # type: ignore[override]
return True return True
def run(self, ctx): def run(self, ctx): # type: ignore[override]
return return
@@ -34,31 +37,34 @@ class TestInstallReposIntegration(unittest.TestCase):
mock_get_repo_dir, mock_get_repo_dir,
mock_clone_repos, mock_clone_repos,
mock_verify_repository, mock_verify_repository,
): ) -> None:
""" """
Full integration test for high-level command resolution + symlink creation. Integration test:
We do NOT re-test all low-level file-system details of We do NOT re-test the low-level implementation details of
resolve_command_for_repo here (that is covered by unit tests). resolve_command_for_repo() here (that is covered by unit tests).
Instead, we assert that:
- If resolve_command_for_repo(...) returns None: Instead, we assert the high-level behavior of install_repos() +
→ install_repos() does NOT create a symlink. InstallationPipeline + create_ink():
- If resolve_command_for_repo(...) returns a path: * If resolve_command_for_repo(...) returns None:
→ install_repos() creates exactly one symlink in bin_dir → install_repos() must NOT create a symlink for that repo.
* If resolve_command_for_repo(...) returns a path:
→ install_repos() must create exactly one symlink in bin_dir
that points to this path. that points to this path.
Concretely: Concretely in this test:
- repo-system: * repo-system:
resolve_command_for_repo(...) → None fake resolver → returns None
→ no symlink in bin_dir for this repo. → no symlink in bin_dir for this repo.
- repo-nix: * repo-nix:
resolve_command_for_repo(...) → "/nix/profile/bin/repo-nix" fake resolver → returns "/nix/profile/bin/repo-nix"
→ exactly one symlink in bin_dir pointing to that path. → exactly one symlink in bin_dir pointing to that path.
""" """
# Repositories must have provider/account/repository so that get_repo_dir() # Repositories must have provider/account/repository so that get_repo_dir()
# does not crash when called from create_ink(). # does not crash when called from create_ink().
repo_system = { repo_system = {
@@ -77,9 +83,7 @@ class TestInstallReposIntegration(unittest.TestCase):
selected_repos = [repo_system, repo_nix] selected_repos = [repo_system, repo_nix]
all_repos = selected_repos all_repos = selected_repos
with tempfile.TemporaryDirectory() as tmp_base, \ with tempfile.TemporaryDirectory() as tmp_base, tempfile.TemporaryDirectory() as tmp_bin:
tempfile.TemporaryDirectory() as tmp_bin:
# Fake repo directories (what get_repo_dir will return) # Fake repo directories (what get_repo_dir will return)
repo_system_dir = os.path.join(tmp_base, "repo-system") repo_system_dir = os.path.join(tmp_base, "repo-system")
repo_nix_dir = os.path.join(tmp_base, "repo-nix") repo_nix_dir = os.path.join(tmp_base, "repo-nix")
@@ -97,11 +101,15 @@ class TestInstallReposIntegration(unittest.TestCase):
# Pretend this is the "Nix binary" path for repo-nix # Pretend this is the "Nix binary" path for repo-nix
nix_tool_path = "/nix/profile/bin/repo-nix" nix_tool_path = "/nix/profile/bin/repo-nix"
# Patch resolve_command_for_repo at the install_repos module level # Patch resolve_command_for_repo at the *pipeline* module level,
with patch("pkgmgr.actions.repository.install.resolve_command_for_repo") as mock_resolve, \ # because InstallationPipeline imports it there.
patch("pkgmgr.actions.repository.install.os.path.exists") as mock_exists_install: with patch(
"pkgmgr.actions.repository.install.pipeline.resolve_command_for_repo"
) as mock_resolve, patch(
"pkgmgr.actions.repository.install.os.path.exists"
) as mock_exists_install:
def fake_resolve_command(repo, repo_identifier: str, repo_dir: str): def fake_resolve(repo, repo_identifier: str, repo_dir: str):
""" """
High-level behavior stub: High-level behavior stub:
@@ -111,9 +119,10 @@ class TestInstallReposIntegration(unittest.TestCase):
- For repo-nix: act as if a Nix profile binary is the entrypoint - For repo-nix: act as if a Nix profile binary is the entrypoint
→ return nix_tool_path (symlink should be created). → return nix_tool_path (symlink should be created).
""" """
if repo_identifier == "repo-system": name = repo.get("name")
if name == "repo-system":
return None return None
if repo_identifier == "repo-nix": if name == "repo-nix":
return nix_tool_path return nix_tool_path
return None return None
@@ -126,7 +135,7 @@ class TestInstallReposIntegration(unittest.TestCase):
return True return True
return False return False
mock_resolve.side_effect = fake_resolve_command mock_resolve.side_effect = fake_resolve
mock_exists_install.side_effect = fake_exists_install mock_exists_install.side_effect = fake_exists_install
# Use only DummyInstaller so we focus on link creation, not installer behavior # Use only DummyInstaller so we focus on link creation, not installer behavior

View File

@@ -1,6 +1,16 @@
# Capability Resolution & Installer Shadowing # Capability Resolution & Installer Shadowing
## Layer Hierarchy This document explains how `pkgmgr` decides **which installer should run** when multiple installation mechanisms are available in a repository.
It reflects the logic shown in the setup-controller diagram:
➡️ **Full graphical schema:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
---
## Layer Hierarchy (Strength Order)
Installers are evaluated from **strongest to weakest**.
A stronger layer shadows all layers below it.
``` ```
┌───────────────────────────┐ Highest layer ┌───────────────────────────┐ Highest layer
@@ -22,7 +32,24 @@
--- ---
## Scenario Matrix ## Capability Matrix
Each layer provides a set of **capabilities**.
Layers that provide *all* capabilities of a lower layer **shadow** that layer.
| Capability | Makefile | Python | Nix | OS-Pkgs |
| -------------------- | -------- | ------------ | --- | ------- |
| `make-install` | ✔ | (optional) ✔ | ✔ | ✔ |
| `python-runtime` | | ✔ | ✔ | ✔ |
| `binary/cli` | | | ✔ | ✔ |
| `system-integration` | | | | ✔ |
✔ = capability available
= not provided by this layer
---
## Scenario Matrix (Expected Installer Execution)
| Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name | | Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name |
| -------------------------- | -------- | ------ | --- | ------- | ----------------------------- | | -------------------------- | -------- | ------ | --- | ------- | ----------------------------- |
@@ -34,40 +61,41 @@
Legend: Legend:
✔ = installer runs ✔ = installer runs
✗ = installer skipped (shadowed by upper layer) ✗ = installer is skipped (shadowed)
= no such layer present = layer not present in this scenario
--- ---
## What the Integration Test Confirms ## What the Integration Test Confirms
**Goal:** Validate that the capability-shadowing mechanism correctly determines *which installers actually run* for a given repository layout. The integration tests ensure that the **actual execution** matches the theoretical capability model.
### 1) Only Makefile ### 1) Only Makefile
* Makefile provides `make-install`. * Only `Makefile` present
* No higher layers → MakefileInstaller runs. → MakefileInstaller runs.
### 2) Python + Makefile ### 2) Python + Makefile
* Python provides `python-runtime`. * Python provides `python-runtime`
* Makefile additionally provides `make-install`. * Makefile provides `make-install`
* No capability overlap → both installers run. → Both run (capabilities are disjoint).
### 3) Python shadows Makefile ### 3) Python shadows Makefile
* Python also provides `make-install`. * Python additionally advertises `make-install`
* Makefiles capability is fully covered → MakefileInstaller is skipped. → MakefileInstaller is skipped.
### 4) Nix shadows Python & Makefile ### 4) Nix shadows Python & Makefile
* Nix provides all capabilities below it. * Nix provides: `python-runtime` + `make-install`
* Only NixInstaller runs. → PythonInstaller and MakefileInstaller are skipped.
→ Only NixInstaller runs.
### 5) OS-Packages shadow all ### 5) OS-Pkg layer shadows all
* PKGBUILD/debian/rpm provide all capabilities. * OS packages provide all capabilities
* Only the corresponding OS package installer runs. Only OS installer runs.
--- ---
@@ -111,6 +139,14 @@ Legend:
--- ---
## Core Principle (one sentence) ## Core Principle
**A layer only executes if it provides at least one capability not already guaranteed by any higher layer.** **A layer is executed only if it contributes at least one capability that no stronger layer has already provided.**
---
## Link to the Setup Controller Diagram
The full visual schema is available here:
➡️ **[https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)**

View File

@@ -2,140 +2,99 @@
# -*- coding: utf-8 -*- # -*- 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") Layer order (strongest → weakest):
- PythonInstaller (layer: "python")
- NixFlakeInstaller (layer: "nix")
- ArchPkgbuildInstaller (layer: "os-packages")
The core idea: OS-PACKAGES > NIX > PYTHON > MAKEFILE
- 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.
""" """
import os import os
import shutil import shutil
import tempfile import tempfile
import unittest import unittest
from typing import List, Sequence, Tuple
from unittest.mock import patch from unittest.mock import patch
import pkgmgr.actions.repository.install as install_mod import pkgmgr.actions.repository.install as install_mod
from pkgmgr.actions.repository.install import install_repos 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.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): class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
# Temporary base directory for this test class self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-recursive-caps-")
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-integration-")
self.bin_dir = os.path.join(self.tmp_root, "bin") self.bin_dir = os.path.join(self.tmp_root, "bin")
os.makedirs(self.bin_dir, exist_ok=True) os.makedirs(self.bin_dir, exist_ok=True)
def tearDown(self) -> None: def tearDown(self) -> None:
shutil.rmtree(self.tmp_root) shutil.rmtree(self.tmp_root)
# ------------------------------------------------------------------ # ------------------------------------------------------------------ helpers
# Helper: create a new repo directory for a scenario
# ------------------------------------------------------------------
def _new_repo(self) -> str: 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 Create a fresh temporary repo directory under self.tmp_root.
dummy repo; return the list of installer labels that actually ran. """
return tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
The installers' supports() are forced to True so that only the def _run_with_installers(
capability-shadowing logic decides whether they are skipped. self,
The installers' run() methods are patched to avoid real commands. 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 override each installer's supports() to always return True and
We patch resolve_command_for_repo() to always return a dummy override run() to append its label to called_installers.
command path so that command resolution does not interfere with
capability-layering tests.
""" """
if selected_repos is None: if selected_repos is None:
repo = {} repo = {"repository": "dummy"}
selected_repos = [repo] selected_repos = [repo]
all_repos = [repo] all_repos = [repo]
else: else:
all_repos = selected_repos all_repos = selected_repos
called_installers: list[str] = [] called_installers: List[str] = []
# Prepare patched instances with recording run() and always-supports.
patched_installers = [] patched_installers = []
for label, inst in installers: for label, inst in installers:
def always_supports(self, ctx): def always_supports(self, ctx):
return True return True
def make_run(label_name): def make_run(label_name: str):
def _run(self, ctx): def _run(self, ctx):
called_installers.append(label_name) called_installers.append(label_name)
return _run return _run
inst.supports = always_supports.__get__(inst, inst.__class__) inst.supports = always_supports.__get__(inst, inst.__class__) # type: ignore[assignment]
inst.run = make_run(label).__get__(inst, inst.__class__) inst.run = make_run(label).__get__(inst, inst.__class__) # type: ignore[assignment]
patched_installers.append(inst) patched_installers.append(inst)
with patch.object(install_mod, "INSTALLERS", patched_installers), \ with patch.object(install_mod, "INSTALLERS", patched_installers), patch.object(
patch.object(install_mod, "get_repo_identifier", return_value="dummy-repo"), \ install_mod, "get_repo_identifier", return_value="dummy-repo"
patch.object(install_mod, "get_repo_dir", return_value=repo_dir), \ ), patch.object(
patch.object(install_mod, "verify_repository", return_value=(True, [], None, None)), \ install_mod, "get_repo_dir", return_value=repo_dir
patch.object(install_mod, "create_ink"), \ ), patch.object(
patch.object(install_mod, "clone_repos"), \ install_mod, "verify_repository", return_value=(True, [], None, None)
patch.object(install_mod, "resolve_command_for_repo", return_value="/bin/dummy"): ), patch.object(
install_mod, "clone_repos"
):
install_repos( install_repos(
selected_repos=selected_repos, selected_repos=selected_repos,
repositories_base_dir=self.tmp_root, repositories_base_dir=self.tmp_root,
@@ -144,25 +103,25 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
no_verification=True, no_verification=True,
preview=False, preview=False,
quiet=False, quiet=False,
clone_mode="shallow", clone_mode="ssh",
update_dependencies=False, update_dependencies=False,
) )
return called_installers return called_installers
# ----------------------------------------------------------------- scenarios
# ------------------------------------------------------------------
# Scenario 1: Only Makefile with install target
# ------------------------------------------------------------------
def test_only_makefile_installer_runs(self) -> None: def test_only_makefile_installer_runs(self) -> None:
"""
With only a Makefile present, only the MakefileInstaller should run.
"""
repo_dir = self._new_repo() 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: 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() mk_inst = MakefileInstaller()
installers = [("makefile", mk_inst)] installers: Sequence[InstallerSpec] = [("makefile", mk_inst)]
called = self._run_with_installers(repo_dir, installers) 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.", "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: 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() 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: with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write( f.write("name = 'dummy'\n")
"[project]\n"
"name = 'dummy'\n"
)
# Makefile: install target for makefile layer.
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f: 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() py_inst = PythonInstaller()
mk_inst = MakefileInstaller() mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
# Order: Python first, then Makefile
installers = [
("python", py_inst), ("python", py_inst),
("makefile", mk_inst), ("makefile", mk_inst),
] ]
called = self._run_with_installers(repo_dir, installers) called = self._run_with_installers(repo_dir, installers)
# Both should have run because:
# - Python provides {python-runtime}
# - Makefile provides {make-install}
self.assertEqual( self.assertEqual(
called, called,
["python", "makefile"], ["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: 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() 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: with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write( f.write(
"[project]\n"
"name = 'dummy'\n" "name = 'dummy'\n"
"\n" "\n"
"# Hint for MakeInstallCapability on layer 'python'\n" "# Hint for MakeInstallCapability on layer 'python'\n"
"make install\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: 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() py_inst = PythonInstaller()
mk_inst = MakefileInstaller() mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
installers = [
("python", py_inst), ("python", py_inst),
("makefile", mk_inst), ("makefile", mk_inst),
] ]
called = self._run_with_installers(repo_dir, installers) 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.assertIn("python", called, "PythonInstaller should have run.")
self.assertNotIn( self.assertNotIn(
"makefile", "makefile",
called, called,
"MakefileInstaller should be skipped because its 'make-install' capability " "MakefileInstaller should be skipped because its 'make-install' "
"is already provided by Python.", "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: 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() 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: with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write( f.write("name = 'dummy'\n")
"[project]\n"
"name = 'dummy'\n"
)
# Makefile: install target
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f: 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: with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write( f.write(
"{\n"
' description = "integration test flake";\n' ' description = "integration test flake";\n'
"}\n" "}\n"
"\n" "\n"
@@ -289,8 +223,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
nix_inst = NixFlakeInstaller() nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller() py_inst = PythonInstaller()
mk_inst = MakefileInstaller() mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
installers = [
("nix", nix_inst), ("nix", nix_inst),
("python", py_inst), ("python", py_inst),
("makefile", mk_inst), ("makefile", mk_inst),
@@ -298,47 +231,35 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
called = self._run_with_installers(repo_dir, installers) 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.assertIn("nix", called, "NixFlakeInstaller should have run.")
self.assertNotIn( self.assertNotIn(
"python", "python",
called, called,
"PythonInstaller should be skipped because its python-runtime capability " "PythonInstaller should be skipped because its python-runtime "
"is already provided by Nix.", "capability is already provided by Nix.",
) )
self.assertNotIn( self.assertNotIn(
"makefile", "makefile",
called, called,
"MakefileInstaller should be skipped because its make-install capability " "MakefileInstaller should be skipped because its make-install "
"is already provided by Nix.", "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: 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() 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: with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
f.write( f.write("name = 'dummy'\n")
"[project]\n"
"name = 'dummy'\n"
)
# Makefile: install target
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f: 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: with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
f.write( f.write(
"{\n"
' description = "integration test flake";\n' ' description = "integration test flake";\n'
"}\n" "}\n"
"\n" "\n"
@@ -346,13 +267,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
"make install\n" "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: with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
f.write( f.write(
"pkgname=dummy\n"
"pkgver=0.1\n" "pkgver=0.1\n"
"pkgrel=1\n" "pkgrel=1\n"
"pkgdesc='dummy pkg for integration test'\n" "pkgdesc='dummy pkg for integration test'\n"
@@ -376,8 +292,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
nix_inst = NixFlakeInstaller() nix_inst = NixFlakeInstaller()
py_inst = PythonInstaller() py_inst = PythonInstaller()
mk_inst = MakefileInstaller() mk_inst = MakefileInstaller()
installers: Sequence[InstallerSpec] = [
installers = [
("os-packages", os_inst), ("os-packages", os_inst),
("nix", nix_inst), ("nix", nix_inst),
("python", py_inst), ("python", py_inst),
@@ -386,11 +301,6 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
called = self._run_with_installers(repo_dir, installers) 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.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
self.assertNotIn( self.assertNotIn(
"nix", "nix",