Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions roar/execution/framework/runtime_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ class RuntimeImportController:
def __init__(self, environ: MutableMapping[str, str]):
self._environ = environ
self._initialized_backend_names: set[str] = set()
self._backend_dispatch_disabled = False

def disable_backend_dispatch(self) -> None:
"""Suppress backend init / observe / patch for the lifetime of this controller.

Used when the traced Python's ABI doesn't match roar's bundled compiled
deps — loading a backend plugin would trigger an ``ImportError`` from
wrong-ABI wheels (pydantic_core, etc.). The stdlib tracker hooks
(file opens, environ reads, import names) keep running.
"""
self._backend_dispatch_disabled = True

def resolve_selected_backend(self) -> ExecutionBackend | None:
backend_name = str(self._environ.get(ROAR_EXECUTION_BACKEND_ENV) or "").strip()
Expand All @@ -33,11 +44,16 @@ def resolve_selected_backend(self) -> ExecutionBackend | None:
return None

def initialize_selected_backend(self) -> None:
if self._backend_dispatch_disabled:
return
backend = self.resolve_selected_backend()
if backend is not None:
self._initialize_backend(backend)

def handle_import(self, module_name: str, module: Any) -> ExecutionBackend | None:
if self._backend_dispatch_disabled:
return None

matched_backend = match_execution_backend_for_module(module_name)
if matched_backend is not None:
self._environ.setdefault(ROAR_EXECUTION_BACKEND_ENV, matched_backend.name)
Expand Down
18 changes: 17 additions & 1 deletion roar/execution/runtime/inject/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def _append_roar_runtime_pythonpath() -> None:
_append_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.tracker import RuntimeInjectionTracker

LOG_FILE = os.environ.get("ROAR_LOG_FILE")
Expand All @@ -42,7 +43,22 @@ def _append_roar_runtime_pythonpath() -> None:


if os.environ.get("ROAR_WRAP") == "1":
_runtime_import_controller.initialize_selected_backend()
_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:
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" 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"
)
_runtime_import_controller.disable_backend_dispatch()
else:
_runtime_import_controller.initialize_selected_backend()


atexit.register(_runtime_tracker.write_log)
47 changes: 47 additions & 0 deletions roar/execution/runtime/inject/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,60 @@

import contextlib
import os
import re
import shutil
import sys
import threading

_roar_suppress = threading.local()


def bundled_abi_tag(inject_dir: str) -> str | None:
"""Return the cpython ABI tag of roar's bundled compiled deps, or None.

Walks up from ``inject_dir`` to find the enclosing ``site-packages`` and
parses the CPython ABI tag from a known compiled dependency's ``.so``
filename (e.g. ``_pydantic_core.cpython-313-x86_64-linux-gnu.so`` →
``"cpython-313"``). Returns ``None`` if the layout doesn't match — callers
should treat ``None`` as "don't gate" rather than assuming mismatch.
"""
site_pkg = inject_dir
while os.path.basename(site_pkg) != "site-packages":
parent = os.path.dirname(site_pkg)
if parent == site_pkg:
return None
site_pkg = parent

for known_pkg in ("pydantic_core", "blake3"):
pkg_dir = os.path.join(site_pkg, known_pkg)
if not os.path.isdir(pkg_dir):
continue
try:
entries = os.listdir(pkg_dir)
except OSError:
continue
for filename in entries:
for token in filename.split("."):
if token.startswith("cpython-"):
m = re.match(r"cpython-\d+", token)
if m:
return m.group()
return None


def abi_minor_version(tag: str | None) -> tuple[int, int] | None:
"""Extract ``(major, minor)`` from an ABI tag like ``cp313`` or ``cpython-313``."""
if not tag:
return None
match = re.search(r"\d+", tag)
if not match:
return None
digits = match.group()
if len(digits) < 2:
return None
return (int(digits[0]), int(digits[1:]))


def is_suppressed() -> bool:
return bool(getattr(_roar_suppress, "active", False))

Expand Down
62 changes: 62 additions & 0 deletions tests/execution/runtime/test_inject_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

from pathlib import Path

from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag


def _make_inject_layout(root: Path, compiled_pkg: str, so_filename: str) -> Path:
"""Create a fake site-packages tree and return the simulated inject dir."""
site_pkg = root / "site-packages"
pkg_dir = site_pkg / compiled_pkg
pkg_dir.mkdir(parents=True)
(pkg_dir / so_filename).touch()
inject_dir = site_pkg / "roar" / "execution" / "runtime" / "inject"
inject_dir.mkdir(parents=True)
return inject_dir


def test_bundled_abi_tag_finds_pydantic_core_tag(tmp_path: Path) -> None:
inject_dir = _make_inject_layout(
tmp_path,
"pydantic_core",
"_pydantic_core.cpython-313-x86_64-linux-gnu.so",
)
assert bundled_abi_tag(str(inject_dir)) == "cpython-313"


def test_bundled_abi_tag_finds_blake3_tag_when_pydantic_absent(tmp_path: Path) -> None:
inject_dir = _make_inject_layout(
tmp_path,
"blake3",
"_blake3.cpython-312-x86_64-linux-gnu.so",
)
assert bundled_abi_tag(str(inject_dir)) == "cpython-312"


def test_bundled_abi_tag_returns_none_with_no_compiled_deps(tmp_path: Path) -> None:
site_pkg = tmp_path / "site-packages"
inject_dir = site_pkg / "roar" / "execution" / "runtime" / "inject"
inject_dir.mkdir(parents=True)
assert bundled_abi_tag(str(inject_dir)) is None


def test_bundled_abi_tag_returns_none_when_site_packages_absent(tmp_path: Path) -> None:
inject_dir = tmp_path / "some" / "other" / "layout"
inject_dir.mkdir(parents=True)
assert bundled_abi_tag(str(inject_dir)) is None


def test_abi_minor_version_parses_short_form() -> None:
assert abi_minor_version("cp313") == (3, 13)


def test_abi_minor_version_parses_long_form() -> None:
assert abi_minor_version("cpython-312") == (3, 12)


def test_abi_minor_version_handles_missing_or_unparseable() -> None:
assert abi_minor_version(None) is 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
49 changes: 49 additions & 0 deletions tests/execution/runtime/test_runtime_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,52 @@ def test_initialize_selected_backend_initializes_only_configured_backend(
controller.initialize_selected_backend()

assert calls == ["initialize"]


def test_disable_backend_dispatch_short_circuits_initialize_selected_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import roar.execution.framework.runtime_imports as runtime_imports

calls: list[str] = []
fake_backend = _fake_backend(calls)
controller = RuntimeImportController({ROAR_EXECUTION_BACKEND_ENV: "fake"})

monkeypatch.setattr(
runtime_imports,
"get_execution_backend",
lambda backend_name: fake_backend if backend_name == "fake" else None,
)

controller.disable_backend_dispatch()
controller.initialize_selected_backend()

assert calls == []


def test_disable_backend_dispatch_short_circuits_handle_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import roar.execution.framework.runtime_imports as runtime_imports

calls: list[str] = []
fake_backend = _fake_backend(calls)
controller = RuntimeImportController({})

monkeypatch.setattr(
runtime_imports,
"match_execution_backend_for_module",
lambda module_name: fake_backend if module_name == "json" else None,
)
monkeypatch.setattr(
runtime_imports,
"get_execution_backend",
lambda backend_name: fake_backend if backend_name == "fake" else None,
)

controller.disable_backend_dispatch()
result = controller.handle_import("json", __import__("json"))

assert result is None
assert calls == []
assert ROAR_EXECUTION_BACKEND_ENV not in controller._environ
Loading