Refine installer capability integration tests and documentation
- Adjust install_repos integration test to patch resolve_command_for_repo in the pipeline module and tighten DummyInstaller overrides - Rewrite recursive capability integration tests to focus on layer ordering and capability shadowing across Makefile, Python, Nix and OS-package installers - Extend recursive capabilities markdown with hierarchy diagram, capability matrix, scenario matrix and link to the external setup controller schema https://chatgpt.com/share/69399857-4d84-800f-a636-6bcd1ab5e192
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -16,10 +19,10 @@ class DummyInstaller(BaseInstaller):
|
|||||||
|
|
||||||
layer = None
|
layer = None
|
||||||
|
|
||||||
def supports(self, ctx):
|
def supports(self, ctx): # type: ignore[override]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def run(self, ctx):
|
def run(self, ctx): # type: ignore[override]
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -34,31 +37,34 @@ class TestInstallReposIntegration(unittest.TestCase):
|
|||||||
mock_get_repo_dir,
|
mock_get_repo_dir,
|
||||||
mock_clone_repos,
|
mock_clone_repos,
|
||||||
mock_verify_repository,
|
mock_verify_repository,
|
||||||
):
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Full integration test for high-level command resolution + symlink creation.
|
Integration test:
|
||||||
|
|
||||||
We do NOT re-test all low-level file-system details of
|
We do NOT re-test the low-level implementation details of
|
||||||
resolve_command_for_repo here (that is covered by unit tests).
|
resolve_command_for_repo() here (that is covered by unit tests).
|
||||||
Instead, we assert that:
|
|
||||||
|
|
||||||
- If resolve_command_for_repo(...) returns None:
|
Instead, we assert the high-level behavior of install_repos() +
|
||||||
→ install_repos() does NOT create a symlink.
|
InstallationPipeline + create_ink():
|
||||||
|
|
||||||
- If resolve_command_for_repo(...) returns a path:
|
* If resolve_command_for_repo(...) returns None:
|
||||||
→ install_repos() creates exactly one symlink in bin_dir
|
→ install_repos() must NOT create a symlink for that repo.
|
||||||
|
|
||||||
|
* If resolve_command_for_repo(...) returns a path:
|
||||||
|
→ install_repos() must create exactly one symlink in bin_dir
|
||||||
that points to this path.
|
that points to this path.
|
||||||
|
|
||||||
Concretely:
|
Concretely in this test:
|
||||||
|
|
||||||
- repo-system:
|
* repo-system:
|
||||||
resolve_command_for_repo(...) → None
|
fake resolver → returns None
|
||||||
→ no symlink in bin_dir for this repo.
|
→ no symlink in bin_dir for this repo.
|
||||||
|
|
||||||
- repo-nix:
|
* repo-nix:
|
||||||
resolve_command_for_repo(...) → "/nix/profile/bin/repo-nix"
|
fake resolver → returns "/nix/profile/bin/repo-nix"
|
||||||
→ exactly one symlink in bin_dir pointing to that path.
|
→ exactly one symlink in bin_dir pointing to that path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Repositories must have provider/account/repository so that get_repo_dir()
|
# Repositories must have provider/account/repository so that get_repo_dir()
|
||||||
# does not crash when called from create_ink().
|
# does not crash when called from create_ink().
|
||||||
repo_system = {
|
repo_system = {
|
||||||
@@ -77,9 +83,7 @@ class TestInstallReposIntegration(unittest.TestCase):
|
|||||||
selected_repos = [repo_system, repo_nix]
|
selected_repos = [repo_system, repo_nix]
|
||||||
all_repos = selected_repos
|
all_repos = selected_repos
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_base, \
|
with tempfile.TemporaryDirectory() as tmp_base, tempfile.TemporaryDirectory() as tmp_bin:
|
||||||
tempfile.TemporaryDirectory() as tmp_bin:
|
|
||||||
|
|
||||||
# Fake repo directories (what get_repo_dir will return)
|
# Fake repo directories (what get_repo_dir will return)
|
||||||
repo_system_dir = os.path.join(tmp_base, "repo-system")
|
repo_system_dir = os.path.join(tmp_base, "repo-system")
|
||||||
repo_nix_dir = os.path.join(tmp_base, "repo-nix")
|
repo_nix_dir = os.path.join(tmp_base, "repo-nix")
|
||||||
@@ -97,11 +101,15 @@ class TestInstallReposIntegration(unittest.TestCase):
|
|||||||
# Pretend this is the "Nix binary" path for repo-nix
|
# Pretend this is the "Nix binary" path for repo-nix
|
||||||
nix_tool_path = "/nix/profile/bin/repo-nix"
|
nix_tool_path = "/nix/profile/bin/repo-nix"
|
||||||
|
|
||||||
# Patch resolve_command_for_repo at the install_repos module level
|
# Patch resolve_command_for_repo at the *pipeline* module level,
|
||||||
with patch("pkgmgr.actions.repository.install.resolve_command_for_repo") as mock_resolve, \
|
# because InstallationPipeline imports it there.
|
||||||
patch("pkgmgr.actions.repository.install.os.path.exists") as mock_exists_install:
|
with patch(
|
||||||
|
"pkgmgr.actions.repository.install.pipeline.resolve_command_for_repo"
|
||||||
|
) as mock_resolve, patch(
|
||||||
|
"pkgmgr.actions.repository.install.os.path.exists"
|
||||||
|
) as mock_exists_install:
|
||||||
|
|
||||||
def fake_resolve_command(repo, repo_identifier: str, repo_dir: str):
|
def fake_resolve(repo, repo_identifier: str, repo_dir: str):
|
||||||
"""
|
"""
|
||||||
High-level behavior stub:
|
High-level behavior stub:
|
||||||
|
|
||||||
@@ -111,9 +119,10 @@ class TestInstallReposIntegration(unittest.TestCase):
|
|||||||
- For repo-nix: act as if a Nix profile binary is the entrypoint
|
- For repo-nix: act as if a Nix profile binary is the entrypoint
|
||||||
→ return nix_tool_path (symlink should be created).
|
→ return nix_tool_path (symlink should be created).
|
||||||
"""
|
"""
|
||||||
if repo_identifier == "repo-system":
|
name = repo.get("name")
|
||||||
|
if name == "repo-system":
|
||||||
return None
|
return None
|
||||||
if repo_identifier == "repo-nix":
|
if name == "repo-nix":
|
||||||
return nix_tool_path
|
return nix_tool_path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ class TestInstallReposIntegration(unittest.TestCase):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
mock_resolve.side_effect = fake_resolve_command
|
mock_resolve.side_effect = fake_resolve
|
||||||
mock_exists_install.side_effect = fake_exists_install
|
mock_exists_install.side_effect = fake_exists_install
|
||||||
|
|
||||||
# Use only DummyInstaller so we focus on link creation, not installer behavior
|
# Use only DummyInstaller so we focus on link creation, not installer behavior
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
# Capability Resolution & Installer Shadowing
|
# Capability Resolution & Installer Shadowing
|
||||||
|
|
||||||
## Layer Hierarchy
|
This document explains how `pkgmgr` decides **which installer should run** when multiple installation mechanisms are available in a repository.
|
||||||
|
It reflects the logic shown in the setup-controller diagram:
|
||||||
|
|
||||||
|
➡️ **Full graphical schema:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer Hierarchy (Strength Order)
|
||||||
|
|
||||||
|
Installers are evaluated from **strongest to weakest**.
|
||||||
|
A stronger layer shadows all layers below it.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────┐ Highest layer
|
┌───────────────────────────┐ Highest layer
|
||||||
@@ -22,7 +32,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scenario Matrix
|
## Capability Matrix
|
||||||
|
|
||||||
|
Each layer provides a set of **capabilities**.
|
||||||
|
Layers that provide *all* capabilities of a lower layer **shadow** that layer.
|
||||||
|
|
||||||
|
| Capability | Makefile | Python | Nix | OS-Pkgs |
|
||||||
|
| -------------------- | -------- | ------------ | --- | ------- |
|
||||||
|
| `make-install` | ✔ | (optional) ✔ | ✔ | ✔ |
|
||||||
|
| `python-runtime` | – | ✔ | ✔ | ✔ |
|
||||||
|
| `binary/cli` | – | – | ✔ | ✔ |
|
||||||
|
| `system-integration` | – | – | – | ✔ |
|
||||||
|
|
||||||
|
✔ = capability available
|
||||||
|
– = not provided by this layer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario Matrix (Expected Installer Execution)
|
||||||
|
|
||||||
| Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name |
|
| Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name |
|
||||||
| -------------------------- | -------- | ------ | --- | ------- | ----------------------------- |
|
| -------------------------- | -------- | ------ | --- | ------- | ----------------------------- |
|
||||||
@@ -34,40 +61,41 @@
|
|||||||
|
|
||||||
Legend:
|
Legend:
|
||||||
✔ = installer runs
|
✔ = installer runs
|
||||||
✗ = installer skipped (shadowed by upper layer)
|
✗ = installer is skipped (shadowed)
|
||||||
– = no such layer present
|
– = layer not present in this scenario
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What the Integration Test Confirms
|
## What the Integration Test Confirms
|
||||||
|
|
||||||
**Goal:** Validate that the capability-shadowing mechanism correctly determines *which installers actually run* for a given repository layout.
|
The integration tests ensure that the **actual execution** matches the theoretical capability model.
|
||||||
|
|
||||||
### 1) Only Makefile
|
### 1) Only Makefile
|
||||||
|
|
||||||
* Makefile provides `make-install`.
|
* Only `Makefile` present
|
||||||
* No higher layers → MakefileInstaller runs.
|
→ MakefileInstaller runs.
|
||||||
|
|
||||||
### 2) Python + Makefile
|
### 2) Python + Makefile
|
||||||
|
|
||||||
* Python provides `python-runtime`.
|
* Python provides `python-runtime`
|
||||||
* Makefile additionally provides `make-install`.
|
* Makefile provides `make-install`
|
||||||
* No capability overlap → both installers run.
|
→ Both run (capabilities are disjoint).
|
||||||
|
|
||||||
### 3) Python shadows Makefile
|
### 3) Python shadows Makefile
|
||||||
|
|
||||||
* Python also provides `make-install`.
|
* Python additionally advertises `make-install`
|
||||||
* Makefile’s capability is fully covered → MakefileInstaller is skipped.
|
→ MakefileInstaller is skipped.
|
||||||
|
|
||||||
### 4) Nix shadows Python & Makefile
|
### 4) Nix shadows Python & Makefile
|
||||||
|
|
||||||
* Nix provides all capabilities below it.
|
* Nix provides: `python-runtime` + `make-install`
|
||||||
* Only NixInstaller runs.
|
→ PythonInstaller and MakefileInstaller are skipped.
|
||||||
|
→ Only NixInstaller runs.
|
||||||
|
|
||||||
### 5) OS-Packages shadow all
|
### 5) OS-Pkg layer shadows all
|
||||||
|
|
||||||
* PKGBUILD/debian/rpm provide all capabilities.
|
* OS packages provide all capabilities
|
||||||
* Only the corresponding OS package installer runs.
|
→ Only OS installer runs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,6 +139,14 @@ Legend:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Principle (one sentence)
|
## Core Principle
|
||||||
|
|
||||||
**A layer only executes if it provides at least one capability not already guaranteed by any higher layer.**
|
**A layer is executed only if it contributes at least one capability that no stronger layer has already provided.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Link to the Setup Controller Diagram
|
||||||
|
|
||||||
|
The full visual schema is available here:
|
||||||
|
|
||||||
|
➡️ **[https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)**
|
||||||
|
|||||||
@@ -2,140 +2,99 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Integration tests for the recursive / layered capability handling in pkgmgr.
|
Integration tests for recursive capability resolution and installer shadowing.
|
||||||
|
|
||||||
We focus on the interaction between:
|
These tests verify that, given different repository layouts (Makefile, pyproject,
|
||||||
|
flake.nix, PKGBUILD), only the expected installers are executed based on the
|
||||||
|
capabilities provided by higher layers.
|
||||||
|
|
||||||
- MakefileInstaller (layer: "makefile")
|
Layer order (strongest → weakest):
|
||||||
- PythonInstaller (layer: "python")
|
|
||||||
- NixFlakeInstaller (layer: "nix")
|
|
||||||
- ArchPkgbuildInstaller (layer: "os-packages")
|
|
||||||
|
|
||||||
The core idea:
|
OS-PACKAGES > NIX > PYTHON > MAKEFILE
|
||||||
|
|
||||||
- Each installer declares logical capabilities for its layer via
|
|
||||||
discover_capabilities() and the global CAPABILITY_MATCHERS.
|
|
||||||
- install_repos() tracks which capabilities have already been provided
|
|
||||||
by earlier installers (in INSTALLERS order).
|
|
||||||
- If an installer only provides capabilities that are already covered
|
|
||||||
by previous installers, it is skipped.
|
|
||||||
|
|
||||||
These tests use *real* capability detection (based on repo files like
|
|
||||||
flake.nix, pyproject.toml, Makefile, PKGBUILD), but patch the installers'
|
|
||||||
run() methods so that no real external commands are executed.
|
|
||||||
|
|
||||||
Scenarios:
|
|
||||||
|
|
||||||
1. Only Makefile with install target
|
|
||||||
→ MakefileInstaller runs, all good.
|
|
||||||
|
|
||||||
2. Python + Makefile (no "make install" in pyproject.toml)
|
|
||||||
→ PythonInstaller provides only python-runtime
|
|
||||||
→ MakefileInstaller provides make-install
|
|
||||||
→ Both run, since their capabilities are disjoint.
|
|
||||||
|
|
||||||
3. Python + Makefile (pyproject.toml mentions "make install")
|
|
||||||
→ PythonInstaller provides {python-runtime, make-install}
|
|
||||||
→ MakefileInstaller provides {make-install}
|
|
||||||
→ MakefileInstaller is skipped (capabilities already covered).
|
|
||||||
|
|
||||||
4. Nix + Python + Makefile
|
|
||||||
- flake.nix hints:
|
|
||||||
* buildPythonApplication (python-runtime)
|
|
||||||
* make install (make-install)
|
|
||||||
→ NixFlakeInstaller provides {python-runtime, make-install, nix-flake}
|
|
||||||
→ PythonInstaller and MakefileInstaller are skipped.
|
|
||||||
|
|
||||||
5. OS packages + Nix + Python + Makefile
|
|
||||||
- PKGBUILD contains:
|
|
||||||
* "pip install ." (python-runtime via os-packages)
|
|
||||||
* "make install" (make-install via os-packages)
|
|
||||||
* "nix profile" (nix-flake via os-packages)
|
|
||||||
→ ArchPkgbuildInstaller provides all capabilities
|
|
||||||
→ All lower layers are skipped.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from typing import List, Sequence, Tuple
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pkgmgr.actions.repository.install as install_mod
|
import pkgmgr.actions.repository.install as install_mod
|
||||||
from pkgmgr.actions.repository.install import install_repos
|
from pkgmgr.actions.repository.install import install_repos
|
||||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
|
||||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
|
||||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
||||||
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
|
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
||||||
|
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import (
|
||||||
|
ArchPkgbuildInstaller,
|
||||||
|
)
|
||||||
|
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
||||||
|
|
||||||
|
|
||||||
|
InstallerSpec = Tuple[str, object]
|
||||||
|
|
||||||
|
|
||||||
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
# Temporary base directory for this test class
|
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-recursive-caps-")
|
||||||
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-integration-")
|
|
||||||
self.bin_dir = os.path.join(self.tmp_root, "bin")
|
self.bin_dir = os.path.join(self.tmp_root, "bin")
|
||||||
os.makedirs(self.bin_dir, exist_ok=True)
|
os.makedirs(self.bin_dir, exist_ok=True)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
shutil.rmtree(self.tmp_root)
|
shutil.rmtree(self.tmp_root)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------ helpers
|
||||||
# Helper: create a new repo directory for a scenario
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _new_repo(self) -> str:
|
def _new_repo(self) -> str:
|
||||||
repo_dir = tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
|
|
||||||
return repo_dir
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helper: run install_repos() with a custom installer list
|
|
||||||
# and record which installers actually ran.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _run_with_installers(self, repo_dir: str, installers, selected_repos=None):
|
|
||||||
"""
|
"""
|
||||||
Run install_repos() with a given INSTALLERS list and a single
|
Create a fresh temporary repo directory under self.tmp_root.
|
||||||
dummy repo; return the list of installer labels that actually ran.
|
"""
|
||||||
|
return tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
|
||||||
|
|
||||||
The installers' supports() are forced to True so that only the
|
def _run_with_installers(
|
||||||
capability-shadowing logic decides whether they are skipped.
|
self,
|
||||||
The installers' run() methods are patched to avoid real commands.
|
repo_dir: str,
|
||||||
|
installers: Sequence[InstallerSpec],
|
||||||
|
selected_repos=None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Run install_repos() with a custom INSTALLERS list and capture which
|
||||||
|
installer labels actually run.
|
||||||
|
|
||||||
NOTE:
|
We override each installer's supports() to always return True and
|
||||||
We patch resolve_command_for_repo() to always return a dummy
|
override run() to append its label to called_installers.
|
||||||
command path so that command resolution does not interfere with
|
|
||||||
capability-layering tests.
|
|
||||||
"""
|
"""
|
||||||
if selected_repos is None:
|
if selected_repos is None:
|
||||||
repo = {}
|
repo = {"repository": "dummy"}
|
||||||
selected_repos = [repo]
|
selected_repos = [repo]
|
||||||
all_repos = [repo]
|
all_repos = [repo]
|
||||||
else:
|
else:
|
||||||
all_repos = selected_repos
|
all_repos = selected_repos
|
||||||
|
|
||||||
called_installers: list[str] = []
|
called_installers: List[str] = []
|
||||||
|
|
||||||
# Prepare patched instances with recording run() and always-supports.
|
|
||||||
patched_installers = []
|
patched_installers = []
|
||||||
for label, inst in installers:
|
for label, inst in installers:
|
||||||
def always_supports(self, ctx):
|
def always_supports(self, ctx):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def make_run(label_name):
|
def make_run(label_name: str):
|
||||||
def _run(self, ctx):
|
def _run(self, ctx):
|
||||||
called_installers.append(label_name)
|
called_installers.append(label_name)
|
||||||
return _run
|
return _run
|
||||||
|
|
||||||
inst.supports = always_supports.__get__(inst, inst.__class__)
|
inst.supports = always_supports.__get__(inst, inst.__class__) # type: ignore[assignment]
|
||||||
inst.run = make_run(label).__get__(inst, inst.__class__)
|
inst.run = make_run(label).__get__(inst, inst.__class__) # type: ignore[assignment]
|
||||||
patched_installers.append(inst)
|
patched_installers.append(inst)
|
||||||
|
|
||||||
with patch.object(install_mod, "INSTALLERS", patched_installers), \
|
with patch.object(install_mod, "INSTALLERS", patched_installers), patch.object(
|
||||||
patch.object(install_mod, "get_repo_identifier", return_value="dummy-repo"), \
|
install_mod, "get_repo_identifier", return_value="dummy-repo"
|
||||||
patch.object(install_mod, "get_repo_dir", return_value=repo_dir), \
|
), patch.object(
|
||||||
patch.object(install_mod, "verify_repository", return_value=(True, [], None, None)), \
|
install_mod, "get_repo_dir", return_value=repo_dir
|
||||||
patch.object(install_mod, "create_ink"), \
|
), patch.object(
|
||||||
patch.object(install_mod, "clone_repos"), \
|
install_mod, "verify_repository", return_value=(True, [], None, None)
|
||||||
patch.object(install_mod, "resolve_command_for_repo", return_value="/bin/dummy"):
|
), patch.object(
|
||||||
|
install_mod, "clone_repos"
|
||||||
|
):
|
||||||
install_repos(
|
install_repos(
|
||||||
selected_repos=selected_repos,
|
selected_repos=selected_repos,
|
||||||
repositories_base_dir=self.tmp_root,
|
repositories_base_dir=self.tmp_root,
|
||||||
@@ -144,25 +103,25 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
no_verification=True,
|
no_verification=True,
|
||||||
preview=False,
|
preview=False,
|
||||||
quiet=False,
|
quiet=False,
|
||||||
clone_mode="shallow",
|
clone_mode="ssh",
|
||||||
update_dependencies=False,
|
update_dependencies=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return called_installers
|
return called_installers
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- scenarios
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Scenario 1: Only Makefile with install target
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def test_only_makefile_installer_runs(self) -> None:
|
def test_only_makefile_installer_runs(self) -> None:
|
||||||
|
"""
|
||||||
|
With only a Makefile present, only the MakefileInstaller should run.
|
||||||
|
"""
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# Makefile: detect a real 'install' target for makefile layer.
|
|
||||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
f.write("install:\n\t@echo 'make install'\n")
|
||||||
|
|
||||||
mk_inst = MakefileInstaller()
|
mk_inst = MakefileInstaller()
|
||||||
installers = [("makefile", mk_inst)]
|
installers: Sequence[InstallerSpec] = [("makefile", mk_inst)]
|
||||||
|
|
||||||
called = self._run_with_installers(repo_dir, installers)
|
called = self._run_with_installers(repo_dir, installers)
|
||||||
|
|
||||||
@@ -172,110 +131,85 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
"With only a Makefile, the MakefileInstaller should run exactly once.",
|
"With only a Makefile, the MakefileInstaller should run exactly once.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Scenario 2: Python + Makefile, but pyproject.toml does NOT mention 'make install'
|
|
||||||
# → capabilities are disjoint, both installers should run.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
|
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
|
||||||
|
"""
|
||||||
|
If Python and Makefile have disjoint capabilities, both installers run.
|
||||||
|
"""
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# pyproject.toml: basic Python project, no 'make install' string.
|
# pyproject.toml without any explicit "make install" hint
|
||||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write("name = 'dummy'\n")
|
||||||
"[project]\n"
|
|
||||||
"name = 'dummy'\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Makefile: install target for makefile layer.
|
|
||||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
f.write("install:\n\t@echo 'make install'\n")
|
||||||
|
|
||||||
py_inst = PythonInstaller()
|
py_inst = PythonInstaller()
|
||||||
mk_inst = MakefileInstaller()
|
mk_inst = MakefileInstaller()
|
||||||
|
installers: Sequence[InstallerSpec] = [
|
||||||
# Order: Python first, then Makefile
|
|
||||||
installers = [
|
|
||||||
("python", py_inst),
|
("python", py_inst),
|
||||||
("makefile", mk_inst),
|
("makefile", mk_inst),
|
||||||
]
|
]
|
||||||
|
|
||||||
called = self._run_with_installers(repo_dir, installers)
|
called = self._run_with_installers(repo_dir, installers)
|
||||||
|
|
||||||
# Both should have run because:
|
|
||||||
# - Python provides {python-runtime}
|
|
||||||
# - Makefile provides {make-install}
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
called,
|
called,
|
||||||
["python", "makefile"],
|
["python", "makefile"],
|
||||||
"PythonInstaller and MakefileInstaller should both run when their capabilities are disjoint.",
|
"PythonInstaller and MakefileInstaller should both run when their "
|
||||||
|
"capabilities are disjoint.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Scenario 3: Python + Makefile, pyproject.toml mentions 'make install'
|
|
||||||
# → PythonInstaller provides {python-runtime, make-install}
|
|
||||||
# MakefileInstaller only {make-install}
|
|
||||||
# → MakefileInstaller must be skipped.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
|
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
|
||||||
|
"""
|
||||||
|
If the Python layer advertises a 'make-install' capability (pyproject
|
||||||
|
explicitly hints at 'make install'), the Makefile layer must be skipped.
|
||||||
|
"""
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# pyproject.toml: Python project with 'make install' hint.
|
|
||||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
"[project]\n"
|
|
||||||
"name = 'dummy'\n"
|
"name = 'dummy'\n"
|
||||||
"\n"
|
"\n"
|
||||||
"# Hint for MakeInstallCapability on layer 'python'\n"
|
"# Hint for MakeInstallCapability on layer 'python'\n"
|
||||||
"make install\n"
|
"make install\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Makefile: install target, but should be shadowed by Python.
|
|
||||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
f.write("install:\n\t@echo 'make install'\n")
|
||||||
|
|
||||||
py_inst = PythonInstaller()
|
py_inst = PythonInstaller()
|
||||||
mk_inst = MakefileInstaller()
|
mk_inst = MakefileInstaller()
|
||||||
|
installers: Sequence[InstallerSpec] = [
|
||||||
installers = [
|
|
||||||
("python", py_inst),
|
("python", py_inst),
|
||||||
("makefile", mk_inst),
|
("makefile", mk_inst),
|
||||||
]
|
]
|
||||||
|
|
||||||
called = self._run_with_installers(repo_dir, installers)
|
called = self._run_with_installers(repo_dir, installers)
|
||||||
|
|
||||||
# Python should run, Makefile should be skipped because its only
|
|
||||||
# capability (make-install) is already provided by Python.
|
|
||||||
self.assertIn("python", called, "PythonInstaller should have run.")
|
self.assertIn("python", called, "PythonInstaller should have run.")
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"makefile",
|
"makefile",
|
||||||
called,
|
called,
|
||||||
"MakefileInstaller should be skipped because its 'make-install' capability "
|
"MakefileInstaller should be skipped because its 'make-install' "
|
||||||
"is already provided by Python.",
|
"capability is already provided by Python.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Scenario 4: Nix + Python + Makefile
|
|
||||||
# flake.nix provides python-runtime + make-install + nix-flake
|
|
||||||
# → Nix shadows both Python and Makefile.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def test_nix_shadows_python_and_makefile(self) -> None:
|
def test_nix_shadows_python_and_makefile(self) -> None:
|
||||||
|
"""
|
||||||
|
If a Nix flake advertises both python-runtime and make-install
|
||||||
|
capabilities, Python and Makefile installers must be skipped.
|
||||||
|
"""
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# pyproject.toml: generic Python project
|
|
||||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write("name = 'dummy'\n")
|
||||||
"[project]\n"
|
|
||||||
"name = 'dummy'\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Makefile: install target
|
|
||||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
f.write("install:\n\t@echo 'make install'\n")
|
||||||
|
|
||||||
# flake.nix: hints for both python-runtime and make-install on layer 'nix'
|
|
||||||
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
"{\n"
|
|
||||||
' description = "integration test flake";\n'
|
' description = "integration test flake";\n'
|
||||||
"}\n"
|
"}\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -289,8 +223,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
nix_inst = NixFlakeInstaller()
|
nix_inst = NixFlakeInstaller()
|
||||||
py_inst = PythonInstaller()
|
py_inst = PythonInstaller()
|
||||||
mk_inst = MakefileInstaller()
|
mk_inst = MakefileInstaller()
|
||||||
|
installers: Sequence[InstallerSpec] = [
|
||||||
installers = [
|
|
||||||
("nix", nix_inst),
|
("nix", nix_inst),
|
||||||
("python", py_inst),
|
("python", py_inst),
|
||||||
("makefile", mk_inst),
|
("makefile", mk_inst),
|
||||||
@@ -298,47 +231,35 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
|
|
||||||
called = self._run_with_installers(repo_dir, installers)
|
called = self._run_with_installers(repo_dir, installers)
|
||||||
|
|
||||||
# Nix must run, Python and Makefile must be skipped:
|
|
||||||
# - Nix provides {python-runtime, make-install, nix-flake}
|
|
||||||
# - Python provides {python-runtime}
|
|
||||||
# - Makefile provides {make-install}
|
|
||||||
self.assertIn("nix", called, "NixFlakeInstaller should have run.")
|
self.assertIn("nix", called, "NixFlakeInstaller should have run.")
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"python",
|
"python",
|
||||||
called,
|
called,
|
||||||
"PythonInstaller should be skipped because its python-runtime capability "
|
"PythonInstaller should be skipped because its python-runtime "
|
||||||
"is already provided by Nix.",
|
"capability is already provided by Nix.",
|
||||||
)
|
)
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"makefile",
|
"makefile",
|
||||||
called,
|
called,
|
||||||
"MakefileInstaller should be skipped because its make-install capability "
|
"MakefileInstaller should be skipped because its make-install "
|
||||||
"is already provided by Nix.",
|
"capability is already provided by Nix.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Scenario 5: OS packages + Nix + Python + Makefile
|
|
||||||
# PKGBUILD provides python-runtime + make-install + nix-flake
|
|
||||||
# → ArchPkgbuildInstaller shadows everything below.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def test_os_packages_shadow_nix_python_and_makefile(self) -> None:
|
def test_os_packages_shadow_nix_python_and_makefile(self) -> None:
|
||||||
|
"""
|
||||||
|
If an OS package layer (PKGBUILD) advertises all capabilities,
|
||||||
|
all lower layers (Nix, Python, Makefile) must be skipped.
|
||||||
|
"""
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# pyproject.toml: enough to signal a Python project
|
|
||||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write("name = 'dummy'\n")
|
||||||
"[project]\n"
|
|
||||||
"name = 'dummy'\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Makefile: install target
|
|
||||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
f.write("install:\n\t@echo 'make install'\n")
|
||||||
|
|
||||||
# flake.nix: as before
|
|
||||||
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
"{\n"
|
|
||||||
' description = "integration test flake";\n'
|
' description = "integration test flake";\n'
|
||||||
"}\n"
|
"}\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -346,13 +267,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
"make install\n"
|
"make install\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# PKGBUILD: contains patterns for all three capabilities on layer 'os-packages':
|
|
||||||
# - "pip install ." → python-runtime
|
|
||||||
# - "make install" → make-install
|
|
||||||
# - "nix profile" → nix-flake
|
|
||||||
with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
|
with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
"pkgname=dummy\n"
|
|
||||||
"pkgver=0.1\n"
|
"pkgver=0.1\n"
|
||||||
"pkgrel=1\n"
|
"pkgrel=1\n"
|
||||||
"pkgdesc='dummy pkg for integration test'\n"
|
"pkgdesc='dummy pkg for integration test'\n"
|
||||||
@@ -376,8 +292,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
nix_inst = NixFlakeInstaller()
|
nix_inst = NixFlakeInstaller()
|
||||||
py_inst = PythonInstaller()
|
py_inst = PythonInstaller()
|
||||||
mk_inst = MakefileInstaller()
|
mk_inst = MakefileInstaller()
|
||||||
|
installers: Sequence[InstallerSpec] = [
|
||||||
installers = [
|
|
||||||
("os-packages", os_inst),
|
("os-packages", os_inst),
|
||||||
("nix", nix_inst),
|
("nix", nix_inst),
|
||||||
("python", py_inst),
|
("python", py_inst),
|
||||||
@@ -386,11 +301,6 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
|
|
||||||
called = self._run_with_installers(repo_dir, installers)
|
called = self._run_with_installers(repo_dir, installers)
|
||||||
|
|
||||||
# ArchPkgbuildInstaller must run, and everything below must be skipped:
|
|
||||||
# - os-packages provides {python-runtime, make-install, nix-flake}
|
|
||||||
# - nix provides {python-runtime, make-install, nix-flake}
|
|
||||||
# - python provides {python-runtime}
|
|
||||||
# - makefile provides {make-install}
|
|
||||||
self.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
|
self.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"nix",
|
"nix",
|
||||||
|
|||||||
Reference in New Issue
Block a user