From eca75cbc9347ec94887d3c9a187185855b74f415 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 00:58:16 +0200 Subject: [PATCH] fix(dev): resolve stale editable packages together --- tests/test_python_environment_sync.py | 82 +++++++++++++++++++++++++++ tools/repo/sync-python-environment.py | 22 ++++--- 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 tests/test_python_environment_sync.py diff --git a/tests/test_python_environment_sync.py b/tests/test_python_environment_sync.py new file mode 100644 index 0000000..d50318d --- /dev/null +++ b/tests/test_python_environment_sync.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import hashlib +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +META_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = META_ROOT / "tools" / "repo" / "sync-python-environment.py" + + +def load_sync_module(): + spec = importlib.util.spec_from_file_location("sync_python_environment", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class PythonEnvironmentSyncTests(unittest.TestCase): + def test_stale_local_projects_share_one_resolver_transaction(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-core\n-e ./govoplan-access\n", encoding="utf-8") + for project in ("govoplan-core", "govoplan-access"): + 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", + ) + + local_requirements = sync.local_requirement_entries(requirements) + fingerprint = sync.build_fingerprint( + requirements=requirements, + python="/test/venv/bin/python", + local_requirements=local_requirements, + ) + 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": entry.pyproject, "sha256": "stale"} + for entry in local_requirements + ), + ], + "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=local_requirements, + force=False, + ) + + self.assertEqual(plan.mode, "Selective Python environment sync") + self.assertIn("one resolver transaction", plan.reason) + self.assertEqual(len(plan.commands), 1) + command = plan.commands[0] + self.assertEqual(command[:5], ("/test/venv/bin/python", "-m", "pip", "install", "-e")) + self.assertEqual(command.count("-e"), 2) + self.assertIn(str(root / "govoplan-core"), command) + self.assertIn(str(root / "govoplan-access"), command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/repo/sync-python-environment.py b/tools/repo/sync-python-environment.py index 5f6c285..47e7da5 100644 --- a/tools/repo/sync-python-environment.py +++ b/tools/repo/sync-python-environment.py @@ -236,7 +236,7 @@ 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 - commands: list[tuple[str, ...]] = [] + installs: list[tuple[str, ...]] = [] warnings: list[str] = [] if stale_requirements: @@ -247,8 +247,7 @@ def build_install_plan( "requirements changed in a way that needs the full resolver.", (full_command,), ) - for install_args in delta.commands: - commands.append((python, "-m", "pip", "install", *install_args)) + installs.extend(delta.commands) warnings.extend(delta.warnings) changed_paths.discard(requirements_key) removed_paths.discard(requirements_key) @@ -266,7 +265,7 @@ def build_install_plan( f"tracked input changed outside a known local project: {path}", (full_command,), ) - commands.append((python, "-m", "pip", "install", *entry.install_args)) + installs.append(entry.install_args) if removed_paths: return InstallPlan( @@ -275,12 +274,19 @@ def build_install_plan( (full_command,), ) - deduped_commands = tuple(dict.fromkeys(commands)) - if deduped_commands: + deduped_installs = tuple(dict.fromkeys(installs)) + if deduped_installs: + command = ( + python, + "-m", + "pip", + "install", + *(argument for install_args in deduped_installs for argument in install_args), + ) return InstallPlan( "Selective Python environment sync", - f"installing {len(deduped_commands)} stale local requirement(s).", - deduped_commands, + f"installing {len(deduped_installs)} stale local requirement(s) in one resolver transaction.", + (command,), tuple(warnings), ) return InstallPlan(