From 098cf415a54151eabbb70fb786e136269aaf6ab4 Mon Sep 17 00:00:00 2001 From: code-steadfast Date: Tue, 8 Sep 2026 09:06:16 +0200 Subject: [PATCH] feat: steps prepend their install directories to the PATH --- AGENTS.md | 2 +- docs/explanation/execution_model.md | 7 +++++++ src/pypeline/domain/execution_context.py | 13 +++++++++++-- src/pypeline/steps/create_venv.py | 4 +++- tests/test_execution_context.py | 20 ++++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ffd4bf..c430e94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ The architecture follows a clean separation of concerns with three main layers: **ExecutionContext** is the shared state container passed to all steps (see [pypeline-steps skill](.agent/skills/pypeline-steps/SKILL.md) for usage examples): -- `install_dirs: List[Path]`: Binary directories automatically added to subprocess PATH +- `install_dirs: List[Path]`: Binary directories automatically added in front of the subprocess PATH, in registration order (`add_install_dirs(dirs, prepend=True)` to precede the earlier steps' directories, as `CreateVEnv` does) - `data_registry: DataRegistry`: Type-safe key-value store for arbitrary data exchange - `inputs: Dict[str, Any]`: User parameters from CLI (`-i key=value`) or config defaults - `env_vars: Dict[str, Any]`: Environment variables injected into all subprocess calls diff --git a/docs/explanation/execution_model.md b/docs/explanation/execution_model.md index 5c1bbe6..1631f16 100644 --- a/docs/explanation/execution_model.md +++ b/docs/explanation/execution_model.md @@ -81,3 +81,10 @@ The executor automatically: - Adds `install_dirs` to PATH - Injects `env_vars` - Handles Windows/Unix shell differences + +### PATH order + +`install_dirs` are placed before the inherited PATH, in the order the steps registered them. +A step can call `add_install_dirs(dirs, prepend=True)` to be placed before the directories of +the steps that ran earlier. `CreateVEnv` does this, so the project virtual environment always +takes precedence over tools installed by other steps, whatever order the steps run in. diff --git a/src/pypeline/domain/execution_context.py b/src/pypeline/domain/execution_context.py index e8075ab..619f131 100644 --- a/src/pypeline/domain/execution_context.py +++ b/src/pypeline/domain/execution_context.py @@ -24,8 +24,17 @@ class ExecutionContext: def get_input(self, name: str) -> Optional[Any]: return self.inputs.get(name, None) - def add_install_dirs(self, install_dirs: List[Path]) -> None: - self.install_dirs.extend(install_dirs) + def add_install_dirs(self, install_dirs: List[Path], prepend: bool = False) -> None: + """ + Register directories to be added to the PATH of the subsequent steps. + + prepend: place these directories ahead of the ones registered by earlier steps, + so that their PATH precedence does not depend on the pipeline step order. + """ + if prepend: + self.install_dirs[:0] = install_dirs + else: + self.install_dirs.extend(install_dirs) def add_env_vars(self, env_vars: Dict[str, Any]) -> None: self.env_vars.update(env_vars) diff --git a/src/pypeline/steps/create_venv.py b/src/pypeline/steps/create_venv.py index a328f18..642213d 100644 --- a/src/pypeline/steps/create_venv.py +++ b/src/pypeline/steps/create_venv.py @@ -428,7 +428,9 @@ def get_config(self) -> Optional[dict[str, str]]: return None def update_execution_context(self) -> None: - self.execution_context.add_install_dirs(self.install_dirs) + # The project virtual environment shall take precedence over tools installed by other steps, + # independent of the order in which the steps run. + self.execution_context.add_install_dirs(self.install_dirs, prepend=True) def get_needs_dependency_management(self) -> bool: # Always return False - the bootstrap script handles dependency management internally diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py index 5cdede3..b1aee3b 100644 --- a/tests/test_execution_context.py +++ b/tests/test_execution_context.py @@ -31,6 +31,26 @@ def test_create_process_executor_with_env_vars(project: Path) -> None: assert "value" in subprocess_executor.env.values() +def test_install_dirs_keep_call_order(project: Path) -> None: + context = ExecutionContext(project_root_dir=project) + context.add_install_dirs([Path("first")]) + context.add_install_dirs([Path("second")]) + + path = context.create_process_env()["PATH"] + assert path.index("first") < path.index("second") + + +def test_prepended_install_dirs_win_over_earlier_steps(project: Path) -> None: + context = ExecutionContext(project_root_dir=project) + # A tool installer (e.g. ScoopInstall) runs first and provides its own Python + context.add_install_dirs([Path("scoop/apps/python311/current")]) + # The virtual environment is created afterwards but must still come first on PATH + context.add_install_dirs([Path(".venv/Scripts")], prepend=True) + + path = context.create_process_env()["PATH"] + assert path.index(".venv/Scripts") < path.index("python311") + + class SomeData: def __init__(self, data: str) -> None: self.data = data