Skip to content

Commit d7ca0df

Browse files
KraHsuclaude
andcommitted
test: optional-dep import boundary (ROADMAP §9 PR R0.2)
Lock invariant #1 from CLAUDE.md — `import genelab.rl` must succeed without `rsl_rl` / `skrl` / `stable_baselines3` / `tensordict` installed. Library imports are function-local inside each backend. Each of the four load-bearing entry points (`genelab.rl`, `genelab.rl.backends.{rsl_rl,skrl,sb3}`) is imported in a fresh subprocess with the four optional libs poisoned via `sys.modules[name] = None`. A non-zero exit code from the import surfaces any top-level dependency that should have been function- local — that's exactly the regression class R7's importlinter contract (ADR-0009 §Decision contract 1) will catch statically once flipped to blocking. Verified on dev today: all four targets import cleanly under poison. Negative control: `genelab.rl.skrl_models` (which intentionally has a top-level `from skrl.models.torch import ...` and is imported lazily inside `backends/skrl.py:112` only) does correctly fail the mechanism, confirming the test catches real leaks. No production code touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cac4b62 commit d7ca0df

2 files changed

Lines changed: 92 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ trajectory so breaking changes can land in any minor release until the 1.0 stabi
88

99
### Added
1010

11+
- `tests/test_optional_deps.py` — runtime guard for invariant #1
12+
(`import genelab.rl` must succeed without `rsl_rl` / `skrl` /
13+
`stable_baselines3` / `tensordict` installed). Each of the four
14+
load-bearing entry points (`genelab.rl`, `genelab.rl.backends.rsl_rl`,
15+
`genelab.rl.backends.skrl`, `genelab.rl.backends.sb3`) is imported
16+
in a fresh subprocess with the four optional libs poisoned via
17+
`sys.modules[name] = None`; a non-zero exit code surfaces any
18+
top-level import that should have been function-local. Pairs with
19+
the R7 importlinter contract (ADR-0009) which catches the same class
20+
of regression statically. Lands as ROADMAP §9 PR R0.2.
1121
- `tests/test_cli_help_snapshots.py` + `tests/snapshots/help-*.txt` —
1222
frozen baseline of every Typer command's `--help` output (root, cache,
1323
prof, list, info, play, eval, export, train, project, project new). The

‎tests/test_optional_deps.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Optional-dep boundary test (ROADMAP §9 Phase R0.2).
2+
3+
Locks invariant #1 from ``CLAUDE.md``::
4+
5+
import genelab.rl # must succeed without any of:
6+
# rsl_rl, skrl, stable_baselines3, tensordict
7+
# Library imports are function-local inside each backend.
8+
9+
Each test spawns a fresh subprocess, poisons the four optional RL
10+
libraries by setting ``sys.modules[name] = None`` (which makes any
11+
subsequent ``import name`` raise ``ImportError`` per Python's
12+
documented behaviour for sentinel ``None`` entries), then attempts to
13+
import one target module. A non-zero exit code means the target leaks
14+
one of the optional libraries at module-load time and must move the
15+
offending import into a function body before R7 can flip
16+
``import-linter`` from lint-only to blocking.
17+
18+
The four targets are the load-bearing entry points: ``genelab.rl``
19+
itself (re-exports the configs, the backend registry, the runner) plus
20+
each concrete backend module under ``genelab.rl.backends/``. None of
21+
them should pull ``rsl_rl`` / ``skrl`` / ``stable_baselines3`` /
22+
``tensordict`` at module level; library-specific code lives behind
23+
function-local imports inside the backend methods (``train`` /
24+
``play`` / ``make_inference_setup`` and helpers).
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import os
30+
import subprocess
31+
import sys
32+
33+
import pytest
34+
35+
# Optional RL libraries that ``import genelab.rl`` must not pull
36+
# transitively. See ``CLAUDE.md`` §"Architectural invariants" #1.
37+
OPTIONAL_LIBS: tuple[str, ...] = (
38+
"rsl_rl",
39+
"skrl",
40+
"stable_baselines3",
41+
"tensordict",
42+
)
43+
44+
# Modules whose load path must stay clear of the optional libraries.
45+
# ``genelab.rl`` is the public surface end-users import; the three
46+
# backend modules are loaded by ``genelab.rl.backends._ensure_loaded``
47+
# on first ``select_backend()`` call and must themselves import without
48+
# their RL library being installed (library-specific imports happen
49+
# inside method bodies).
50+
TARGETS: tuple[str, ...] = (
51+
"genelab.rl",
52+
"genelab.rl.backends.rsl_rl",
53+
"genelab.rl.backends.skrl",
54+
"genelab.rl.backends.sb3",
55+
)
56+
57+
_WRAPPER = (
58+
"import sys\n"
59+
"for _lib in {libs!r}:\n"
60+
" sys.modules[_lib] = None\n"
61+
"import {target}\n"
62+
)
63+
64+
65+
@pytest.mark.parametrize("target", TARGETS)
66+
def test_import_with_optional_deps_poisoned(target: str) -> None:
67+
"""``import {target}`` must succeed with the four optional RL libs absent."""
68+
code = _WRAPPER.format(libs=OPTIONAL_LIBS, target=target)
69+
proc = subprocess.run(
70+
[sys.executable, "-c", code],
71+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
72+
capture_output=True,
73+
text=True,
74+
check=False,
75+
)
76+
assert proc.returncode == 0, (
77+
f"`import {target}` failed when {list(OPTIONAL_LIBS)} are poisoned.\n"
78+
f"This means one of those libraries is imported at module-load time "
79+
f"somewhere on the import chain — find the offending top-level "
80+
f"`import` and move it into a function body.\n"
81+
f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
82+
)

0 commit comments

Comments
 (0)