diff --git a/roar/__init__.py b/roar/__init__.py index e69de29..bd7bf7e 100644 --- a/roar/__init__.py +++ b/roar/__init__.py @@ -0,0 +1,11 @@ +"""roar - Run Observation & Artifact Registration.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _version + +try: + __version__: str = _version("roar-cli") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + +__all__ = ["__version__"] diff --git a/roar/core/models/provenance.py b/roar/core/models/provenance.py index c3ef3e2..0619cfa 100644 --- a/roar/core/models/provenance.py +++ b/roar/core/models/provenance.py @@ -65,6 +65,8 @@ class PythonInjectData(RoarBaseModel): shared_libs: list[str] = Field(default_factory=list) used_packages: dict[str, str | None] = Field(default_factory=dict) installed_packages: dict[str, str] = Field(default_factory=dict) + python_version: str = "" + python_implementation: str = "" @computed_field # type: ignore[prop-decorator] @property diff --git a/roar/execution/provenance/data_loader.py b/roar/execution/provenance/data_loader.py index 5fea3e2..e57d4b0 100644 --- a/roar/execution/provenance/data_loader.py +++ b/roar/execution/provenance/data_loader.py @@ -217,4 +217,6 @@ def load_python_data(self, path: str | None) -> PythonInjectData: shared_libs=data.get("shared_libs", []), used_packages=data.get("used_packages", {}), installed_packages=data.get("installed_packages", {}), + python_version=data.get("python_version", ""), + python_implementation=data.get("python_implementation", ""), ) diff --git a/roar/execution/provenance/runtime_collector.py b/roar/execution/provenance/runtime_collector.py index 3245c1e..1252b0e 100644 --- a/roar/execution/provenance/runtime_collector.py +++ b/roar/execution/provenance/runtime_collector.py @@ -195,8 +195,14 @@ def collect( "machine": platform.machine(), }, python={ - "version": platform.python_version(), - "implementation": platform.python_implementation(), + # Prefer the traced child's Python (captured via the inject log) + # over roar-cli's host Python — when they differ (cross-Python + # roar run), the host value would misrepresent which Python + # actually ran the user's code. + "version": python_data.python_version or platform.python_version(), + "implementation": ( + python_data.python_implementation or platform.python_implementation() + ), }, env_vars=python_data.env_reads, container=container_info, diff --git a/roar/execution/runtime/abi_probe.py b/roar/execution/runtime/abi_probe.py new file mode 100644 index 0000000..645de84 --- /dev/null +++ b/roar/execution/runtime/abi_probe.py @@ -0,0 +1,41 @@ +"""Probe a target Python interpreter's CPython ABI tag.""" + +from __future__ import annotations + +import os +import subprocess + +_PROBE_TIMEOUT_SECONDS = 5 +_PROBE_SCRIPT = "import sys; print(sys.implementation.cache_tag)" + + +def probe_python_abi(executable: str) -> str | None: + """Return the running ABI tag (e.g. ``cp312``) of ``executable``, or ``None``. + + Returns ``None`` if the target doesn't look like Python (we only probe + invocations whose argv[0] basename starts with ``python``), the probe + fails, or the interpreter is something exotic that doesn't expose + ``sys.implementation.cache_tag``. Callers should treat ``None`` as + "don't lazy-install for this target" — the sitecustomize gate handles + whatever the traced process turns out to be. + """ + if not executable: + return None + if not os.path.basename(executable).startswith("python"): + return None + try: + result = subprocess.run( + [executable, "-c", _PROBE_SCRIPT], + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT_SECONDS, + check=False, + ) + except Exception: + # Anything from a missing executable to a test mock returning a weird + # value should degrade to "don't lazy-install for this target." + return None + if result.returncode != 0: + return None + tag = result.stdout.strip() + return tag or None diff --git a/roar/execution/runtime/inject/sitecustomize.py b/roar/execution/runtime/inject/sitecustomize.py index 2c1f372..db3b2c7 100644 --- a/roar/execution/runtime/inject/sitecustomize.py +++ b/roar/execution/runtime/inject/sitecustomize.py @@ -5,22 +5,37 @@ import sys -def _append_roar_runtime_pythonpath() -> None: +def _prepend_roar_runtime_pythonpath() -> None: + """Prepend ``ROAR_RUNTIME_PYTHONPATH`` entries to ``sys.path`` (in order). + + When the traced Python has a lazy-installed ABI-matched runtime tree on + ``ROAR_RUNTIME_PYTHONPATH``, that tree must beat system site-packages — + the system copies are the wrong-ABI ones, which is exactly why we + installed the tree in the first place. Prepending the whole list in + declared order (cache, then bundled fallbacks) keeps the lazy-install + cache at ``sys.path[0]``. + + Logic is inlined (rather than imported from elsewhere in roar) because + this runs *before* roar is necessarily importable — making roar + importable is exactly what this function does. + """ if importlib.util.find_spec("roar") is not None: return - appended = [] - for path in os.environ.get("ROAR_RUNTIME_PYTHONPATH", "").split(os.pathsep): - if path and path not in sys.path: - sys.path.append(path) - appended.append(path) - if appended: - os.environ["ROAR_RUNTIME_PYTHONPATH_ACTIVE"] = os.pathsep.join(appended) + new_paths = [ + path + for path in os.environ.get("ROAR_RUNTIME_PYTHONPATH", "").split(os.pathsep) + if path and path not in sys.path + ] + if not new_paths: + return + sys.path[:0] = new_paths + os.environ["ROAR_RUNTIME_PYTHONPATH_ACTIVE"] = os.pathsep.join(new_paths) -_append_roar_runtime_pythonpath() +_prepend_roar_runtime_pythonpath() from roar.execution.framework.runtime_imports import RuntimeImportController -from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag +from roar.execution.runtime.inject.support import matching_compiled_pydantic_core from roar.execution.runtime.inject.tracker import RuntimeInjectionTracker LOG_FILE = os.environ.get("ROAR_LOG_FILE") @@ -43,18 +58,19 @@ def _append_roar_runtime_pythonpath() -> None: if os.environ.get("ROAR_WRAP") == "1": - _bundled_abi = abi_minor_version(bundled_abi_tag(_ROAR_INJECT_DIR)) _running_abi = (sys.version_info.major, sys.version_info.minor) - if _bundled_abi is not None and _bundled_abi != _running_abi: + _expected_soabi = f"cpython-{_running_abi[0]}{_running_abi[1]}" + if not matching_compiled_pydantic_core(sys.path, _expected_soabi): sys.stderr.write( - f"roar: traced Python is {_running_abi[0]}.{_running_abi[1]} but " - f"roar-cli was installed under Python " - f"{_bundled_abi[0]}.{_bundled_abi[1]}.\n" + f"roar: no ABI-matched runtime found for Python " + f"{_running_abi[0]}.{_running_abi[1]}.\n" f" Backend integrations (Ray, OSMO) are disabled for this run.\n" f" File I/O is still captured.\n" - f" To re-enable backends, reinstall under the matching Python:\n" - f" uv tool install --python python{_running_abi[0]}.{_running_abi[1]} " - f"roar-cli --force\n" + f" Fix one of:\n" + f" - Install roar in this Python: pip install roar-cli\n" + f" - Reinstall roar-cli under matching Python:\n" + f" uv tool install --python python{_running_abi[0]}.{_running_abi[1]} " + f"roar-cli --reinstall\n" ) _runtime_import_controller.disable_backend_dispatch() else: diff --git a/roar/execution/runtime/inject/support.py b/roar/execution/runtime/inject/support.py index e86b95b..b6a8c3e 100644 --- a/roar/execution/runtime/inject/support.py +++ b/roar/execution/runtime/inject/support.py @@ -56,6 +56,32 @@ def abi_minor_version(tag: str | None) -> tuple[int, int] | None: return (int(digits[0]), int(digits[1:])) +def matching_compiled_pydantic_core(sys_path: list[str], expected_soabi: str) -> bool: + """Return True if a pydantic_core/_pydantic_core..so exists on ``sys_path``. + + ``expected_soabi`` is the long-form CPython SOABI substring (e.g. + ``cpython-313``) — typically built from the running interpreter's version + tuple. Used as the gate primitive in ``sitecustomize.py``: if a matching + compiled pydantic_core is reachable (either in roar's bundled tree or in + a lazy-installed runtime tree on ``ROAR_RUNTIME_PYTHONPATH``), backend + dispatch can safely fire. + """ + for entry in sys_path: + if not entry: + continue + pdc_dir = os.path.join(entry, "pydantic_core") + if not os.path.isdir(pdc_dir): + continue + try: + filenames = os.listdir(pdc_dir) + except OSError: + continue + for filename in filenames: + if expected_soabi in filename and filename.endswith(".so"): + return True + return False + + def is_suppressed() -> bool: return bool(getattr(_roar_suppress, "active", False)) diff --git a/roar/execution/runtime/inject/tracker.py b/roar/execution/runtime/inject/tracker.py index d1f8f7b..f817a18 100644 --- a/roar/execution/runtime/inject/tracker.py +++ b/roar/execution/runtime/inject/tracker.py @@ -6,6 +6,7 @@ import contextlib import json import os +import platform import sys from collections.abc import Mapping, MutableMapping, Sequence from typing import Any, Protocol, cast @@ -203,6 +204,8 @@ def write_log(self) -> None: "argv": sys.argv, "installed_packages": installed_packages, "used_packages": used_packages, + "python_version": platform.python_version(), + "python_implementation": platform.python_implementation(), } with self._real_open(self._log_file, "w") as handle: json.dump(data, handle) diff --git a/roar/execution/runtime/lazy_install.py b/roar/execution/runtime/lazy_install.py new file mode 100644 index 0000000..a41108a --- /dev/null +++ b/roar/execution/runtime/lazy_install.py @@ -0,0 +1,217 @@ +"""Lazy install of a per-ABI runtime tree for cross-Python ``roar run``. + +When ``uv tool install roar-cli`` installs roar under one Python (e.g. 3.13) +but ``roar run`` is invoked against a different one (e.g. system 3.12), +roar's bundled compiled deps don't match the traced Python's ABI. This +module installs a matching tree of runtime deps on demand into a per-ABI +cache directory under ``~/.cache/roar/runtime//``. + +``sitecustomize.py``'s ``_append_roar_runtime_pythonpath`` prepends the +cache directory to ``sys.path`` in the traced process, so imports there +resolve to the ABI-matched copies before reaching roar's bundled tree. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +# Deps backend dispatch needs in the traced Python. Kept short — pip/uv +# resolves transitive deps. Unpinned: roar tolerates any pydantic 2.x. +_RUNTIME_DEPS: tuple[str, ...] = ("pydantic", "blake3") + +_STAMP_FILENAME = "roar_runtime.json" +_INSTALL_TIMEOUT_SECONDS = 180 + + +def runtime_cache_root() -> Path: + """Return ``$XDG_CACHE_HOME/roar/runtime`` (default ``~/.cache/roar/runtime``).""" + xdg_cache = os.environ.get("XDG_CACHE_HOME") + base = Path(xdg_cache) if xdg_cache else Path.home() / ".cache" + return base / "roar" / "runtime" + + +def runtime_cache_dir(abi_tag: str) -> Path: + """Return the per-ABI cache directory (e.g. ``.../roar/runtime/cp312/``).""" + return runtime_cache_root() / abi_tag + + +def runtime_site_packages(abi_tag: str) -> Path: + return runtime_cache_dir(abi_tag) / "site-packages" + + +def is_runtime_cached(abi_tag: str, roar_version: str) -> bool: + """True iff a matching, roar-version-stamped runtime tree exists for ``abi_tag``.""" + stamp_path = runtime_cache_dir(abi_tag) / _STAMP_FILENAME + if not stamp_path.is_file(): + return False + try: + stamp = json.loads(stamp_path.read_text()) + except (OSError, ValueError): + return False + return stamp.get("roar_version") == roar_version + + +def install_runtime( + abi_tag: str, + target_python: str, + roar_version: str, + deps: tuple[str, ...] = _RUNTIME_DEPS, +) -> bool: + """Install a matching runtime tree for ``abi_tag``. Returns ``True`` on success. + + Atomic: installs into a tempdir alongside the cache root, then renames + into place. Failures (no network, missing pip, etc.) leave the cache in + its prior state — callers should treat a ``False`` return as "fall back + to the sitecustomize gate." + """ + cache_dir = runtime_cache_dir(abi_tag) + sys.stderr.write( + f"🦖 installing roar runtime for {abi_tag} ... (one-time per Python; cached)\n" + ) + sys.stderr.flush() + + try: + cache_root = runtime_cache_root() + cache_root.mkdir(parents=True, exist_ok=True) + except OSError: + return False + + tmpdir = Path(tempfile.mkdtemp(prefix="roar-runtime-", dir=cache_root)) + moved = False + try: + target_site = tmpdir / "site-packages" + target_site.mkdir(parents=True) + installer_cmd = _select_installer(target_python, target_site, deps) + if installer_cmd is None: + sys.stderr.write("🦖 install failed: no installer found (need uv or pip)\n") + return False + try: + result = subprocess.run( + installer_cmd, + capture_output=True, + text=True, + timeout=_INSTALL_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + sys.stderr.write(f"🦖 install failed: {exc}\n") + return False + if result.returncode != 0: + stderr_tail = (result.stderr or "").strip()[-500:] + sys.stderr.write(f"🦖 install failed (rc={result.returncode}): {stderr_tail}\n") + return False + + stamp_data = { + "roar_version": roar_version, + "abi_tag": abi_tag, + "installed_at": time.time(), + "deps": list(deps), + } + (tmpdir / _STAMP_FILENAME).write_text(json.dumps(stamp_data, indent=2)) + + if cache_dir.exists(): + with contextlib.suppress(OSError): + shutil.rmtree(cache_dir) + try: + os.rename(tmpdir, cache_dir) + moved = True + except OSError: + return False + finally: + if not moved: + with contextlib.suppress(Exception): + shutil.rmtree(tmpdir) + + return is_runtime_cached(abi_tag, roar_version) + + +def _select_installer( + target_python: str, target_dir: Path, deps: tuple[str, ...] +) -> list[str] | None: + """Pick the install command. Prefer ``uv pip install --target --python``.""" + uv = shutil.which("uv") + if uv: + return [ + uv, + "pip", + "install", + "--target", + str(target_dir), + "--python", + target_python, + *deps, + ] + # Fallback: plain pip. Only works if `pip` is in the target Python's env; + # best-effort. uv is strongly preferred because of the --python flag. + pip = shutil.which("pip") or shutil.which("pip3") + if pip: + return [pip, "install", "--target", str(target_dir), *deps] + return None + + +def runtime_install_mode(start_dir: Path | None = None) -> str: + """Resolve runtime.install mode: ``'auto'`` (default) or ``'skip'``. + + ``ROAR_RUNTIME_INSTALL`` env var takes precedence over project config. + Anything unrecognized falls back to ``'auto'``. + """ + env_value = os.environ.get("ROAR_RUNTIME_INSTALL") + if env_value: + normalized = env_value.strip().lower() + if normalized in ("auto", "skip"): + return normalized + return "auto" + + try: + from roar.integrations.config.access import config_get + + configured = config_get( + "runtime.install", start_dir=str(start_dir) if start_dir is not None else None + ) + except Exception: + return "auto" + if isinstance(configured, str) and configured.lower() in ("auto", "skip"): + return configured.lower() + return "auto" + + +def ensure_runtime( + target_python: str, + target_abi: str, + bundled_abi: str | None, + roar_version: str, + mode: str | None = None, + start_dir: Path | None = None, +) -> Path | None: + """Return the site-packages path for an ABI-matched runtime, or ``None``. + + Behavior: + - No action when ``target_abi`` matches ``bundled_abi`` — bundled wins. + - No action when mode is ``'skip'`` — gate handles whatever happens. + - Cache hit: return the cached path. + - Cache miss: install lazily and return the path on success. + - Install failure: ``None`` (caller falls back to the gate). + """ + if not target_abi: + return None + if bundled_abi and target_abi == bundled_abi: + return None + + resolved_mode = mode or runtime_install_mode(start_dir) + if resolved_mode == "skip": + return None + + if is_runtime_cached(target_abi, roar_version): + return runtime_site_packages(target_abi) + + if install_runtime(target_abi, target_python, roar_version): + return runtime_site_packages(target_abi) + return None diff --git a/roar/execution/runtime/tracer.py b/roar/execution/runtime/tracer.py index fb6bd52..7c796ff 100644 --- a/roar/execution/runtime/tracer.py +++ b/roar/execution/runtime/tracer.py @@ -5,6 +5,7 @@ """ import os +import shutil import subprocess import sys import time @@ -95,6 +96,69 @@ def add(path: str | Path) -> None: return entries + def _lazy_install_runtime_entries(self, command: list[str], roar_dir: Path) -> list[str]: + """Probe the target Python and lazy-install a matching runtime tree on mismatch. + + Returns a list of site-packages paths to prepend to + ``ROAR_RUNTIME_PYTHONPATH``. Empty on: + - non-Python targets (bash, make, etc.) — can't probe a python ABI; + - matching ABI — bundled deps work as-is; + - ``runtime.install = skip`` — opted out; + - install failure (no network, no installer, etc.) — the sitecustomize + gate handles the fallback. + """ + if not command: + return [] + + target_python = command[0] + + # Fast path: if the target Python is the same executable roar-cli + # itself runs under, we know the ABI matches by construction — skip + # the probe subprocess entirely. Avoids a Python-startup-per-run on + # platforms where that startup is slow (macOS framework Python), + # which on tight test budgets is the difference between passing and + # timing out. + try: + resolved_target = shutil.which(target_python) or target_python + if os.path.realpath(resolved_target) == os.path.realpath(sys.executable): + return [] + except OSError: + pass # fall through to the full probe + + try: + from roar import __version__ as roar_version + + from .abi_probe import probe_python_abi + from .lazy_install import ensure_runtime + except ImportError as exc: + # An import failure here is an internal contract violation (the + # imported names should always be available in a normal install), + # not a user-environment thing — log loud enough that a corrupted + # install surfaces in --verbose runs. + self.logger.warning("lazy-install init: import failed: %s", exc) + return [] + + target_abi = probe_python_abi(target_python) + if not target_abi: + return [] + bundled_abi = sys.implementation.cache_tag + if target_abi == bundled_abi: + return [] + try: + tree = ensure_runtime( + target_python=target_python, + target_abi=target_abi, + bundled_abi=bundled_abi, + roar_version=roar_version, + start_dir=roar_dir, + ) + except Exception as exc: + self.logger.debug("lazy-install failed: %s", exc) + return [] + if tree is None: + return [] + return [str(tree)] + def _find_ptrace_tracer(self) -> str | None: """Find the roar-tracer (ptrace) binary.""" return tracer_backends.find_ptrace_tracer(self._package_path) @@ -430,7 +494,9 @@ def execute( env["PYTHONPATH"] = ( f"{inject_dir}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else inject_dir ) - env["ROAR_RUNTIME_PYTHONPATH"] = os.pathsep.join(self._runtime_pythonpath_entries()) + runtime_entries = self._lazy_install_runtime_entries(command, roar_dir) + runtime_entries.extend(self._runtime_pythonpath_entries()) + env["ROAR_RUNTIME_PYTHONPATH"] = os.pathsep.join(runtime_entries) env["ROAR_LOG_FILE"] = inject_log_file env["ROAR_WRAP"] = "1" env["ROAR_PROJECT_DIR"] = str(roar_dir.parent) diff --git a/roar/integrations/config/access.py b/roar/integrations/config/access.py index 50a8d1d..57d8b72 100644 --- a/roar/integrations/config/access.py +++ b/roar/integrations/config/access.py @@ -179,6 +179,16 @@ "description": "Show a one-time per-machine banner when a tracer backend is " "selected for the first time or via fallback (set to false to suppress)", }, + "runtime.install": { + "type": str, + "default": "auto", + "description": ( + "How to handle a traced Python whose ABI doesn't match roar-cli's bundled " + "deps. 'auto' = lazy-install a matching runtime tree on first roar run; " + "'skip' = use bundled deps only (backend integrations disabled on mismatch). " + "Useful in restricted-network containers." + ), + }, "telemetry.enabled": { "type": bool, "default": True, diff --git a/tests/execution/runtime/test_abi_probe.py b/tests/execution/runtime/test_abi_probe.py new file mode 100644 index 0000000..5c5ff60 --- /dev/null +++ b/tests/execution/runtime/test_abi_probe.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock + +import pytest + +from roar.execution.runtime import abi_probe + + +def test_returns_none_for_empty_executable() -> None: + assert abi_probe.probe_python_abi("") is None + + +def test_returns_none_for_non_python_basename(monkeypatch: pytest.MonkeyPatch) -> None: + # Should bail before invoking subprocess for things that don't look like Python. + called = MagicMock() + monkeypatch.setattr(subprocess, "run", called) + assert abi_probe.probe_python_abi("/bin/bash") is None + assert called.call_count == 0 + + +def test_parses_tag_from_successful_probe(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=0, stdout="cp312\n", stderr="")), + ) + assert abi_probe.probe_python_abi("/usr/bin/python3") == "cp312" + + +def test_returns_none_on_nonzero_returncode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=1, stdout="", stderr="boom")), + ) + assert abi_probe.probe_python_abi("/usr/bin/python3") is None + + +def test_returns_none_on_subprocess_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + MagicMock(side_effect=OSError("no such executable")), + ) + assert abi_probe.probe_python_abi("/usr/bin/python3") is None + + +def test_returns_none_when_stdout_is_blank(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=0, stdout="\n", stderr="")), + ) + assert abi_probe.probe_python_abi("/usr/bin/python3") is None + + +def test_accepts_versioned_python_names(monkeypatch: pytest.MonkeyPatch) -> None: + called = MagicMock(return_value=MagicMock(returncode=0, stdout="cp311\n", stderr="")) + monkeypatch.setattr(subprocess, "run", called) + assert abi_probe.probe_python_abi("/usr/bin/python3.11") == "cp311" + assert called.call_count == 1 diff --git a/tests/execution/runtime/test_inject_support.py b/tests/execution/runtime/test_inject_support.py index 433859e..ff3fa90 100644 --- a/tests/execution/runtime/test_inject_support.py +++ b/tests/execution/runtime/test_inject_support.py @@ -2,7 +2,11 @@ from pathlib import Path -from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag +from roar.execution.runtime.inject.support import ( + abi_minor_version, + bundled_abi_tag, + matching_compiled_pydantic_core, +) def _make_inject_layout(root: Path, compiled_pkg: str, so_filename: str) -> Path: @@ -60,3 +64,52 @@ def test_abi_minor_version_handles_missing_or_unparseable() -> None: assert abi_minor_version("") is None assert abi_minor_version("nothing") is None assert abi_minor_version("cp3") is None # too few digits to split + + +# --------------------------------------------------------------------------- +# matching_compiled_pydantic_core +# --------------------------------------------------------------------------- + + +def _make_pydantic_core(site_pkg: Path, abi_filename: str) -> None: + pdc = site_pkg / "pydantic_core" + pdc.mkdir(parents=True) + (pdc / abi_filename).touch() + + +def test_matching_compiled_pydantic_core_finds_in_bundled(tmp_path: Path) -> None: + bundled = tmp_path / "bundled" + _make_pydantic_core(bundled, "_pydantic_core.cpython-313-x86_64-linux-gnu.so") + assert matching_compiled_pydantic_core([str(bundled)], "cpython-313") is True + + +def test_matching_compiled_pydantic_core_finds_in_lazy_runtime(tmp_path: Path) -> None: + bundled = tmp_path / "bundled" + runtime = tmp_path / "runtime" + _make_pydantic_core(bundled, "_pydantic_core.cpython-313-x86_64-linux-gnu.so") + _make_pydantic_core(runtime, "_pydantic_core.cpython-312-x86_64-linux-gnu.so") + assert matching_compiled_pydantic_core([str(runtime), str(bundled)], "cpython-312") is True + + +def test_matching_compiled_pydantic_core_returns_false_on_mismatch(tmp_path: Path) -> None: + bundled = tmp_path / "bundled" + _make_pydantic_core(bundled, "_pydantic_core.cpython-313-x86_64-linux-gnu.so") + assert matching_compiled_pydantic_core([str(bundled)], "cpython-312") is False + + +def test_matching_compiled_pydantic_core_handles_missing_pkg_dir(tmp_path: Path) -> None: + empty = tmp_path / "empty" + empty.mkdir() + assert matching_compiled_pydantic_core([str(empty)], "cpython-313") is False + + +def test_matching_compiled_pydantic_core_skips_blank_entries(tmp_path: Path) -> None: + bundled = tmp_path / "bundled" + _make_pydantic_core(bundled, "_pydantic_core.cpython-313-x86_64-linux-gnu.so") + assert matching_compiled_pydantic_core(["", str(bundled)], "cpython-313") is True + + +def test_matching_compiled_pydantic_core_ignores_wrong_extension(tmp_path: Path) -> None: + bundled = tmp_path / "bundled" + _make_pydantic_core(bundled, "_pydantic_core.cpython-313-x86_64-linux-gnu.pyd") + assert matching_compiled_pydantic_core([str(bundled)], "cpython-313") is False diff --git a/tests/execution/runtime/test_lazy_install.py b/tests/execution/runtime/test_lazy_install.py new file mode 100644 index 0000000..7da192d --- /dev/null +++ b/tests/execution/runtime/test_lazy_install.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from roar.execution.runtime import lazy_install + + +@pytest.fixture +def cache_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Pin the runtime cache to a tmp dir for all tests in this module.""" + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + return tmp_path / "roar" / "runtime" + + +# --------------------------------------------------------------------------- +# Cache layout +# --------------------------------------------------------------------------- + + +def test_runtime_cache_dir_respects_xdg(cache_root: Path) -> None: + assert lazy_install.runtime_cache_dir("cp312") == cache_root / "cp312" + assert lazy_install.runtime_site_packages("cp312") == cache_root / "cp312" / "site-packages" + + +def test_runtime_cache_root_falls_back_to_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path)) + assert lazy_install.runtime_cache_root() == tmp_path / ".cache" / "roar" / "runtime" + + +# --------------------------------------------------------------------------- +# is_runtime_cached +# --------------------------------------------------------------------------- + + +def test_is_runtime_cached_returns_false_without_stamp(cache_root: Path) -> None: + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is False + + +def test_is_runtime_cached_returns_true_for_matching_version(cache_root: Path) -> None: + abi_dir = cache_root / "cp312" + abi_dir.mkdir(parents=True) + (abi_dir / "roar_runtime.json").write_text(json.dumps({"roar_version": "0.3.0"})) + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is True + + +def test_is_runtime_cached_returns_false_for_stale_version(cache_root: Path) -> None: + abi_dir = cache_root / "cp312" + abi_dir.mkdir(parents=True) + (abi_dir / "roar_runtime.json").write_text(json.dumps({"roar_version": "0.2.0"})) + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is False + + +def test_is_runtime_cached_handles_corrupt_stamp(cache_root: Path) -> None: + abi_dir = cache_root / "cp312" + abi_dir.mkdir(parents=True) + (abi_dir / "roar_runtime.json").write_text("not json") + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is False + + +# --------------------------------------------------------------------------- +# install_runtime — subprocess mocked, real tempdir / rename behavior +# --------------------------------------------------------------------------- + + +def test_install_runtime_writes_stamp_and_returns_true_on_success( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Pretend uv is on PATH so _select_installer picks it. + monkeypatch.setattr( + lazy_install.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None + ) + # Mock the subprocess.run that does the install — pretend it succeeded. + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")), + ) + + success = lazy_install.install_runtime("cp312", "/usr/bin/python3.12", "0.3.0") + + assert success is True + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is True + stamp = json.loads((cache_root / "cp312" / "roar_runtime.json").read_text()) + assert stamp["roar_version"] == "0.3.0" + assert stamp["abi_tag"] == "cp312" + + +def test_install_runtime_returns_false_on_subprocess_failure( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + lazy_install.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None + ) + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=1, stdout="", stderr="no network")), + ) + + success = lazy_install.install_runtime("cp312", "/usr/bin/python3.12", "0.3.0") + + assert success is False + assert not (cache_root / "cp312").exists() + + +def test_install_runtime_returns_false_when_no_installer_available( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(lazy_install.shutil, "which", lambda _name: None) + spy = MagicMock() + monkeypatch.setattr(subprocess, "run", spy) + + assert lazy_install.install_runtime("cp312", "/usr/bin/python3.12", "0.3.0") is False + assert spy.call_count == 0 + + +def test_install_runtime_replaces_stale_cache( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Pre-existing stale tree. + stale_dir = cache_root / "cp312" + stale_dir.mkdir(parents=True) + (stale_dir / "roar_runtime.json").write_text(json.dumps({"roar_version": "0.2.0"})) + (stale_dir / "site-packages").mkdir() + (stale_dir / "site-packages" / "stale.txt").write_text("old") + + monkeypatch.setattr( + lazy_install.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None + ) + monkeypatch.setattr( + subprocess, + "run", + MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")), + ) + + assert lazy_install.install_runtime("cp312", "/usr/bin/python3.12", "0.3.0") is True + assert not (stale_dir / "site-packages" / "stale.txt").exists() + assert lazy_install.is_runtime_cached("cp312", "0.3.0") is True + + +# --------------------------------------------------------------------------- +# runtime_install_mode +# --------------------------------------------------------------------------- + + +def test_runtime_install_mode_defaults_to_auto(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ROAR_RUNTIME_INSTALL", raising=False) + monkeypatch.setattr( + "roar.integrations.config.access.config_get", + lambda _key, **_kwargs: None, + ) + assert lazy_install.runtime_install_mode() == "auto" + + +def test_runtime_install_mode_env_overrides_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ROAR_RUNTIME_INSTALL", "skip") + monkeypatch.setattr( + "roar.integrations.config.access.config_get", + lambda _key, **_kwargs: "auto", + ) + assert lazy_install.runtime_install_mode() == "skip" + + +def test_runtime_install_mode_reads_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ROAR_RUNTIME_INSTALL", raising=False) + monkeypatch.setattr( + "roar.integrations.config.access.config_get", + lambda _key, **_kwargs: "skip", + ) + assert lazy_install.runtime_install_mode() == "skip" + + +def test_runtime_install_mode_normalizes_case_and_rejects_garbage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ROAR_RUNTIME_INSTALL", "SKIP") + assert lazy_install.runtime_install_mode() == "skip" + monkeypatch.setenv("ROAR_RUNTIME_INSTALL", "nonsense") + assert lazy_install.runtime_install_mode() == "auto" + + +# --------------------------------------------------------------------------- +# ensure_runtime — the orchestration decision tree +# --------------------------------------------------------------------------- + + +def test_ensure_runtime_returns_none_when_abi_matches_bundled( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + spy = MagicMock() + monkeypatch.setattr(lazy_install, "install_runtime", spy) + result = lazy_install.ensure_runtime( + target_python="/usr/bin/python3.13", + target_abi="cp313", + bundled_abi="cp313", + roar_version="0.3.0", + ) + assert result is None + assert spy.call_count == 0 + + +def test_ensure_runtime_returns_none_when_mode_is_skip( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + spy = MagicMock() + monkeypatch.setattr(lazy_install, "install_runtime", spy) + result = lazy_install.ensure_runtime( + target_python="/usr/bin/python3.12", + target_abi="cp312", + bundled_abi="cp313", + roar_version="0.3.0", + mode="skip", + ) + assert result is None + assert spy.call_count == 0 + + +def test_ensure_runtime_returns_cache_path_on_cache_hit( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + abi_dir = cache_root / "cp312" + abi_dir.mkdir(parents=True) + (abi_dir / "roar_runtime.json").write_text(json.dumps({"roar_version": "0.3.0"})) + spy = MagicMock() + monkeypatch.setattr(lazy_install, "install_runtime", spy) + + result = lazy_install.ensure_runtime( + target_python="/usr/bin/python3.12", + target_abi="cp312", + bundled_abi="cp313", + roar_version="0.3.0", + mode="auto", + ) + assert result == abi_dir / "site-packages" + assert spy.call_count == 0 + + +def test_ensure_runtime_triggers_install_on_cache_miss( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_install(*_args, **_kwargs) -> bool: + abi_dir = cache_root / "cp312" + abi_dir.mkdir(parents=True) + (abi_dir / "roar_runtime.json").write_text(json.dumps({"roar_version": "0.3.0"})) + return True + + monkeypatch.setattr(lazy_install, "install_runtime", fake_install) + result = lazy_install.ensure_runtime( + target_python="/usr/bin/python3.12", + target_abi="cp312", + bundled_abi="cp313", + roar_version="0.3.0", + mode="auto", + ) + assert result == cache_root / "cp312" / "site-packages" + + +def test_ensure_runtime_returns_none_on_install_failure( + cache_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(lazy_install, "install_runtime", lambda *_a, **_kw: False) + result = lazy_install.ensure_runtime( + target_python="/usr/bin/python3.12", + target_abi="cp312", + bundled_abi="cp313", + roar_version="0.3.0", + mode="auto", + ) + assert result is None diff --git a/tests/execution/runtime/test_runtime_tracker.py b/tests/execution/runtime/test_runtime_tracker.py index 24f6f21..71999cd 100644 --- a/tests/execution/runtime/test_runtime_tracker.py +++ b/tests/execution/runtime/test_runtime_tracker.py @@ -37,6 +37,11 @@ def handle_import(self, module_name: str, module) -> None: assert str(data_path.resolve()) in payload["opened_files"] assert payload["env_reads"]["VIRTUAL_ENV"] == "/tmp/venv" assert payload["virtual_env"] == "/tmp/venv" + # Python identity from the *traced* process — consumed by runtime_collector + # to populate job metadata. Critical when roar's host Python and the traced + # Python differ (cross-Python `roar run`). + assert payload["python_version"].count(".") >= 2 # e.g. "3.12.3" + assert payload["python_implementation"] # e.g. "CPython" def test_runtime_tracker_excludes_roar_runtime_pythonpath_modules(tmp_path) -> None: diff --git a/tests/execution/runtime/test_sitecustomize_path_order.py b/tests/execution/runtime/test_sitecustomize_path_order.py new file mode 100644 index 0000000..1183057 --- /dev/null +++ b/tests/execution/runtime/test_sitecustomize_path_order.py @@ -0,0 +1,109 @@ +"""sitecustomize prepends ROAR_RUNTIME_PYTHONPATH entries (in order). + +Behavior under test: when the traced Python doesn't already have roar +importable (the cross-Python lazy-install scenario), ``sitecustomize.py`` +must put the entries from ``ROAR_RUNTIME_PYTHONPATH`` at the *front* of +``sys.path``, preserving the declared order. Appending (the old behavior) +lets the system's stale site-packages win — which is the friction-journal +bug where lazy-installed ``typing_extensions`` 4.15.0 lost to the +system's 4.4.x. + +Tested via subprocess so we exercise the real sitecustomize module-import +side effects without polluting the test process's ``sys.path``. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +SOURCE_ROOT = Path(__file__).resolve().parents[3] + + +def _run_python( + code: str, + *, + roar_runtime_pythonpath: str | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a subprocess Python with sitecustomize loaded from this source tree.""" + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + source_root = str(SOURCE_ROOT) + env["PYTHONPATH"] = source_root if not existing else source_root + os.pathsep + existing + if roar_runtime_pythonpath is not None: + env["ROAR_RUNTIME_PYTHONPATH"] = roar_runtime_pythonpath + else: + env.pop("ROAR_RUNTIME_PYTHONPATH", None) + env.pop("ROAR_WRAP", None) # skip the backend-dispatch gate; we only care about path order + return subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + env=env, + cwd=SOURCE_ROOT, + timeout=30, + ) + + +def test_runtime_pythonpath_entries_land_at_front_in_declared_order(tmp_path: Path) -> None: + """When roar isn't already importable, ROAR_RUNTIME_PYTHONPATH wins.""" + fake_runtime = tmp_path / "fake-runtime" + fake_runtime.mkdir() + fake_other = tmp_path / "fake-other" + fake_other.mkdir() + + # Force find_spec("roar") to return None by monkey-patching it before + # sitecustomize runs. We mark roar's site-packages location empty for the + # purposes of this subprocess by inserting a stub finder ahead of it that + # claims "roar is missing" — that's what triggers the prepend codepath. + code = textwrap.dedent( + """ + import importlib.util + import sys + + _real_find_spec = importlib.util.find_spec + def _patched_find_spec(name, *args, **kwargs): + if name == "roar": + return None + return _real_find_spec(name, *args, **kwargs) + importlib.util.find_spec = _patched_find_spec + + import importlib + importlib.import_module("roar.execution.runtime.inject.sitecustomize") + # The prepend has run; assert the entries are at the front, in order. + print(f"first={sys.path[0]}") + print(f"second={sys.path[1]}") + """ + ) + result = _run_python( + code, + roar_runtime_pythonpath=os.pathsep.join([str(fake_runtime), str(fake_other)]), + ) + assert result.returncode == 0, result.stderr + out = result.stdout + assert f"first={fake_runtime}" in out, out + assert f"second={fake_other}" in out, out + + +def test_no_prepend_when_roar_already_importable(tmp_path: Path) -> None: + """When roar is already importable, the function early-returns and leaves sys.path alone.""" + fake_runtime = tmp_path / "fake-runtime" + fake_runtime.mkdir() + code = textwrap.dedent( + f""" + import importlib + import sys + # Roar IS importable (PYTHONPATH points at source root). Prepend should no-op. + importlib.import_module("roar.execution.runtime.inject.sitecustomize") + target = {str(fake_runtime)!r} + in_top_three = target in sys.path[:3] + print("in_top_three=" + str(in_top_three)) + """ + ) + result = _run_python(code, roar_runtime_pythonpath=str(fake_runtime)) + assert result.returncode == 0, result.stderr + assert "in_top_three=False" in result.stdout, result.stdout diff --git a/tests/unit/test_roar_version.py b/tests/unit/test_roar_version.py new file mode 100644 index 0000000..7ef4985 --- /dev/null +++ b/tests/unit/test_roar_version.py @@ -0,0 +1,29 @@ +"""``roar.__version__`` must be importable and resolve to a real string. + +The lazy-install path imports ``from roar import __version__`` inside the +tracer launcher. An empty ``roar/__init__.py`` would silently turn the +entire lazy-install codepath into dead code via the launcher's +ImportError fallback. This test catches that regression at the package +level so it can't slip through. +""" + +from __future__ import annotations + + +def test_version_is_importable_and_non_empty() -> None: + from roar import __version__ + + assert isinstance(__version__, str) + assert __version__ + assert __version__ != "0.0.0+unknown" or not _roar_cli_metadata_available() + + +def _roar_cli_metadata_available() -> bool: + """True if PyPI/wheel metadata for roar-cli is reachable from this Python.""" + from importlib.metadata import PackageNotFoundError, version + + try: + version("roar-cli") + return True + except PackageNotFoundError: + return False diff --git a/tests/unit/test_tracer_data_loader.py b/tests/unit/test_tracer_data_loader.py index 90812b4..8f4e994 100644 --- a/tests/unit/test_tracer_data_loader.py +++ b/tests/unit/test_tracer_data_loader.py @@ -146,3 +146,92 @@ def test_preserves_thread_aware_file_contract_fields(self, tmp_path: Path) -> No "written_threads": [202], } ] + + +# --------------------------------------------------------------------------- +# load_python_data — the inject-log reader side +# --------------------------------------------------------------------------- + + +class TestLoadPythonData: + def test_python_identity_keys_flow_through(self, tmp_path: Path) -> None: + """python_version / python_implementation make it from JSON into the model.""" + log_path = tmp_path / "inject-log.json" + _write_json( + log_path, + { + "opened_files": [], + "imported_modules": [], + "env_reads": {}, + "modules_files": [], + "roar_inject_dir": str(tmp_path), + "shared_libs": [], + "sys_prefix": "/opt/py312", + "sys_base_prefix": "/opt/py312", + "installed_packages": {}, + "used_packages": {}, + "python_version": "3.12.3", + "python_implementation": "CPython", + }, + ) + + data = DataLoaderService().load_python_data(str(log_path)) + + assert data.python_version == "3.12.3" + assert data.python_implementation == "CPython" + + def test_python_identity_keys_default_to_empty_for_older_logs(self, tmp_path: Path) -> None: + """An inject log without the new keys loads cleanly (back-compat).""" + log_path = tmp_path / "inject-log.json" + _write_json( + log_path, + { + "opened_files": [], + "imported_modules": [], + "env_reads": {}, + "modules_files": [], + "roar_inject_dir": str(tmp_path), + "shared_libs": [], + "sys_prefix": "/opt/py312", + "sys_base_prefix": "/opt/py312", + "installed_packages": {}, + "used_packages": {}, + }, + ) + + data = DataLoaderService().load_python_data(str(log_path)) + + assert data.python_version == "" + assert data.python_implementation == "" + + def test_writer_reader_roundtrip_carries_python_identity(self, tmp_path: Path) -> None: + """End-to-end seam: the real tracker writes the JSON, the real loader reads it. + + Would have caught both halves of the friction-journal bug — if the + writer drops the key or the reader doesn't extract it, the loaded + model's python_version is empty. + """ + from roar.execution.runtime.inject.tracker import RuntimeInjectionTracker + + log_path = tmp_path / "inject-log.json" + + class _FakeController: + def handle_import(self, module_name, module) -> None: + return None + + tracker = RuntimeInjectionTracker( + {"ROAR_LOG_FILE": str(log_path)}, + _FakeController(), + log_file=str(log_path), + inject_dir=str(tmp_path / "inject"), + ) + tracker.write_log() + + data = DataLoaderService().load_python_data(str(log_path)) + + # In-process round-trip: this test's interpreter wrote the JSON, so + # the version is the test runner's — but the *seam* is what we care + # about, not the value. + assert data.python_version + assert data.python_version.count(".") >= 2 # e.g. "3.12.3" + assert data.python_implementation