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
|
"sse-starlette", # Server-Sent Events for A2A streaming
|
||||||
# Direct imports (promoted from transitive)
|
# Direct imports (promoted from transitive)
|
||||||
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
|
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
|
||||||
|
"packaging", # services/toolchain.py — PEP 440 requires-python resolution
|
||||||
"claude-agent-sdk>=0.2.105",
|
"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 = "httpx" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
|
{ name = "packaging" },
|
||||||
{ name = "passlib", extra = ["bcrypt"] },
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -3073,6 +3074,7 @@ requires-dist = [
|
|||||||
{ name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" },
|
{ name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" },
|
||||||
{ name = "mypy", marker = "extra == 'dev'" },
|
{ name = "mypy", marker = "extra == 'dev'" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
|
{ name = "packaging" },
|
||||||
{ name = "passlib", extras = ["bcrypt"] },
|
{ name = "passlib", extras = ["bcrypt"] },
|
||||||
{ name = "pip-audit", marker = "extra == 'dev'" },
|
{ name = "pip-audit", marker = "extra == 'dev'" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
|||||||
Reference in New Issue
Block a user