"""Toolchain-matched provisioning: workspace gets the TARGET's Python. When ``toolchain_match_enabled`` is on and the target declares a Python version, ``install_dev_deps`` provisions the venv against that interpreter (``uv ... --python ``), runs a runnability smoke, and records the result in a workspace marker the gates read. When off, provisioning is exactly today's behavior. """ from __future__ import annotations import subprocess from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest from roboco.services.workspace import ( WorkspaceService, _detect_dep_commands, ) if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path _PY = '[project]\nname = "x"\nrequires-python = ">=3.14,<3.15"\n' def _service() -> WorkspaceService: session = MagicMock() session.execute = AsyncMock() return WorkspaceService(session) def _make_workspace(tmp_path: Path) -> Path: workspace = tmp_path / "roboco" / "backend" / "be-dev-1" (workspace / ".git").mkdir(parents=True) return workspace def test_detect_dep_commands_injects_target_python(tmp_path: Path) -> None: ws = _make_workspace(tmp_path) (ws / "pyproject.toml").write_text(_PY) commands = _detect_dep_commands(ws, target_python="3.14") assert commands[0][1] == ["uv", "sync", "--extra", "dev", "--python", "3.14"] def test_detect_dep_commands_no_python_is_unchanged(tmp_path: Path) -> None: ws = _make_workspace(tmp_path) (ws / "pyproject.toml").write_text(_PY) commands = _detect_dep_commands(ws) assert commands[0][1] == ["uv", "sync", "--extra", "dev"] def _fake_run_factory( captured: list[list[str]], *, collect_rc: int ) -> Callable[..., subprocess.CompletedProcess[str]]: def _fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]: captured.append(argv) rc = collect_rc if "--collect-only" in argv else 0 return subprocess.CompletedProcess(argv, returncode=rc, stdout="", stderr="") return _fake_run @pytest.mark.asyncio async def test_provisions_target_python_and_records_ok(tmp_path: Path) -> None: ws = _make_workspace(tmp_path) (ws / "pyproject.toml").write_text(_PY) (ws / "uv.lock").write_text("version = 1\n") svc = _service() captured: list[list[str]] = [] with ( patch("roboco.services.workspace.settings.toolchain_match_enabled", True), patch( "roboco.services.workspace.subprocess.run", side_effect=_fake_run_factory(captured, collect_rc=0), ), patch("roboco.services.workspace._ensure_agent_owned"), ): await svc.install_dev_deps(ws) # provisioned against 3.14 + ran the collect smoke assert ["uv", "sync", "--extra", "dev", "--python", "3.14"] in captured assert any("--collect-only" in argv for argv in captured) assert svc.read_toolchain_status(ws) == ("3.14", "ok") @pytest.mark.asyncio async def test_records_broken_on_collection_error(tmp_path: Path) -> None: ws = _make_workspace(tmp_path) (ws / "pyproject.toml").write_text(_PY) (ws / "uv.lock").write_text("version = 1\n") svc = _service() captured: list[list[str]] = [] with ( patch("roboco.services.workspace.settings.toolchain_match_enabled", True), patch( "roboco.services.workspace.subprocess.run", side_effect=_fake_run_factory( captured, collect_rc=2 ), # pytest collect error ), patch("roboco.services.workspace._ensure_agent_owned"), ): await svc.install_dev_deps(ws) assert svc.read_toolchain_status(ws) == ("3.14", "broken") @pytest.mark.asyncio async def test_flag_off_is_unchanged_no_marker(tmp_path: Path) -> None: ws = _make_workspace(tmp_path) (ws / "pyproject.toml").write_text(_PY) (ws / "uv.lock").write_text("version = 1\n") svc = _service() captured: list[list[str]] = [] with ( patch("roboco.services.workspace.settings.toolchain_match_enabled", False), patch( "roboco.services.workspace.subprocess.run", side_effect=_fake_run_factory(captured, collect_rc=0), ), patch("roboco.services.workspace._ensure_agent_owned"), ): await svc.install_dev_deps(ws) assert ["uv", "sync", "--extra", "dev"] in captured assert not any("--python" in argv for argv in captured) assert not any("--collect-only" in argv for argv in captured) assert svc.read_toolchain_status(ws) == (None, None)