diff --git a/agent_cli/dev/project.py b/agent_cli/dev/project.py index c53452a8..1b636c73 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 47b93310..bba6ec5a 100644 --- a/tests/dev/test_project.py +++ b/tests/dev/test_project.py @@ -42,19 +42,120 @@ 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 + 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.""" @@ -253,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"]: