From 2702496ddb6d78c5de677a8a0ea5939a728f9e94 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 10 Jun 2026 11:32:54 -0700 Subject: [PATCH 1/2] Pick best available installer for generic Python project setup Generic pyproject.toml and requirements.txt projects hardcoded pip, which fails on systems without a bare pip on PATH (e.g. macOS with Homebrew Python only ships pip3) with: Warning: Setup failed: /bin/sh: pip: command not found Choose the installer at detection time instead, preferring uv (uv venv + uv pip install, which targets the freshly created ./.venv since run_setup clears VIRTUAL_ENV), then pip, then pip3. When no installer is available, detection skips setup instead of failing. As a bonus, the uv-created .venv means the direnv step now generates a 'source .venv/bin/activate' .envrc for generic Python projects. --- agent_cli/dev/project.py | 64 +++++++++++++++++++++------- tests/dev/test_project.py | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 15 deletions(-) diff --git a/agent_cli/dev/project.py b/agent_cli/dev/project.py index c53452a8d..1b636c73a 100644 --- a/agent_cli/dev/project.py +++ b/agent_cli/dev/project.py @@ -89,6 +89,28 @@ def _unidep_cmd(subcommand: str) -> str | None: return None +def _python_install_commands(install_args: str) -> list[str] | None: + """Build install commands using the best available Python installer. + + Prefers uv (``uv venv`` creates ./.venv, which ``uv pip install`` then + targets from the working directory), then pip, then pip3. Returns None + when no installer is available. + + Evidence: https://docs.astral.sh/uv/pip/environments/ - ``uv venv`` + creates a virtual environment at .venv and ``uv pip install`` installs + into the .venv in the working directory. Note ``uv pip`` prefers an + activated environment (VIRTUAL_ENV) over ./.venv, which run_setup() + handles by clearing VIRTUAL_ENV from the subprocess environment. + """ + if shutil.which("uv"): + return ["uv venv", f"uv pip install {install_args}"] + if shutil.which("pip"): + return [f"pip install {install_args}"] + if shutil.which("pip3"): + return [f"pip3 install {install_args}"] + return None + + def _detect_unidep_project(path: Path) -> ProjectType | None: """Detect unidep project and determine the appropriate install command. @@ -143,6 +165,29 @@ def _detect_unidep_project(path: Path) -> ProjectType | None: return None +def _detect_pip_install_project(path: Path) -> ProjectType | None: + """Detect pip-installable Python projects, skipped when no installer is available.""" + if (path / "requirements.txt").exists(): + commands = _python_install_commands("-r requirements.txt") + if commands is not None: + return ProjectType( + name="python-pip", + setup_commands=commands, + description="Python project with pip", + ) + + if (path / "pyproject.toml").exists(): + commands = _python_install_commands("-e .") + if commands is not None: + return ProjectType( + name="python", + setup_commands=commands, + description="Python project", + ) + + return None + + def detect_project_type(path: Path) -> ProjectType | None: # noqa: PLR0911 """Detect the project type based on files present. @@ -181,21 +226,10 @@ def detect_project_type(path: Path) -> ProjectType | None: # noqa: PLR0911 description="Python project with Poetry", ) - # Python with pip/requirements.txt - if (path / "requirements.txt").exists(): - return ProjectType( - name="python-pip", - setup_commands=["pip install -r requirements.txt"], - description="Python project with pip", - ) - - # Python with pyproject.toml (generic) - if (path / "pyproject.toml").exists(): - return ProjectType( - name="python", - setup_commands=["pip install -e ."], - description="Python project", - ) + # Python with pip/requirements.txt or generic pyproject.toml + pip_project = _detect_pip_install_project(path) + if pip_project is not None: + return pip_project # Node.js with pnpm if (path / "pnpm-lock.yaml").exists(): diff --git a/tests/dev/test_project.py b/tests/dev/test_project.py index 47b933108..5ec284264 100644 --- a/tests/dev/test_project.py +++ b/tests/dev/test_project.py @@ -56,6 +56,93 @@ def test_python_generic(self, tmp_path: Path) -> None: assert project is not None assert project.name == "python" + def test_python_generic_prefers_uv( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: + """Generic Python projects install via uv when available. + + Evidence: https://docs.astral.sh/uv/pip/environments/ - `uv venv` + creates a virtual environment at .venv in the working directory, and + `uv pip install` installs into the .venv in the working directory + (verified live with uv 0.9: `uv venv && uv pip install six` in an + empty directory creates ./.venv and installs into it). `uv pip` + prefers an activated VIRTUAL_ENV over ./.venv, which run_setup() + already neutralizes by removing VIRTUAL_ENV from the subprocess env. + """ + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None, + ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + project = detect_project_type(tmp_path) + assert project is not None + assert project.name == "python" + assert project.setup_commands == ["uv venv", "uv pip install -e ."] + + def test_python_pip_prefers_uv( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: + """requirements.txt projects install via uv when available.""" + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None, + ) + (tmp_path / "requirements.txt").write_text("requests>=2.0") + project = detect_project_type(tmp_path) + assert project is not None + assert project.name == "python-pip" + assert project.setup_commands == ["uv venv", "uv pip install -r requirements.txt"] + + def test_python_generic_falls_back_to_pip( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: + """Without uv, generic Python projects fall back to pip.""" + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: "/usr/bin/pip" if name == "pip" else None, + ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + project = detect_project_type(tmp_path) + assert project is not None + assert project.setup_commands == ["pip install -e ."] + + def test_python_generic_falls_back_to_pip3( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: + """Without uv and pip, fall back to pip3. + + Evidence: macOS (e.g. Homebrew/Xcode Python) ships `pip3` without a + bare `pip` on PATH, so `pip install -e .` fails with + '/bin/sh: pip: command not found'. + """ + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: "/usr/bin/pip3" if name == "pip3" else None, + ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + project = detect_project_type(tmp_path) + assert project is not None + assert project.setup_commands == ["pip3 install -e ."] + + def test_python_generic_no_installer_available( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: + """Without any Python installer, detection skips setup instead of failing.""" + mocker.patch("agent_cli.dev.project.shutil.which", return_value=None) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + project = detect_project_type(tmp_path) + assert project is None + def test_node_pnpm(self, tmp_path: Path) -> None: """Detect Node.js project with pnpm.""" (tmp_path / "pnpm-lock.yaml").touch() From 468b8f31e6f7b63084eaceca5d70a5d42618ee3a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 10 Jun 2026 11:43:08 -0700 Subject: [PATCH 2/2] Mock shutil.which in environment-sensitive detection tests Generic Python detection now requires an installer on PATH, so tests asserting it must not depend on the host having uv/pip/pip3 installed. Addresses review feedback on #591. --- tests/dev/test_project.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/dev/test_project.py b/tests/dev/test_project.py index 5ec284264..bba6ec5a0 100644 --- a/tests/dev/test_project.py +++ b/tests/dev/test_project.py @@ -42,15 +42,29 @@ def test_python_poetry(self, tmp_path: Path) -> None: assert project.name == "python-poetry" assert "poetry install" in project.setup_commands - def test_python_pip(self, tmp_path: Path) -> None: - """Detect Python project with requirements.txt.""" + def test_python_pip(self, tmp_path: Path, mocker: pytest.MockerFixture) -> None: + """Detect Python project with requirements.txt. + + Mocks shutil.which because detection requires an installer on PATH. + """ + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: f"/usr/bin/{name}", + ) (tmp_path / "requirements.txt").write_text("requests>=2.0") project = detect_project_type(tmp_path) assert project is not None assert project.name == "python-pip" - def test_python_generic(self, tmp_path: Path) -> None: - """Detect generic Python project with pyproject.toml.""" + def test_python_generic(self, tmp_path: Path, mocker: pytest.MockerFixture) -> None: + """Detect generic Python project with pyproject.toml. + + Mocks shutil.which because detection requires an installer on PATH. + """ + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: f"/usr/bin/{name}", + ) (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') project = detect_project_type(tmp_path) assert project is not None @@ -340,12 +354,20 @@ def test_python_unidep_monorepo_without_root_requirements(self, tmp_path: Path) cmd = project.setup_commands[0] assert "unidep install-all -e -n {env_name}" in cmd - def test_python_unidep_excludes_test_example_dirs(self, tmp_path: Path) -> None: + def test_python_unidep_excludes_test_example_dirs( + self, + tmp_path: Path, + mocker: pytest.MockerFixture, + ) -> None: """Exclude test/example directories from monorepo detection. Evidence: Directories like tests/, example/, docs/ often contain requirements.yaml files as test fixtures, not actual dependencies. """ + mocker.patch( + "agent_cli.dev.project.shutil.which", + side_effect=lambda name: f"/usr/bin/{name}", + ) # Only requirements.yaml in excluded directories - should NOT be monorepo (tmp_path / "pyproject.toml").write_text('[project]\nname = "myproject"') for excluded in ["tests", "example", "docs"]: