mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user