Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 49 additions & 15 deletions agent_cli/dev/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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():
Expand Down
119 changes: 114 additions & 5 deletions tests/dev/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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"]:
Expand Down
Loading