diff --git a/tests/test_python_environment_sync.py b/tests/test_python_environment_sync.py index 46dd569..7991b39 100644 --- a/tests/test_python_environment_sync.py +++ b/tests/test_python_environment_sync.py @@ -113,6 +113,60 @@ class PythonEnvironmentSyncTests(unittest.TestCase): self.assertEqual(plan.mode, "Selective Python environment repair") self.assertEqual(plan.commands[0][-2:], ("-e", str(project_root))) + def test_metadata_sync_also_repairs_unrelated_missing_distribution(self) -> None: + sync = load_sync_module() + with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory: + root = Path(directory) + requirements = root / "requirements-dev.txt" + requirements.write_text("-e ./govoplan-changed\n-e ./govoplan-missing\n", encoding="utf-8") + for project in ("govoplan-changed", "govoplan-missing"): + project_root = root / project + project_root.mkdir() + (project_root / "pyproject.toml").write_text( + f'[project]\nname = "{project}"\nversion = "0.1.10"\n', + encoding="utf-8", + ) + + entries = sync.local_requirement_entries(requirements) + fingerprint = sync.build_fingerprint( + requirements=requirements, + python="/test/venv/bin/python", + local_requirements=entries, + ) + requirements_digest = hashlib.sha256(requirements.read_bytes()).hexdigest() + previous = { + "version": sync.STAMP_VERSION, + "python": "/test/venv/bin/python", + "inputs": [ + {"path": str(requirements), "sha256": requirements_digest}, + {"path": entries[0].pyproject, "sha256": "stale"}, + { + "path": entries[1].pyproject, + "sha256": hashlib.sha256(Path(entries[1].pyproject).read_bytes()).hexdigest(), + }, + ], + "requirements_entries": [ + entry.as_dict() for entry in sync.parse_requirement_entries(requirements) + ], + } + + plan = sync.build_install_plan( + previous=previous, + fingerprint=fingerprint, + requirements=requirements, + python="/test/venv/bin/python", + local_requirements=entries, + repair_requirements=(entries[1],), + force=False, + ) + + self.assertEqual(plan.mode, "Selective Python environment sync") + self.assertEqual(len(plan.commands), 1) + command = plan.commands[0] + self.assertEqual(command.count("-e"), 2) + self.assertIn(str(root / "govoplan-changed"), command) + self.assertIn(str(root / "govoplan-missing"), command) + def test_declared_module_entry_points_are_part_of_environment_validation(self) -> None: sync = load_sync_module() with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory: diff --git a/tools/repo/sync-python-environment.py b/tools/repo/sync-python-environment.py index 0da7965..710af16 100644 --- a/tools/repo/sync-python-environment.py +++ b/tools/repo/sync-python-environment.py @@ -149,6 +149,7 @@ def main() -> int: requirements=requirements, python=python, local_requirements=local_requirements, + repair_requirements=environment.stale_requirements, force=args.force, ) @@ -250,6 +251,7 @@ def build_install_plan( python: str, local_requirements: tuple[RequirementEntry, ...], force: bool, + repair_requirements: tuple[RequirementEntry, ...] = (), ) -> InstallPlan: full_command = (python, "-m", "pip", "install", "-r", str(requirements)) if force: @@ -273,7 +275,12 @@ def build_install_plan( removed_paths = set(previous_inputs) - set(current_inputs) stale_requirements = requirements_key in changed_paths or requirements_key in removed_paths - installs: list[tuple[str, ...]] = [] + # Metadata changes and installation drift can happen together. Keep both + # sets in one resolver transaction so a selective metadata sync also + # repairs local distributions omitted from the current environment. + installs: list[tuple[str, ...]] = [ + requirement.install_args for requirement in repair_requirements + ] warnings: list[str] = [] if stale_requirements: @@ -322,7 +329,7 @@ def build_install_plan( ) return InstallPlan( "Selective Python environment sync", - f"installing {len(deduped_installs)} stale local requirement(s) in one resolver transaction.", + f"installing {len(deduped_installs)} stale or missing local requirement(s) in one resolver transaction.", (command,), tuple(warnings), )