diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000..5c9a888 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,29 @@ +name: Test package-manager (integration) + +on: + push: + branches: + - main + - master + - develop + - "*" + pull_request: + +jobs: + test-integration: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show Docker version + run: docker version + + # Build Arch test image (same as used in test-unit and test-e2e) + - name: Build test images + run: make build + + - name: Run integration tests via make (Arch container) + run: make test-integration diff --git a/Makefile b/Makefile index 37554e9..3188e74 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: install setup uninstall aur_builder_setup \ - test build build-no-cache test-unit test-e2e + test build build-no-cache test-unit test-e2e test-integration # ------------------------------------------------------------ # Local Nix cache directories in the repo @@ -99,6 +99,30 @@ test-unit: build -p "test_*.py"; \ ' +# Integration tests: also in Arch container, but using tests/integration +test-integration: build + @echo "============================================================" + @echo ">>> Running INTEGRATION tests in Arch container" + @echo "============================================================" + docker run --rm \ + -v "$$(pwd):/src" \ + --workdir /src \ + --entrypoint bash \ + "package-manager-test-arch" \ + -c '\ + set -e; \ + if [ -f /etc/os-release ]; then . /etc/os-release; fi; \ + echo "Detected container distro: $${ID:-unknown} (like: $${ID_LIKE:-})"; \ + echo "Running Python integration tests (tests/integration)..."; \ + git config --global --add safe.directory /src || true; \ + cd /src; \ + export PYTHONPATH=/src:$${PYTHONPATH}; \ + python -m unittest discover \ + -s tests/integration \ + -t /src \ + -p "test_*.py"; \ + ' + # End-to-end tests: run in all distros via Nix devShell (tests/e2e) test-e2e: build @echo "Ensuring Docker Nix volumes exist (auto-created if missing)..." @@ -166,8 +190,8 @@ test-e2e: build ' || exit $$?; \ done -# Combined test target for local + CI (unit + e2e) -test: build test-unit test-e2e +# Combined test target for local + CI (unit + e2e + integration) +test: build test-unit test-e2e test-integration # ------------------------------------------------------------ # Installer for host systems (original logic) diff --git a/pkgmgr/capabilities.py b/pkgmgr/capabilities.py index 6463453..c64d36d 100644 --- a/pkgmgr/capabilities.py +++ b/pkgmgr/capabilities.py @@ -14,6 +14,20 @@ Each capability is represented by a class that: This allows pkgmgr to dynamically decide if a higher layer already covers work a lower layer would otherwise do (e.g. Nix calling pyproject/make, or distro packages wrapping Nix or Makefile logic). + +On top of the raw detection, this module also exposes a bottom-up +"effective capability" resolver: + + - We start from the lowest layer (e.g. "makefile") and go upwards. + - For each capability provided by a lower layer, we check whether any + higher layer also provides the same capability. + - If yes, we consider the capability "shadowed" by the higher layer; + the lower layer does not list it as an effective capability. + - If no higher layer provides it, the capability remains attached to + the lower layer. + +This yields, for each layer, only those capabilities that are not +redundant with respect to higher layers in the stack. """ from __future__ import annotations @@ -50,6 +64,8 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b """ lower_patterns = [p.lower() for p in patterns] for path in files: + if not path: + continue content = _read_text_if_exists(path) if not content: continue @@ -295,3 +311,97 @@ CAPABILITY_MATCHERS: list[CapabilityMatcher] = [ MakeInstallCapability(), NixFlakeCapability(), ] + + +# --------------------------------------------------------------------------- +# Layer ordering and effective capability resolution +# --------------------------------------------------------------------------- + +#: Default bottom-up order of installer layers. +#: Lower indices = lower layers; higher indices = higher layers. +LAYER_ORDER: list[str] = [ + "makefile", + "python", + "nix", + "os-packages", +] + + +def detect_capabilities( + ctx: "RepoContext", + layers: Iterable[str], +) -> dict[str, set[str]]: + """ + Perform raw capability detection per layer, without any shadowing. + + Returns a mapping: + + { + "makefile": {"make-install"}, + "python": {"python-runtime", "make-install"}, + "nix": {"python-runtime", "make-install", "nix-flake"}, + "os-packages": set(), + } + + depending on which matchers report capabilities for each layer. + """ + layers_list = list(layers) + caps_by_layer: dict[str, set[str]] = {layer: set() for layer in layers_list} + + for matcher in CAPABILITY_MATCHERS: + for layer in layers_list: + if not matcher.applies_to_layer(layer): + continue + if matcher.is_provided(ctx, layer): + caps_by_layer[layer].add(matcher.name) + + return caps_by_layer + + +def resolve_effective_capabilities( + ctx: "RepoContext", + layers: Iterable[str] | None = None, +) -> dict[str, set[str]]: + """ + Resolve *effective* capabilities for each layer using a bottom-up strategy. + + Algorithm (layer-agnostic, works for all layers in the given order): + + 1. Run raw detection (detect_capabilities) to obtain which capabilities + are provided by which layer. + 2. Iterate layers from bottom to top (the order in `layers`): + For each capability that a lower layer provides, check whether + any *higher* layer also provides the same capability. + - If yes, the capability is considered "shadowed" by the higher + layer and is NOT listed as effective for the lower layer. + - If no higher layer provides it, it remains as an effective + capability of the lower layer. + 3. Return a mapping layer → set of effective capabilities. + + This means *any* higher layer can overshadow a lower layer, not just + a specific one like Nix. The resolver is completely generic. + """ + if layers is None: + layers_list = list(LAYER_ORDER) + else: + layers_list = list(layers) + + raw_caps = detect_capabilities(ctx, layers_list) + effective: dict[str, set[str]] = {layer: set() for layer in layers_list} + + # Bottom-up walk: lower index = lower layer, higher index = higher layer + for idx, lower in enumerate(layers_list): + lower_caps = raw_caps.get(lower, set()) + for cap in lower_caps: + # Check if any higher layer also provides this capability + covered_by_higher = False + for higher in layers_list[idx + 1 :]: + higher_caps = raw_caps.get(higher, set()) + if cap in higher_caps: + covered_by_higher = True + break + + if not covered_by_higher: + effective[lower].add(cap) + + return effective diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_recursive_capabilities_integration.md b/tests/integration/test_recursive_capabilities_integration.md new file mode 100644 index 0000000..0c17896 --- /dev/null +++ b/tests/integration/test_recursive_capabilities_integration.md @@ -0,0 +1,116 @@ +# Capability Resolution & Installer Shadowing + +## Layer Hierarchy + +``` +┌───────────────────────────┐ Highest layer +│ OS-PACKAGES │ (PKGBUILD / debian / rpm) +└───────────▲───────────────┘ + │ shadows lower layers +┌───────────┴───────────────┐ +│ NIX │ (flake.nix) +└───────────▲───────────────┘ + │ shadows lower layers +┌───────────┴───────────────┐ +│ PYTHON │ (pyproject.toml) +└───────────▲───────────────┘ + │ shadows lower layers +┌───────────┴───────────────┐ +│ MAKEFILE │ (Makefile) +└────────────────────────────┘ Lowest layer +``` + +--- + +## Scenario Matrix + +| Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name | +| -------------------------- | -------- | ------ | --- | ------- | ----------------------------- | +| 1) Only Makefile | ✔ | – | – | – | `only_makefile` | +| 2) Python + Makefile | ✔ | ✔ | – | – | `python_and_makefile` | +| 3) Python shadows Makefile | ✗ | ✔ | – | – | `python_shadows_makefile` | +| 4) Nix shadows Py & MF | ✗ | ✗ | ✔ | – | `nix_shadows_python_makefile` | +| 5) OS-Pkgs shadow all | ✗ | ✗ | ✗ | ✔ | `os_packages_shadow_all` | + +Legend: +✔ = installer runs +✗ = installer skipped (shadowed by upper layer) +– = no such layer present + +--- + +## What the Integration Test Confirms + +**Goal:** Validate that the capability-shadowing mechanism correctly determines *which installers actually run* for a given repository layout. + +### 1) Only Makefile + +* Makefile provides `make-install`. +* No higher layers → MakefileInstaller runs. + +### 2) Python + Makefile + +* Python provides `python-runtime`. +* Makefile additionally provides `make-install`. +* No capability overlap → both installers run. + +### 3) Python shadows Makefile + +* Python also provides `make-install`. +* Makefile’s capability is fully covered → MakefileInstaller is skipped. + +### 4) Nix shadows Python & Makefile + +* Nix provides all capabilities below it. +* Only NixInstaller runs. + +### 5) OS-Packages shadow all + +* PKGBUILD/debian/rpm provide all capabilities. +* Only the corresponding OS package installer runs. + +--- + +## Capability Processing Flowchart + +``` + ┌────────────────────────────┐ + │ Start │ + └───────────────┬────────────┘ + │ + provided_capabilities = ∅ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ For each installer in layer order (low→high) │ + └───────────────────┬──────────────────────────┘ + │ + supports(ctx)?│ + ┌─────────┴──────────┐ + │ no │ + │ → skip installer │ + └─────────┬──────────┘ + │ yes + ▼ + caps = detect_capabilities(layer) + │ + caps ⊆ provided_capabilities ? + ┌─────────────┬─────────────┐ + │ yes │ no │ + │ skip │ run installer│ + └─────────────┴──────────────┘ + │ + ▼ + provided_capabilities ∪= caps + │ + ▼ + ┌────────────────────────┐ + │ End of installer list │ + └────────────────────────┘ +``` + +--- + +## Core Principle (one sentence) + +**A layer only executes if it provides at least one capability not already guaranteed by any higher layer.** diff --git a/tests/integration/test_recursive_capabilities_integration.py b/tests/integration/test_recursive_capabilities_integration.py new file mode 100644 index 0000000..1312ddc --- /dev/null +++ b/tests/integration/test_recursive_capabilities_integration.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Integration tests for the recursive / layered capability handling in pkgmgr. + +We focus on the interaction between: + + - MakefileInstaller (layer: "makefile") + - PythonInstaller (layer: "python") + - NixFlakeInstaller (layer: "nix") + - ArchPkgbuildInstaller (layer: "os-packages") + +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. +""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch + +import pkgmgr.install_repos as install_mod +from pkgmgr.install_repos import install_repos +from pkgmgr.installers.nix_flake import NixFlakeInstaller +from pkgmgr.installers.python import PythonInstaller +from pkgmgr.installers.makefile import MakefileInstaller +from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller + + +class TestRecursiveCapabilitiesIntegration(unittest.TestCase): + def setUp(self) -> None: + # Temporary base directory for this test class + self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-integration-") + 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 + # ------------------------------------------------------------------ + 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. + + 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. + """ + if selected_repos is None: + repo = {} + selected_repos = [repo] + all_repos = [repo] + else: + all_repos = selected_repos + + 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 _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__) + 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"): + + 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="shallow", + update_dependencies=False, + ) + + return called_installers + + # ------------------------------------------------------------------ + # Scenario 1: Only Makefile with install target + # ------------------------------------------------------------------ + def test_only_makefile_installer_runs(self) -> None: + 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") + + mk_inst = MakefileInstaller() + installers = [("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.", + ) + + # ------------------------------------------------------------------ + # 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: + repo_dir = self._new_repo() + + # pyproject.toml: basic Python project, no 'make install' string. + with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f: + f.write( + "[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: + f.write("install:\n\t@echo 'installing from Makefile'\n") + + py_inst = PythonInstaller() + mk_inst = MakefileInstaller() + + # Order: Python first, then Makefile + installers = [ + ("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.", + ) + + # ------------------------------------------------------------------ + # 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: + 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") + + py_inst = PythonInstaller() + mk_inst = MakefileInstaller() + + installers = [ + ("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.", + ) + + # ------------------------------------------------------------------ + # 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: + 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" + ) + + # 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") + + # 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" + "# 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 = [ + ("nix", nix_inst), + ("python", py_inst), + ("makefile", mk_inst), + ] + + 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.", + ) + self.assertNotIn( + "makefile", + called, + "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: + 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" + ) + + # 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") + + # 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" + "buildPythonApplication something\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: + f.write( + "pkgname=dummy\n" + "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 = [ + ("os-packages", os_inst), + ("nix", nix_inst), + ("python", py_inst), + ("makefile", mk_inst), + ] + + 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", + 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() diff --git a/tests/unit/pkgmgr/test_capabilities.py b/tests/unit/pkgmgr/test_capabilities.py index bdceb80..7be7294 100644 --- a/tests/unit/pkgmgr/test_capabilities.py +++ b/tests/unit/pkgmgr/test_capabilities.py @@ -8,21 +8,32 @@ from pkgmgr.capabilities import ( PythonRuntimeCapability, MakeInstallCapability, NixFlakeCapability, + CapabilityMatcher, + detect_capabilities, + resolve_effective_capabilities, + LAYER_ORDER, ) class DummyCtx: """Minimal RepoContext stub with just repo_dir.""" + def __init__(self, repo_dir: str): self.repo_dir = repo_dir -class TestCapabilities(unittest.TestCase): +# --------------------------------------------------------------------------- +# Tests for individual capability detectors +# --------------------------------------------------------------------------- + + +class TestCapabilitiesDetectors(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): + """PythonRuntimeCapability: python layer is provided if pyproject.toml exists.""" cap = PythonRuntimeCapability() def exists_side_effect(path): @@ -32,10 +43,17 @@ class TestCapabilities(unittest.TestCase): self.assertTrue(cap.applies_to_layer("python")) self.assertTrue(cap.is_provided(self.ctx, "python")) + # Other layers should not be treated as python-runtime by this branch + self.assertFalse(cap.is_provided(self.ctx, "nix")) + self.assertFalse(cap.is_provided(self.ctx, "os-packages")) @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): + """ + PythonRuntimeCapability: nix layer is provided if flake.nix contains + Python-related patterns like buildPythonApplication. + """ cap = PythonRuntimeCapability() def exists_side_effect(path): @@ -54,6 +72,7 @@ class TestCapabilities(unittest.TestCase): read_data="install:\n\t echo 'installing'\n", ) def test_make_install_makefile_layer(self, mock_file, mock_exists): + """MakeInstallCapability: makefile layer is provided if Makefile has an install target.""" cap = MakeInstallCapability() self.assertTrue(cap.applies_to_layer("makefile")) @@ -61,6 +80,7 @@ class TestCapabilities(unittest.TestCase): @patch("pkgmgr.capabilities.os.path.exists") def test_nix_flake_capability_on_nix_layer(self, mock_exists): + """NixFlakeCapability: nix layer is provided if flake.nix exists.""" cap = NixFlakeCapability() def exists_side_effect(path): @@ -72,5 +92,283 @@ class TestCapabilities(unittest.TestCase): self.assertTrue(cap.is_provided(self.ctx, "nix")) +# --------------------------------------------------------------------------- +# Dummy capability matcher for resolver tests +# --------------------------------------------------------------------------- + + +class DummyCapability(CapabilityMatcher): + """ + Simple test capability that returns True/False based on a static mapping: + + mapping = { + "makefile": True, + "python": False, + "nix": True, + ... + } + """ + + def __init__(self, name: str, mapping: dict[str, bool]): + self.name = name + self._mapping = mapping + + def applies_to_layer(self, layer: str) -> bool: + return layer in self._mapping + + def is_provided(self, ctx: DummyCtx, layer: str) -> bool: + # ctx is unused here; we are testing the resolution logic, not IO + return self._mapping.get(layer, False) + + +# --------------------------------------------------------------------------- +# Tests for detect_capabilities (raw detection) +# --------------------------------------------------------------------------- + + +class TestDetectCapabilities(unittest.TestCase): + def setUp(self): + self.ctx = DummyCtx("/tmp/repo") + + def test_detect_capabilities_with_dummy_matchers(self): + """ + detect_capabilities should aggregate all capabilities per layer + based on the matchers' applies_to_layer/is_provided logic. + """ + layers = ["makefile", "python", "nix", "os-packages"] + + dummy1 = DummyCapability( + "cap-a", + { + "makefile": True, + "python": False, + "nix": True, + }, + ) + dummy2 = DummyCapability( + "cap-b", + { + "python": True, + "os-packages": True, + }, + ) + + with patch("pkgmgr.capabilities.CAPABILITY_MATCHERS", [dummy1, dummy2]): + caps = detect_capabilities(self.ctx, layers) + + self.assertEqual( + caps, + { + "makefile": {"cap-a"}, + "python": {"cap-b"}, + "nix": {"cap-a"}, + "os-packages": {"cap-b"}, + }, + ) + + +# --------------------------------------------------------------------------- +# Tests for resolve_effective_capabilities (bottom-up shadowing) +# --------------------------------------------------------------------------- + + +class TestResolveEffectiveCapabilities(unittest.TestCase): + def setUp(self): + self.ctx = DummyCtx("/tmp/repo") + + def test_bottom_up_shadowing_makefile_python_nix(self): + """ + Scenario: + - makefile: provides make-install + - python: provides python-runtime, make-install + - nix: provides python-runtime, make-install, nix-flake + - os-packages: none + + Expected effective capabilities: + - makefile: {} + - python: {} + - nix: {python-runtime, make-install, nix-flake} + - os-packages: {} + """ + layers = ["makefile", "python", "nix", "os-packages"] + + cap_make_install = DummyCapability( + "make-install", + { + "makefile": True, + "python": True, + "nix": True, + "os-packages": False, + }, + ) + cap_python_runtime = DummyCapability( + "python-runtime", + { + "makefile": False, + "python": True, + "nix": True, + "os-packages": False, + }, + ) + cap_nix_flake = DummyCapability( + "nix-flake", + { + "makefile": False, + "python": False, + "nix": True, + "os-packages": False, + }, + ) + + with patch( + "pkgmgr.capabilities.CAPABILITY_MATCHERS", + [cap_make_install, cap_python_runtime, cap_nix_flake], + ): + effective = resolve_effective_capabilities(self.ctx, layers) + + self.assertEqual(effective["makefile"], set()) + self.assertEqual(effective["python"], set()) + self.assertEqual( + effective["nix"], + {"python-runtime", "make-install", "nix-flake"}, + ) + self.assertEqual(effective["os-packages"], set()) + + def test_os_packages_shadow_all_lower_layers(self): + """ + Scenario: + - python: provides python-runtime + - nix: provides python-runtime + - os-packages: provides python-runtime + + Expected effective capabilities: + - python: {} + - nix: {} + - os-packages: {python-runtime} + """ + layers = ["python", "nix", "os-packages"] + + cap_python_runtime = DummyCapability( + "python-runtime", + { + "python": True, + "nix": True, + "os-packages": True, + }, + ) + + with patch( + "pkgmgr.capabilities.CAPABILITY_MATCHERS", + [cap_python_runtime], + ): + effective = resolve_effective_capabilities(self.ctx, layers) + + self.assertEqual(effective["python"], set()) + self.assertEqual(effective["nix"], set()) + self.assertEqual(effective["os-packages"], {"python-runtime"}) + + def test_capability_only_in_lowest_layer(self): + """ + If a capability is only provided by the lowest layer, it should remain + attached to that layer as an effective capability. + """ + layers = ["makefile", "python", "nix"] + + cap_only_make = DummyCapability( + "make-install", + { + "makefile": True, + "python": False, + "nix": False, + }, + ) + + with patch("pkgmgr.capabilities.CAPABILITY_MATCHERS", [cap_only_make]): + effective = resolve_effective_capabilities(self.ctx, layers) + + self.assertEqual(effective["makefile"], {"make-install"}) + self.assertEqual(effective["python"], set()) + self.assertEqual(effective["nix"], set()) + + def test_capability_only_in_highest_layer(self): + """ + If a capability is only provided by the highest layer, it should appear + only there as an effective capability. + """ + layers = ["makefile", "python", "nix"] + + cap_only_nix = DummyCapability( + "nix-flake", + { + "makefile": False, + "python": False, + "nix": True, + }, + ) + + with patch("pkgmgr.capabilities.CAPABILITY_MATCHERS", [cap_only_nix]): + effective = resolve_effective_capabilities(self.ctx, layers) + + self.assertEqual(effective["makefile"], set()) + self.assertEqual(effective["python"], set()) + self.assertEqual(effective["nix"], {"nix-flake"}) + + def test_partial_layer_subset_order_respected(self): + """ + When passing a custom subset of layers, the resolver must respect + that custom order for shadowing. + + Scenario: + - layers = ["python", "nix"] + - both provide "python-runtime" + + Expected: + - python: {} + - nix: {"python-runtime"} + """ + layers = ["python", "nix"] + + cap_python_runtime = DummyCapability( + "python-runtime", + { + "python": True, + "nix": True, + }, + ) + + with patch( + "pkgmgr.capabilities.CAPABILITY_MATCHERS", + [cap_python_runtime], + ): + effective = resolve_effective_capabilities(self.ctx, layers) + + self.assertEqual(effective["python"], set()) + self.assertEqual(effective["nix"], {"python-runtime"}) + + def test_default_layer_order_is_used_if_none_given(self): + """ + If no explicit layers are passed, resolve_effective_capabilities + should use LAYER_ORDER. + """ + cap_dummy = DummyCapability( + "dummy-cap", + { + # Only provide something on the highest default layer + LAYER_ORDER[-1]: True, + }, + ) + + with patch( + "pkgmgr.capabilities.CAPABILITY_MATCHERS", + [cap_dummy], + ): + effective = resolve_effective_capabilities(self.ctx) + + # All lower layers must be empty; highest default layer must have the cap + for layer in LAYER_ORDER[:-1]: + self.assertEqual(effective[layer], set()) + self.assertEqual(effective[LAYER_ORDER[-1]], {"dummy-cap"}) + + if __name__ == "__main__": unittest.main()