Add recursive capability resolver, integration tests, and GitHub workflow (see: https://chatgpt.com/share/6936abc9-87cc-800f-97e6-f7429fb1a910)

This commit is contained in:
Kevin Veen-Birkenbach
2025-12-08 11:43:39 +01:00
parent 775c30149c
commit f641b95d81
7 changed files with 990 additions and 4 deletions

29
.github/workflows/test-integration.yml vendored Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

View File

@@ -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`.
* Makefiles 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.**

View File

@@ -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()

View File

@@ -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()