Skip to content
11 changes: 11 additions & 0 deletions roar/__init__.py
Original file line number Diff line number Diff line change
@@ -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__"]
2 changes: 2 additions & 0 deletions roar/core/models/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions roar/execution/provenance/data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
)
10 changes: 8 additions & 2 deletions roar/execution/provenance/runtime_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions roar/execution/runtime/abi_probe.py
Original file line number Diff line number Diff line change
@@ -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
52 changes: 34 additions & 18 deletions roar/execution/runtime/inject/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions roar/execution/runtime/inject/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<soabi>.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))

Expand Down
3 changes: 3 additions & 0 deletions roar/execution/runtime/inject/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading