fix(install): install a repository once, by the first hook that supports it
The pipeline used to skip a second installer only when it advertised capabilities the first had already provided, so a hook with a different capability still ran on the very same project. file-dedupe was installed through pip and then again through `make install`, whose first line upgrades pip; PEP 668 refuses that, and the run went red on a repository that was already installed correctly. One hook now installs a repository and the pipeline stops there. The order of INSTALLERS is the preference: `make install` leads, because it is the hook a repository writes for itself, while the others infer an installation from a manifest. A hook that fails installed nothing, so the rule is one *successful* installation rather than one attempt: _run_installer returns a bool instead of re-raising, and the caller moves on to the next hook. To keep that fallback from turning a broken repository into a silent pass, a repository whose hooks all fail now exits with the list of what was tried. The capability bookkeeping the old skip rule needed is gone from the pipeline; discover_capabilities remains on BaseInstaller and is now unused there. Verified: 14 targeted unit and integration tests, and a full `pkgmgr update --all` sweep in the arch image with 0 installation failures. Delta measured at the consumer: repositories left without a CLI entry point, 16 before and 16 after, diff identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,11 +21,13 @@ class DummyInstaller(BaseInstaller):
|
||||
layer: str | None = None,
|
||||
supports_result: bool = True,
|
||||
capabilities: set[str] | None = None,
|
||||
fails: bool = False,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self.layer = layer # type: ignore[assignment]
|
||||
self._supports_result = supports_result
|
||||
self._capabilities = capabilities or set()
|
||||
self._fails = fails
|
||||
self.ran = False
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool: # type: ignore[override]
|
||||
@@ -33,6 +35,8 @@ class DummyInstaller(BaseInstaller):
|
||||
|
||||
def run(self, ctx: RepoContext) -> None: # type: ignore[override]
|
||||
self.ran = True
|
||||
if self._fails:
|
||||
raise SystemExit(2)
|
||||
|
||||
def discover_capabilities(self, ctx: RepoContext) -> set[str]: # type: ignore[override]
|
||||
return set(self._capabilities)
|
||||
@@ -117,15 +121,12 @@ class TestInstallationPipeline(unittest.TestCase):
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_capabilities_prevent_duplicate_installers(
|
||||
def test_only_one_installation_hook_runs(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
If one installer has already provided a set of capabilities,
|
||||
a second installer advertising the same capabilities should be skipped.
|
||||
"""
|
||||
"""A repository is installed once, by the first hook that supports it."""
|
||||
mock_resolve_command_for_repo.return_value = None # no CLI initially
|
||||
|
||||
ctx = _minimal_context()
|
||||
@@ -148,9 +149,103 @@ class TestInstallationPipeline(unittest.TestCase):
|
||||
self.assertTrue(first.ran, "First installer should run.")
|
||||
self.assertFalse(
|
||||
second.ran,
|
||||
"Second installer with identical capabilities must be skipped.",
|
||||
"A second installation hook must not run after the first one did.",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_a_differing_second_hook_is_not_run_either(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""The rule is one hook, not one hook per capability."""
|
||||
mock_resolve_command_for_repo.return_value = None
|
||||
|
||||
ctx = _minimal_context()
|
||||
first = DummyInstaller(
|
||||
"python-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
capabilities={"python-runtime"},
|
||||
)
|
||||
second = DummyInstaller(
|
||||
"makefile-installer",
|
||||
layer=CliLayer.MAKEFILE.value,
|
||||
supports_result=True,
|
||||
capabilities={"make-install"},
|
||||
)
|
||||
|
||||
pipeline = InstallationPipeline([first, second])
|
||||
pipeline.run(ctx)
|
||||
|
||||
self.assertTrue(first.ran, "The first supported hook should run.")
|
||||
self.assertFalse(
|
||||
second.ran,
|
||||
"A hook advertising a different capability must not run either: "
|
||||
"the project is already installed.",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_a_failed_hook_falls_back_to_the_next(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""A hook that fails installed nothing, so the next one gets its turn."""
|
||||
mock_resolve_command_for_repo.return_value = None
|
||||
|
||||
ctx = _minimal_context()
|
||||
broken = DummyInstaller(
|
||||
"makefile-installer",
|
||||
layer=CliLayer.MAKEFILE.value,
|
||||
supports_result=True,
|
||||
fails=True,
|
||||
)
|
||||
working = DummyInstaller(
|
||||
"python-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
)
|
||||
|
||||
pipeline = InstallationPipeline([broken, working])
|
||||
pipeline.run(ctx)
|
||||
|
||||
self.assertTrue(broken.ran, "The preferred hook should be tried first.")
|
||||
self.assertTrue(
|
||||
working.ran,
|
||||
"The next hook must run when the preferred one installed nothing.",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_a_repository_whose_hooks_all_fail_is_reported(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""Falling back must not turn a broken repository into a silent pass."""
|
||||
mock_resolve_command_for_repo.return_value = None
|
||||
|
||||
ctx = _minimal_context()
|
||||
first = DummyInstaller(
|
||||
"makefile-installer",
|
||||
layer=CliLayer.MAKEFILE.value,
|
||||
supports_result=True,
|
||||
fails=True,
|
||||
)
|
||||
second = DummyInstaller(
|
||||
"python-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
fails=True,
|
||||
)
|
||||
|
||||
pipeline = InstallationPipeline([first, second])
|
||||
with self.assertRaises(SystemExit):
|
||||
pipeline.run(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user