Skip to content

Commit 1e0b313

Browse files
chrisgeyertreqsclaude
authored andcommitted
feat(runtime): lazy-install matching ABI runtime tree for cross-Python roar run
Solves the cross-Python first-run experience: `uv tool install roar-cli` installs roar under one CPython (typically uv's default), but the user runs `roar run python3 …` against a different one. Without a matching ABI tree, backend dispatch loads roar's bundled (wrong-ABI) compiled deps and crashes inside pydantic_core. The previous PR (#107) gates that crash gracefully. This one closes the loop: probe the target Python's ABI before launching, install a matching runtime tree at `~/.cache/roar/runtime/<tag>/` on the fly (or use the existing cache), and prepend it to ROAR_RUNTIME_PYTHONPATH so the traced process picks up ABI-correct compiled deps. What's added - `roar/execution/runtime/abi_probe.py` — `probe_python_abi(executable)` runs the target Python in a one-shot subprocess to read its `sys.implementation.cache_tag`. Bails fast for non-Python targets (bash/make/etc.) so `roar run cmd` doesn't pay a probe cost for things it couldn't lazy-install for anyway. - `roar/execution/runtime/lazy_install.py` — XDG-respecting cache (`$XDG_CACHE_HOME/roar/runtime/<tag>/` or `~/.cache/...`), atomic install via `uv pip install --target --python` (with plain pip fallback), roar-version-stamped cache invalidation, and the `ensure_runtime(...)` orchestrator. Failures return None — the sitecustomize gate handles fallback. - `runtime.install` config key — `auto` (default, lazy install on mismatch) or `skip` (use bundled only; backend dispatch off on mismatch). Env `ROAR_RUNTIME_INSTALL` overrides. The `skip` mode covers restricted-network containers where lazy-install would fail anyway. - `TracerService._lazy_install_runtime_entries(command, roar_dir)` — hooks the probe + ensure_runtime into `execute()` right before `ROAR_RUNTIME_PYTHONPATH` is set. Gate refactor (touches #107 territory) The original ABI-tag check (`bundled_abi_tag` + `abi_minor_version`) parsed roar's bundled `.so` filenames. That works for the bundled-only case but is blind to a lazy-installed runtime tree on the same path. Replaced with `matching_compiled_pydantic_core(sys.path, expected_soabi)` — walks sys.path for a pydantic_core SO whose filename matches the running interpreter's SOABI. Composes naturally with the lazy-install path (matching SO anywhere on sys.path satisfies the gate). The old helpers are kept (still tested) for future use and to avoid churning the API surface of `support.py`. User-visible - Lazy-install path emits a single 🦖 line on cache miss: 🦖 installing roar runtime for cp312 ... - Cache hits are silent. - Skip mode + ABI mismatch falls through to the gate's actionable message, which now also recommends `pip install roar-cli` for single-Python container environments. Test coverage - `test_abi_probe.py`: success / non-Python target / subprocess failure / blank stdout / versioned python names. - `test_lazy_install.py`: cache layout, stamp invalidation, atomic install (mocked subprocess, real tempdir/rename), failure paths, mode resolution (env / config / default / case normalization), and the `ensure_runtime` decision tree (match / skip / cache-hit / cache-miss-install / install-fail). - `test_inject_support.py`: `matching_compiled_pydantic_core` finds in bundled, finds in lazy runtime, returns False on mismatch / missing pkg / wrong extension / blank entries. 968 unit tests passing, 1 pre-existing skipped. Stacked on #107 (the gate). When #107 merges, this branch rebases cleanly onto main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 518b969 commit 1e0b313

9 files changed

Lines changed: 732 additions & 10 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Probe a target Python interpreter's CPython ABI tag."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
8+
_PROBE_TIMEOUT_SECONDS = 5
9+
_PROBE_SCRIPT = "import sys; print(sys.implementation.cache_tag)"
10+
11+
12+
def probe_python_abi(executable: str) -> str | None:
13+
"""Return the running ABI tag (e.g. ``cp312``) of ``executable``, or ``None``.
14+
15+
Returns ``None`` if the target doesn't look like Python (we only probe
16+
invocations whose argv[0] basename starts with ``python``), the probe
17+
fails, or the interpreter is something exotic that doesn't expose
18+
``sys.implementation.cache_tag``. Callers should treat ``None`` as
19+
"don't lazy-install for this target" — the sitecustomize gate handles
20+
whatever the traced process turns out to be.
21+
"""
22+
if not executable:
23+
return None
24+
if not os.path.basename(executable).startswith("python"):
25+
return None
26+
try:
27+
result = subprocess.run(
28+
[executable, "-c", _PROBE_SCRIPT],
29+
capture_output=True,
30+
text=True,
31+
timeout=_PROBE_TIMEOUT_SECONDS,
32+
check=False,
33+
)
34+
except (OSError, subprocess.SubprocessError):
35+
return None
36+
if result.returncode != 0:
37+
return None
38+
tag = result.stdout.strip()
39+
return tag or None

roar/execution/runtime/inject/sitecustomize.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _append_roar_runtime_pythonpath() -> None:
2020
_append_roar_runtime_pythonpath()
2121

2222
from roar.execution.framework.runtime_imports import RuntimeImportController
23-
from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag
23+
from roar.execution.runtime.inject.support import matching_compiled_pydantic_core
2424
from roar.execution.runtime.inject.tracker import RuntimeInjectionTracker
2525

2626
LOG_FILE = os.environ.get("ROAR_LOG_FILE")
@@ -43,17 +43,18 @@ def _append_roar_runtime_pythonpath() -> None:
4343

4444

4545
if os.environ.get("ROAR_WRAP") == "1":
46-
_bundled_abi = abi_minor_version(bundled_abi_tag(_ROAR_INJECT_DIR))
4746
_running_abi = (sys.version_info.major, sys.version_info.minor)
48-
if _bundled_abi is not None and _bundled_abi != _running_abi:
47+
_expected_soabi = f"cpython-{_running_abi[0]}{_running_abi[1]}"
48+
if not matching_compiled_pydantic_core(sys.path, _expected_soabi):
4949
sys.stderr.write(
50-
f"roar: traced Python is {_running_abi[0]}.{_running_abi[1]} but "
51-
f"roar-cli was installed under Python "
52-
f"{_bundled_abi[0]}.{_bundled_abi[1]}.\n"
50+
f"roar: no ABI-matched runtime found for Python "
51+
f"{_running_abi[0]}.{_running_abi[1]}.\n"
5352
f" Backend integrations (Ray, OSMO) are disabled for this run.\n"
5453
f" File I/O is still captured.\n"
55-
f" To re-enable backends, reinstall under the matching Python:\n"
56-
f" uv tool install --python python{_running_abi[0]}.{_running_abi[1]} "
54+
f" Fix one of:\n"
55+
f" - Install roar in this Python: pip install roar-cli\n"
56+
f" - Reinstall roar-cli under matching Python:\n"
57+
f" uv tool install --python python{_running_abi[0]}.{_running_abi[1]} "
5758
f"roar-cli --force\n"
5859
)
5960
_runtime_import_controller.disable_backend_dispatch()

roar/execution/runtime/inject/support.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,32 @@ def abi_minor_version(tag: str | None) -> tuple[int, int] | None:
5656
return (int(digits[0]), int(digits[1:]))
5757

5858

59+
def matching_compiled_pydantic_core(sys_path: list[str], expected_soabi: str) -> bool:
60+
"""Return True if a pydantic_core/_pydantic_core.<soabi>.so exists on ``sys_path``.
61+
62+
``expected_soabi`` is the long-form CPython SOABI substring (e.g.
63+
``cpython-313``) — typically built from the running interpreter's version
64+
tuple. Used as the gate primitive in ``sitecustomize.py``: if a matching
65+
compiled pydantic_core is reachable (either in roar's bundled tree or in
66+
a lazy-installed runtime tree on ``ROAR_RUNTIME_PYTHONPATH``), backend
67+
dispatch can safely fire.
68+
"""
69+
for entry in sys_path:
70+
if not entry:
71+
continue
72+
pdc_dir = os.path.join(entry, "pydantic_core")
73+
if not os.path.isdir(pdc_dir):
74+
continue
75+
try:
76+
filenames = os.listdir(pdc_dir)
77+
except OSError:
78+
continue
79+
for filename in filenames:
80+
if expected_soabi in filename and filename.endswith(".so"):
81+
return True
82+
return False
83+
84+
5985
def is_suppressed() -> bool:
6086
return bool(getattr(_roar_suppress, "active", False))
6187

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Lazy install of a per-ABI runtime tree for cross-Python ``roar run``.
2+
3+
When ``uv tool install roar-cli`` installs roar under one Python (e.g. 3.13)
4+
but ``roar run`` is invoked against a different one (e.g. system 3.12),
5+
roar's bundled compiled deps don't match the traced Python's ABI. This
6+
module installs a matching tree of runtime deps on demand into a per-ABI
7+
cache directory under ``~/.cache/roar/runtime/<tag>/``.
8+
9+
``sitecustomize.py``'s ``_append_roar_runtime_pythonpath`` prepends the
10+
cache directory to ``sys.path`` in the traced process, so imports there
11+
resolve to the ABI-matched copies before reaching roar's bundled tree.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import contextlib
17+
import json
18+
import os
19+
import shutil
20+
import subprocess
21+
import sys
22+
import tempfile
23+
import time
24+
from pathlib import Path
25+
26+
# Deps backend dispatch needs in the traced Python. Kept short — pip/uv
27+
# resolves transitive deps. Unpinned: roar tolerates any pydantic 2.x.
28+
_RUNTIME_DEPS: tuple[str, ...] = ("pydantic", "blake3")
29+
30+
_STAMP_FILENAME = "roar_runtime.json"
31+
_INSTALL_TIMEOUT_SECONDS = 180
32+
33+
34+
def runtime_cache_root() -> Path:
35+
"""Return ``$XDG_CACHE_HOME/roar/runtime`` (default ``~/.cache/roar/runtime``)."""
36+
xdg_cache = os.environ.get("XDG_CACHE_HOME")
37+
base = Path(xdg_cache) if xdg_cache else Path.home() / ".cache"
38+
return base / "roar" / "runtime"
39+
40+
41+
def runtime_cache_dir(abi_tag: str) -> Path:
42+
"""Return the per-ABI cache directory (e.g. ``.../roar/runtime/cp312/``)."""
43+
return runtime_cache_root() / abi_tag
44+
45+
46+
def runtime_site_packages(abi_tag: str) -> Path:
47+
return runtime_cache_dir(abi_tag) / "site-packages"
48+
49+
50+
def is_runtime_cached(abi_tag: str, roar_version: str) -> bool:
51+
"""True iff a matching, roar-version-stamped runtime tree exists for ``abi_tag``."""
52+
stamp_path = runtime_cache_dir(abi_tag) / _STAMP_FILENAME
53+
if not stamp_path.is_file():
54+
return False
55+
try:
56+
stamp = json.loads(stamp_path.read_text())
57+
except (OSError, ValueError):
58+
return False
59+
return stamp.get("roar_version") == roar_version
60+
61+
62+
def install_runtime(
63+
abi_tag: str,
64+
target_python: str,
65+
roar_version: str,
66+
deps: tuple[str, ...] = _RUNTIME_DEPS,
67+
) -> bool:
68+
"""Install a matching runtime tree for ``abi_tag``. Returns ``True`` on success.
69+
70+
Atomic: installs into a tempdir alongside the cache root, then renames
71+
into place. Failures (no network, missing pip, etc.) leave the cache in
72+
its prior state — callers should treat a ``False`` return as "fall back
73+
to the sitecustomize gate."
74+
"""
75+
cache_dir = runtime_cache_dir(abi_tag)
76+
sys.stderr.write(f"🦖 installing roar runtime for {abi_tag} ...\n")
77+
sys.stderr.flush()
78+
79+
try:
80+
cache_root = runtime_cache_root()
81+
cache_root.mkdir(parents=True, exist_ok=True)
82+
except OSError:
83+
return False
84+
85+
tmpdir = Path(tempfile.mkdtemp(prefix="roar-runtime-", dir=cache_root))
86+
moved = False
87+
try:
88+
target_site = tmpdir / "site-packages"
89+
target_site.mkdir(parents=True)
90+
installer_cmd = _select_installer(target_python, target_site, deps)
91+
if installer_cmd is None:
92+
sys.stderr.write("🦖 install failed: no installer found (need uv or pip)\n")
93+
return False
94+
try:
95+
result = subprocess.run(
96+
installer_cmd,
97+
capture_output=True,
98+
text=True,
99+
timeout=_INSTALL_TIMEOUT_SECONDS,
100+
check=False,
101+
)
102+
except (OSError, subprocess.SubprocessError) as exc:
103+
sys.stderr.write(f"🦖 install failed: {exc}\n")
104+
return False
105+
if result.returncode != 0:
106+
stderr_tail = (result.stderr or "").strip()[-500:]
107+
sys.stderr.write(f"🦖 install failed (rc={result.returncode}): {stderr_tail}\n")
108+
return False
109+
110+
stamp_data = {
111+
"roar_version": roar_version,
112+
"abi_tag": abi_tag,
113+
"installed_at": time.time(),
114+
"deps": list(deps),
115+
}
116+
(tmpdir / _STAMP_FILENAME).write_text(json.dumps(stamp_data, indent=2))
117+
118+
if cache_dir.exists():
119+
with contextlib.suppress(OSError):
120+
shutil.rmtree(cache_dir)
121+
try:
122+
os.rename(tmpdir, cache_dir)
123+
moved = True
124+
except OSError:
125+
return False
126+
finally:
127+
if not moved:
128+
with contextlib.suppress(Exception):
129+
shutil.rmtree(tmpdir)
130+
131+
return is_runtime_cached(abi_tag, roar_version)
132+
133+
134+
def _select_installer(
135+
target_python: str, target_dir: Path, deps: tuple[str, ...]
136+
) -> list[str] | None:
137+
"""Pick the install command. Prefer ``uv pip install --target --python``."""
138+
uv = shutil.which("uv")
139+
if uv:
140+
return [
141+
uv,
142+
"pip",
143+
"install",
144+
"--target",
145+
str(target_dir),
146+
"--python",
147+
target_python,
148+
*deps,
149+
]
150+
# Fallback: plain pip. Only works if `pip` is in the target Python's env;
151+
# best-effort. uv is strongly preferred because of the --python flag.
152+
pip = shutil.which("pip") or shutil.which("pip3")
153+
if pip:
154+
return [pip, "install", "--target", str(target_dir), *deps]
155+
return None
156+
157+
158+
def runtime_install_mode(start_dir: Path | None = None) -> str:
159+
"""Resolve runtime.install mode: ``'auto'`` (default) or ``'skip'``.
160+
161+
``ROAR_RUNTIME_INSTALL`` env var takes precedence over project config.
162+
Anything unrecognized falls back to ``'auto'``.
163+
"""
164+
env_value = os.environ.get("ROAR_RUNTIME_INSTALL")
165+
if env_value:
166+
normalized = env_value.strip().lower()
167+
if normalized in ("auto", "skip"):
168+
return normalized
169+
return "auto"
170+
171+
try:
172+
from roar.integrations.config.access import config_get
173+
174+
configured = config_get("runtime.install", start_dir=start_dir)
175+
except Exception:
176+
return "auto"
177+
if isinstance(configured, str) and configured.lower() in ("auto", "skip"):
178+
return configured.lower()
179+
return "auto"
180+
181+
182+
def ensure_runtime(
183+
target_python: str,
184+
target_abi: str,
185+
bundled_abi: str | None,
186+
roar_version: str,
187+
mode: str | None = None,
188+
start_dir: Path | None = None,
189+
) -> Path | None:
190+
"""Return the site-packages path for an ABI-matched runtime, or ``None``.
191+
192+
Behavior:
193+
- No action when ``target_abi`` matches ``bundled_abi`` — bundled wins.
194+
- No action when mode is ``'skip'`` — gate handles whatever happens.
195+
- Cache hit: return the cached path.
196+
- Cache miss: install lazily and return the path on success.
197+
- Install failure: ``None`` (caller falls back to the gate).
198+
"""
199+
if not target_abi:
200+
return None
201+
if bundled_abi and target_abi == bundled_abi:
202+
return None
203+
204+
resolved_mode = mode or runtime_install_mode(start_dir)
205+
if resolved_mode == "skip":
206+
return None
207+
208+
if is_runtime_cached(target_abi, roar_version):
209+
return runtime_site_packages(target_abi)
210+
211+
if install_runtime(target_abi, target_python, roar_version):
212+
return runtime_site_packages(target_abi)
213+
return None

roar/execution/runtime/tracer.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,50 @@ def add(path: str | Path) -> None:
9595

9696
return entries
9797

98+
def _lazy_install_runtime_entries(self, command: list[str], roar_dir: Path) -> list[str]:
99+
"""Probe the target Python and lazy-install a matching runtime tree on mismatch.
100+
101+
Returns a list of site-packages paths to prepend to
102+
``ROAR_RUNTIME_PYTHONPATH``. Empty on:
103+
- non-Python targets (bash, make, etc.) — can't probe a python ABI;
104+
- matching ABI — bundled deps work as-is;
105+
- ``runtime.install = skip`` — opted out;
106+
- install failure (no network, no installer, etc.) — the sitecustomize
107+
gate handles the fallback.
108+
"""
109+
if not command:
110+
return []
111+
try:
112+
from roar import __version__ as roar_version
113+
114+
from .abi_probe import probe_python_abi
115+
from .lazy_install import ensure_runtime
116+
except Exception as exc:
117+
self.logger.debug("lazy-install import skipped: %s", exc)
118+
return []
119+
120+
target_python = command[0]
121+
target_abi = probe_python_abi(target_python)
122+
if not target_abi:
123+
return []
124+
bundled_abi = sys.implementation.cache_tag
125+
if target_abi == bundled_abi:
126+
return []
127+
try:
128+
tree = ensure_runtime(
129+
target_python=target_python,
130+
target_abi=target_abi,
131+
bundled_abi=bundled_abi,
132+
roar_version=roar_version,
133+
start_dir=roar_dir,
134+
)
135+
except Exception as exc:
136+
self.logger.debug("lazy-install failed: %s", exc)
137+
return []
138+
if tree is None:
139+
return []
140+
return [str(tree)]
141+
98142
def _find_ptrace_tracer(self) -> str | None:
99143
"""Find the roar-tracer (ptrace) binary."""
100144
return tracer_backends.find_ptrace_tracer(self._package_path)
@@ -430,7 +474,9 @@ def execute(
430474
env["PYTHONPATH"] = (
431475
f"{inject_dir}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else inject_dir
432476
)
433-
env["ROAR_RUNTIME_PYTHONPATH"] = os.pathsep.join(self._runtime_pythonpath_entries())
477+
runtime_entries = self._lazy_install_runtime_entries(command, roar_dir)
478+
runtime_entries.extend(self._runtime_pythonpath_entries())
479+
env["ROAR_RUNTIME_PYTHONPATH"] = os.pathsep.join(runtime_entries)
434480
env["ROAR_LOG_FILE"] = inject_log_file
435481
env["ROAR_WRAP"] = "1"
436482
env["ROAR_PROJECT_DIR"] = str(roar_dir.parent)

0 commit comments

Comments
 (0)