feat(toolchain): resolve target interpreter from requires-python

Pure resolver (services/toolchain.py) that derives the Python version an agent
should provision a target workspace with. Defends against uv's resolution
order — a .python-version file overrides requires-python during interpreter
selection — by honoring the pin only when it satisfies requires-python, else
resolving a concrete version from requires-python for the caller to pass via
--python. This is the root cause behind the live guard-core-app failure (pin
3.13 vs packages needing 3.14). Promotes packaging to a direct dependency.
This commit is contained in:
Renn F
2026-06-22 01:25:47 +02:00
parent 77c94b74da
commit 9274cd4584
4 changed files with 223 additions and 0 deletions
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
"sse-starlette", # Server-Sent Events for A2A streaming
# Direct imports (promoted from transitive)
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
"packaging", # services/toolchain.py — PEP 440 requires-python resolution
"claude-agent-sdk>=0.2.105",
]
+117
View File
@@ -0,0 +1,117 @@
"""Resolve the Python interpreter a target project needs.
Agents build arbitrary target projects whose Python requirement is independent
of RoboCo's own 3.13 stack. This module derives the version to provision the
agent workspace with, from the target's ``pyproject.toml`` (``requires-python``)
and ``.python-version``.
The load-bearing rule defends against uv's resolution order: uv lets a
``.python-version`` file override ``requires-python`` during interpreter
selection, so a repo pinned to 3.13 whose packages need 3.14 silently gets the
wrong interpreter. We therefore honor ``.python-version`` only when it actually
satisfies ``requires-python``; otherwise we resolve a concrete version from
``requires-python`` so the caller can pass it to uv explicitly (``--python``),
which overrides the pin.
Pure: filesystem reads only, no subprocess, no DB.
"""
from __future__ import annotations
import re
import tomllib
from dataclasses import dataclass
from typing import TYPE_CHECKING
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
if TYPE_CHECKING:
from pathlib import Path
from typing import Any
# Candidate ``major.minor`` interpreters, lowest first. The lowest version that
# satisfies the constraint is the most portable choice (best wheel coverage).
_CANDIDATE_MINORS: tuple[str, ...] = tuple(f"3.{minor}" for minor in range(8, 20))
_PIN_RE = re.compile(r"(\d+)\.(\d+)")
@dataclass(frozen=True)
class ResolvedPython:
"""The interpreter to provision with, and where it came from."""
version: str
source: str # "python_version_file" | "requires_python"
def satisfies(version: str, specifier: str) -> bool:
"""True iff ``version`` satisfies the PEP 440 ``specifier`` (empty = any)."""
if not specifier.strip():
return True
try:
return Version(version) in SpecifierSet(specifier)
except (InvalidSpecifier, InvalidVersion):
return False
def _extract_requires_python(data: dict[str, Any]) -> str | None:
"""Pull ``requires-python`` from a parsed pyproject (PEP 621, then poetry)."""
project = data.get("project")
if isinstance(project, dict) and project.get("requires-python"):
return str(project["requires-python"])
tool = data.get("tool")
poetry = tool.get("poetry") if isinstance(tool, dict) else None
deps = poetry.get("dependencies") if isinstance(poetry, dict) else None
python = deps.get("python") if isinstance(deps, dict) else None
if isinstance(python, str) and python.strip():
return python
return None
def _read_requires_python(project_root: Path) -> str | None:
pyproject = project_root / "pyproject.toml"
if not pyproject.exists():
return None
try:
data = tomllib.loads(pyproject.read_text())
except (tomllib.TOMLDecodeError, OSError):
return None
return _extract_requires_python(data)
def _read_pin(project_root: Path) -> str | None:
"""The ``major.minor`` from ``.python-version`` (tolerates ``3.13.2``,
``cpython-3.13``); None when absent or unparseable."""
pin_file = project_root / ".python-version"
if not pin_file.exists():
return None
try:
match = _PIN_RE.search(pin_file.read_text())
except OSError:
return None
return f"{match.group(1)}.{match.group(2)}" if match else None
def _lowest_satisfying(specifier: str) -> str | None:
for candidate in _CANDIDATE_MINORS:
if satisfies(candidate, specifier):
return candidate
return None
def resolve_target_python(project_root: Path) -> ResolvedPython | None:
"""The interpreter to provision the agent workspace with, or None.
None means the target declares nothing actionable (no python project / no
constraint) — the caller should leave provisioning unchanged.
"""
requires = _read_requires_python(project_root)
pin = _read_pin(project_root)
if pin is not None and (requires is None or satisfies(pin, requires)):
return ResolvedPython(pin, "python_version_file")
if requires is not None:
version = _lowest_satisfying(requires)
if version is not None:
return ResolvedPython(version, "requires_python")
return None
@@ -0,0 +1,103 @@
"""The interpreter resolver derives the target project's Python version.
uv lets a ``.python-version`` file override ``requires-python`` during
interpreter selection — that mismatch is the live failure mode (a repo pinned
to 3.13 whose packages need 3.14). The resolver defends against it: it honors
``.python-version`` only when it actually satisfies ``requires-python``,
otherwise it resolves a concrete version from ``requires-python`` so provisioning
can pass it to uv explicitly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.services.toolchain import ResolvedPython, resolve_target_python, satisfies
if TYPE_CHECKING:
from pathlib import Path
def _write(root: Path, *, pyproject: str | None = None, pin: str | None = None) -> None:
if pyproject is not None:
(root / "pyproject.toml").write_text(pyproject)
if pin is not None:
(root / ".python-version").write_text(pin)
def test_requires_python_only_resolves_lowest_satisfying(tmp_path: Path) -> None:
_write(tmp_path, pyproject='[project]\nrequires-python = ">=3.14,<3.15"\n')
assert resolve_target_python(tmp_path) == ResolvedPython("3.14", "requires_python")
def test_python_version_file_ignored_when_it_violates_requires_python(
tmp_path: Path,
) -> None:
# The guard-core-app bug: pin says 3.13 but the packages need 3.14.
_write(
tmp_path,
pyproject='[project]\nrequires-python = ">=3.14,<3.15"\n',
pin="3.13\n",
)
assert resolve_target_python(tmp_path) == ResolvedPython("3.14", "requires_python")
def test_python_version_file_honored_when_it_satisfies(tmp_path: Path) -> None:
_write(
tmp_path,
pyproject='[project]\nrequires-python = ">=3.14,<3.15"\n',
pin="3.14\n",
)
assert resolve_target_python(tmp_path) == ResolvedPython(
"3.14", "python_version_file"
)
def test_python_version_file_with_patch_and_satisfying(tmp_path: Path) -> None:
_write(
tmp_path,
pyproject='[project]\nrequires-python = ">=3.12"\n',
pin="3.13.2\n",
)
assert resolve_target_python(tmp_path) == ResolvedPython(
"3.13", "python_version_file"
)
def test_open_lower_bound_picks_that_minor(tmp_path: Path) -> None:
_write(tmp_path, pyproject='[project]\nrequires-python = ">=3.12,<3.13"\n')
assert resolve_target_python(tmp_path) == ResolvedPython("3.12", "requires_python")
def test_poetry_table_requires_python(tmp_path: Path) -> None:
_write(
tmp_path,
pyproject='[tool.poetry.dependencies]\npython = ">=3.14,<3.15"\n',
)
assert resolve_target_python(tmp_path) == ResolvedPython("3.14", "requires_python")
def test_no_python_project_returns_none(tmp_path: Path) -> None:
# No pyproject and no pin → nothing to resolve; caller leaves uv unchanged.
assert resolve_target_python(tmp_path) is None
def test_pyproject_without_requires_python_and_no_pin_returns_none(
tmp_path: Path,
) -> None:
_write(tmp_path, pyproject='[project]\nname = "x"\nversion = "0"\n')
assert resolve_target_python(tmp_path) is None
def test_pin_only_no_requires_python_is_honored(tmp_path: Path) -> None:
# A pin with no requires-python constraint is trivially satisfying.
_write(tmp_path, pyproject='[project]\nname = "x"\nversion = "0"\n', pin="3.13\n")
assert resolve_target_python(tmp_path) == ResolvedPython(
"3.13", "python_version_file"
)
def test_satisfies_helper() -> None:
assert satisfies("3.14", ">=3.14,<3.15") is True
assert satisfies("3.13", ">=3.14,<3.15") is False
assert satisfies("3.12", "") is True
Generated
+2
View File
@@ -2996,6 +2996,7 @@ dependencies = [
{ name = "httpx" },
{ name = "mcp" },
{ name = "openai" },
{ name = "packaging" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -3073,6 +3074,7 @@ requires-dist = [
{ name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" },
{ name = "mypy", marker = "extra == 'dev'" },
{ name = "openai" },
{ name = "packaging" },
{ name = "passlib", extras = ["bcrypt"] },
{ name = "pip-audit", marker = "extra == 'dev'" },
{ name = "pydantic" },