Add recursive capability resolver, integration tests, and GitHub workflow (see: https://chatgpt.com/share/6936abc9-87cc-800f-97e6-f7429fb1a910)
This commit is contained in:
29
.github/workflows/test-integration.yml
vendored
Normal file
29
.github/workflows/test-integration.yml
vendored
Normal 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
|
||||||
30
Makefile
30
Makefile
@@ -1,5 +1,5 @@
|
|||||||
.PHONY: install setup uninstall aur_builder_setup \
|
.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
|
# Local Nix cache directories in the repo
|
||||||
@@ -99,6 +99,30 @@ test-unit: build
|
|||||||
-p "test_*.py"; \
|
-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)
|
# End-to-end tests: run in all distros via Nix devShell (tests/e2e)
|
||||||
test-e2e: build
|
test-e2e: build
|
||||||
@echo "Ensuring Docker Nix volumes exist (auto-created if missing)..."
|
@echo "Ensuring Docker Nix volumes exist (auto-created if missing)..."
|
||||||
@@ -166,8 +190,8 @@ test-e2e: build
|
|||||||
' || exit $$?; \
|
' || exit $$?; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Combined test target for local + CI (unit + e2e)
|
# Combined test target for local + CI (unit + e2e + integration)
|
||||||
test: build test-unit test-e2e
|
test: build test-unit test-e2e test-integration
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Installer for host systems (original logic)
|
# Installer for host systems (original logic)
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ Each capability is represented by a class that:
|
|||||||
This allows pkgmgr to dynamically decide if a higher layer already covers
|
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,
|
work a lower layer would otherwise do (e.g. Nix calling pyproject/make,
|
||||||
or distro packages wrapping Nix or Makefile logic).
|
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
|
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]
|
lower_patterns = [p.lower() for p in patterns]
|
||||||
for path in files:
|
for path in files:
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
content = _read_text_if_exists(path)
|
content = _read_text_if_exists(path)
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
@@ -295,3 +311,97 @@ CAPABILITY_MATCHERS: list[CapabilityMatcher] = [
|
|||||||
MakeInstallCapability(),
|
MakeInstallCapability(),
|
||||||
NixFlakeCapability(),
|
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
|
||||||
|
|||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
116
tests/integration/test_recursive_capabilities_integration.md
Normal file
116
tests/integration/test_recursive_capabilities_integration.md
Normal 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`.
|
||||||
|
* 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.**
|
||||||
409
tests/integration/test_recursive_capabilities_integration.py
Normal file
409
tests/integration/test_recursive_capabilities_integration.py
Normal 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()
|
||||||
@@ -8,21 +8,32 @@ from pkgmgr.capabilities import (
|
|||||||
PythonRuntimeCapability,
|
PythonRuntimeCapability,
|
||||||
MakeInstallCapability,
|
MakeInstallCapability,
|
||||||
NixFlakeCapability,
|
NixFlakeCapability,
|
||||||
|
CapabilityMatcher,
|
||||||
|
detect_capabilities,
|
||||||
|
resolve_effective_capabilities,
|
||||||
|
LAYER_ORDER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DummyCtx:
|
class DummyCtx:
|
||||||
"""Minimal RepoContext stub with just repo_dir."""
|
"""Minimal RepoContext stub with just repo_dir."""
|
||||||
|
|
||||||
def __init__(self, repo_dir: str):
|
def __init__(self, repo_dir: str):
|
||||||
self.repo_dir = repo_dir
|
self.repo_dir = repo_dir
|
||||||
|
|
||||||
|
|
||||||
class TestCapabilities(unittest.TestCase):
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for individual capability detectors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilitiesDetectors(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.ctx = DummyCtx("/tmp/repo")
|
self.ctx = DummyCtx("/tmp/repo")
|
||||||
|
|
||||||
@patch("pkgmgr.capabilities.os.path.exists")
|
@patch("pkgmgr.capabilities.os.path.exists")
|
||||||
def test_python_runtime_python_layer_pyproject(self, mock_exists):
|
def test_python_runtime_python_layer_pyproject(self, mock_exists):
|
||||||
|
"""PythonRuntimeCapability: python layer is provided if pyproject.toml exists."""
|
||||||
cap = PythonRuntimeCapability()
|
cap = PythonRuntimeCapability()
|
||||||
|
|
||||||
def exists_side_effect(path):
|
def exists_side_effect(path):
|
||||||
@@ -32,10 +43,17 @@ class TestCapabilities(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(cap.applies_to_layer("python"))
|
self.assertTrue(cap.applies_to_layer("python"))
|
||||||
self.assertTrue(cap.is_provided(self.ctx, "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._read_text_if_exists")
|
||||||
@patch("pkgmgr.capabilities.os.path.exists")
|
@patch("pkgmgr.capabilities.os.path.exists")
|
||||||
def test_python_runtime_nix_layer_flake(self, mock_exists, mock_read):
|
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()
|
cap = PythonRuntimeCapability()
|
||||||
|
|
||||||
def exists_side_effect(path):
|
def exists_side_effect(path):
|
||||||
@@ -54,6 +72,7 @@ class TestCapabilities(unittest.TestCase):
|
|||||||
read_data="install:\n\t echo 'installing'\n",
|
read_data="install:\n\t echo 'installing'\n",
|
||||||
)
|
)
|
||||||
def test_make_install_makefile_layer(self, mock_file, mock_exists):
|
def test_make_install_makefile_layer(self, mock_file, mock_exists):
|
||||||
|
"""MakeInstallCapability: makefile layer is provided if Makefile has an install target."""
|
||||||
cap = MakeInstallCapability()
|
cap = MakeInstallCapability()
|
||||||
|
|
||||||
self.assertTrue(cap.applies_to_layer("makefile"))
|
self.assertTrue(cap.applies_to_layer("makefile"))
|
||||||
@@ -61,6 +80,7 @@ class TestCapabilities(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("pkgmgr.capabilities.os.path.exists")
|
@patch("pkgmgr.capabilities.os.path.exists")
|
||||||
def test_nix_flake_capability_on_nix_layer(self, mock_exists):
|
def test_nix_flake_capability_on_nix_layer(self, mock_exists):
|
||||||
|
"""NixFlakeCapability: nix layer is provided if flake.nix exists."""
|
||||||
cap = NixFlakeCapability()
|
cap = NixFlakeCapability()
|
||||||
|
|
||||||
def exists_side_effect(path):
|
def exists_side_effect(path):
|
||||||
@@ -72,5 +92,283 @@ class TestCapabilities(unittest.TestCase):
|
|||||||
self.assertTrue(cap.is_provided(self.ctx, "nix"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user