Skip to content

Commit 2741e42

Browse files
authored
feat(isaac): [isaac] extras + entry-point + PEP 562 lazy import (1/5 of #31 split) (#44)
* feat(isaac): [isaac] extras + entry-point + PEP 562 lazy import (1/5 of #31 split) Part 1 / 5 of the split of #31 — tracked by #42. Adds the package skeleton for the Isaac Sim backend with zero `omni` overhead on `import strands_robots_sim`: - `pyproject.toml`: - new `[isaac]` extra (lightweight Isaac-companion deps that ARE pip-installable: `usd-core>=24.5,<26.0`, `warp-lang>=1.13.0`). Comment explains that Isaac Sim itself is NOT installable from PyPI — must come via Omniverse Launcher / Isaac Lab / nvcr.io. - new `[all]` aggregate extra (currently delegates to `[isaac]`; will gain `[newton]` etc. as later backends land). - new `[project.entry-points."strands_robots.backends"]` declaring both `isaac` and `isaac_sim` aliases pointing at `strands_robots_sim.isaac.simulation:IsaacSimulation` (the class itself lands in PR-4 / `feat(isaac): IsaacSimulation backend`). - new `pytest>=7.0` in `dev` extras and `[tool.hatch.envs.default]` deps so future PRs in this split chain can run pytest under `hatch run test`. - new `[tool.pytest.ini_options]` with a `gpu` marker so PR-4's GPU-only integ tests can gate on `STRANDS_GPU_TEST=1`. - `strands_robots_sim/isaac/__init__.py`: PEP 562 lazy stub — declares `__all__ = ["IsaacSimulation", "IsaacConfig"]` and routes attribute access through `__getattr__` to deferred imports of `.simulation` and `.config`. The bare `import strands_robots_sim.isaac` does NOT import any `omni` modules; that overhead only fires when a caller actually accesses `IsaacSimulation` or `IsaacConfig`. - `strands_robots_sim/isaac/tests/__init__.py`: empty package marker so future test files in this directory are collectable. Test files themselves land in subsequent slices alongside the code they cover. CI signal: lint clean (black / isort / flake8); the existing `hatch run test` import-smoke passes (it doesn't touch `isaac`, so no missing-submodule errors). Pytest doesn't run yet from `hatch run test` — the test command stays as the import-smoke placeholder in this slice and flips to `pytest strands_robots_sim/isaac/tests/` in PR-3 once the first real test files land. Why this lands first: PR-2 / PR-3 / PR-4 all import from `strands_robots_sim.isaac`, so the package stub + extras + entry points have to exist before any of those files can be code-reviewed against current `main`. Original work by @cagataycali in #31 (`413ff15..befb1e3`); this slice cherry-picks just the foundation files. Review-thread anchors on the original commits stay intact for whichever child PR consumes the relevant code. * review(isaac): port 10 entry-point + lazy-import tests from #43 (PR #44 R1) Closes @cagataycali's R1 review on #44 -- the lazy-import contract ships with concrete CI signal now, not just a manual probe in the PR description. Three coordinated changes per the review: 1. **Port `tests/test_entrypoint.py` (140 LOC, 10 tests)** from the closed orphan PR #43 (`feat/isaac-extras-and-lazy-import`). These tests verify *only* PR-1's surface and have **zero dependency on `simulation.py`** (which lands in PR-4 of the #31 split): - `TestEntryPointDeclaration` (6 tests): - `test_pyproject_exists` - `test_isaac_entry_point_declared_in_pyproject` - `test_isaac_sim_alias_entry_point_declared_in_pyproject` - `test_isaac_extra_declared_in_pyproject` - `test_isaac_extra_includes_pytest` - `test_entry_points_visible_via_importlib_metadata_when_installed` (skips with actionable hint when not pip-installed) - `TestLazyImportSurface` (4 tests): - `test_import_isaac_does_not_load_omni` -- pins the `omni`-not- loaded contract via `sys.modules` set diff before/after import. - `test_isaac_subpackage_exposes_lazy_attrs_in___all__` - `test_unknown_attr_raises_attributeerror` -- pins the PEP 562 `__getattr__` raises `AttributeError` (not `ImportError`) on unknown attrs. - `test_dunder_getattr_is_present` 2. **Add `pytest>=7.0` to the `[isaac]` extra** in `pyproject.toml`. Means `pip install '.[isaac]'` covers the test deps without a separate dev extra on CI hosts. Also pinned by `test_isaac_extra_includes_pytest`. 3. **Flip `hatch run test`** from the import-smoke placeholder (`python -c "import strands_robots_sim; ..."`) to `pytest strands_robots_sim/isaac/tests/ -v`. The 10 tests collect and pass standalone; PR-4's simulation-module tests join the collection automatically when that PR rebases. Verification: - `pytest strands_robots_sim/isaac/tests/ -v` -> **10 passed in 0.14s** - `black --check` / `isort --check-only` / `flake8` (max-line=120) on `strands_robots_sim/` + `examples/` -> clean - AST parse on touched files -> ok - The 10 tests have no import path through `simulation.py`; they validate only PR-1's actual surface (pyproject parsing, entry- point declaration, PEP 562 `__getattr__` contract). The original `test_entrypoint.py` on `cagataycali/feat/isaac-sim` imports from `strands_robots_sim.isaac.simulation`, which is why PR-1 of the split couldn't ship that file unmodified. The version ported here was rewritten on the #43 branch specifically for the PR-1 surface; cleanly self-contained against this slice.
1 parent 4d82bcf commit 2741e42

4 files changed

Lines changed: 215 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,32 @@ dev = [
4141
"black",
4242
"isort",
4343
"flake8",
44+
"pytest>=7.0",
45+
]
46+
# Isaac Sim is NOT installable from PyPI -- it's an Omniverse Kit app.
47+
# The user must install it separately:
48+
# - via NVIDIA Omniverse Launcher (Isaac Sim 2024.x), OR
49+
# - via Isaac Lab: git clone IsaacLab && ./isaaclab.sh -i, OR
50+
# - via Docker: nvcr.io/nvidia/isaac-sim:4.5.0
51+
# We declare lightweight deps that Isaac ships and that ARE pip-installable:
52+
isaac = [
53+
"usd-core>=24.5,<26.0",
54+
"warp-lang>=1.13.0",
55+
"pytest>=7.0",
4456
]
4557
# NOTE: the lightweight `sim` extra (libero / robosuite / mujoco / gymnasium)
4658
# was removed in 0.2.0 — that backend now lives in `strands-robots` and is
4759
# installed via `pip install 'strands-robots[sim-mujoco]'`. Heavy GPU-only
4860
# backends (Isaac Sim, Newton) will land here behind `[isaac]` / `[newton]`
4961
# extras in upcoming releases. See examples/MIGRATION.md and
5062
# https://github.com/strands-labs/robots-sim/issues/8.
63+
all = [
64+
"strands-robots-sim[isaac]",
65+
]
66+
67+
[project.entry-points."strands_robots.backends"]
68+
isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"
69+
isaac_sim = "strands_robots_sim.isaac.simulation:IsaacSimulation"
5170

5271
[project.urls]
5372
Homepage = "https://github.com/strands-labs/robots-sim"
@@ -68,12 +87,16 @@ dependencies = [
6887
"black",
6988
"isort",
7089
"flake8",
90+
"pytest>=7.0",
7191
]
7292

7393
[tool.hatch.envs.default.scripts]
74-
# No tests until backend code lands in R7 (Isaac) / R11 (Newton). For now
75-
# `hatch run test` is an import smoke that exercises the no-op stub.
76-
test = "python -c \"import strands_robots_sim; print('strands-robots-sim', strands_robots_sim.__version__, 'OK')\""
94+
# `hatch run test` runs the Isaac PR-1 entry-point + lazy-import test
95+
# slice (10 tests; pyproject parsing + importlib.metadata discovery +
96+
# PEP 562 lazy `__getattr__` contract). PR-4 of the #31 split adds the
97+
# simulation-module tests under the same directory; the glob picks
98+
# them up automatically.
99+
test = "pytest strands_robots_sim/isaac/tests/ -v"
77100
# Lint extends to `examples/` so the runnable example files (which copy-
78101
# paste into PR docstrings + the README matrix) catch the same drift the
79102
# package source does. Keeps each new R8/R12/R23 backend example honest.
@@ -96,3 +119,8 @@ include = '\.pyi?$'
96119
profile = "black"
97120
line_length = 120
98121
multi_line_output = 3
122+
123+
[tool.pytest.ini_options]
124+
markers = [
125+
"gpu: tests requiring NVIDIA GPU + Isaac Sim (gated on STRANDS_GPU_TEST=1)",
126+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""strands_robots_sim.isaac -- GPU-native Isaac Sim simulation backend.
2+
3+
This subpackage provides :class:`IsaacSimulation`, a ``SimEngine`` backend
4+
built on **NVIDIA Isaac Sim / Omniverse** for photorealistic rendering,
5+
synthetic data generation, and GPU-batched sensor simulation.
6+
7+
Usage::
8+
9+
from strands_robots_sim.isaac import IsaacSimulation, IsaacConfig
10+
config = IsaacConfig(num_envs=1, headless=True)
11+
sim = IsaacSimulation(config)
12+
ok, msg = IsaacSimulation.is_available()
13+
14+
Requires NVIDIA Isaac Sim 2024.x+ (not pip-installable).
15+
Install via Omniverse Launcher or ``nvcr.io/nvidia/isaac-sim:4.5.0``.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
__all__ = ["IsaacSimulation", "IsaacConfig"]
21+
22+
23+
def _lazy_isaac_simulation():
24+
"""Lazy import to avoid pulling omni/Isaac at module-import time."""
25+
from strands_robots_sim.isaac.simulation import IsaacSimulation
26+
27+
return IsaacSimulation
28+
29+
30+
def _lazy_isaac_config():
31+
"""Lazy import to avoid pulling dataclass internals at import time."""
32+
from strands_robots_sim.isaac.config import IsaacConfig
33+
34+
return IsaacConfig
35+
36+
37+
def __getattr__(name: str):
38+
"""PEP 562 lazy attribute access."""
39+
if name == "IsaacSimulation":
40+
return _lazy_isaac_simulation()
41+
if name == "IsaacConfig":
42+
return _lazy_isaac_config()
43+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""strands_robots_sim.isaac.tests package."""
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Entry-point + lazy-import tests for the Isaac backend skeleton.
2+
3+
This is the PR-1 slice of #31 (see issue #42): the `[isaac]` extra,
4+
the ``strands_robots.backends`` entry points, and the PEP 562 lazy
5+
``strands_robots_sim.isaac`` import surface.
6+
7+
Class-level contracts that depend on
8+
``strands_robots_sim.isaac.simulation`` (``SimEngine`` subclassing,
9+
abstract-method completeness, ``is_available()`` return shape, no-GPU
10+
constructor) are covered in PR-4 once that module lands; importing
11+
``IsaacSimulation`` here would create a hard dependency on PR-4 that
12+
defeats the point of the split.
13+
14+
Run with:: pytest strands_robots_sim/isaac/tests/test_entrypoint.py -v
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import importlib.metadata
20+
import pathlib
21+
22+
import pytest
23+
24+
_PYPROJECT = pathlib.Path(__file__).resolve().parents[3] / "pyproject.toml"
25+
26+
27+
class TestEntryPointDeclaration:
28+
"""Validate that ``strands_robots.backends`` entry points are declared."""
29+
30+
def test_pyproject_exists(self):
31+
assert _PYPROJECT.exists(), f"pyproject.toml not found at {_PYPROJECT}"
32+
33+
def test_isaac_entry_point_declared_in_pyproject(self):
34+
"""``isaac`` entry point points at the planned simulation module."""
35+
content = _PYPROJECT.read_text()
36+
assert 'isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"' in content, (
37+
'Expected `isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"` '
38+
'under [project.entry-points."strands_robots.backends"] in pyproject.toml. '
39+
"PR-4 of the #31 split will land the simulation module the entry point resolves to."
40+
)
41+
42+
def test_isaac_sim_alias_entry_point_declared_in_pyproject(self):
43+
"""``isaac_sim`` alias is declared alongside ``isaac``."""
44+
content = _PYPROJECT.read_text()
45+
assert 'isaac_sim = "strands_robots_sim.isaac.simulation:IsaacSimulation"' in content, (
46+
"Expected `isaac_sim` alias entry point alongside `isaac` so users can write either "
47+
'`create_simulation("isaac")` or `create_simulation("isaac_sim")`.'
48+
)
49+
50+
def test_isaac_extra_declared_in_pyproject(self):
51+
"""``[project.optional-dependencies] isaac = [...]`` extra exists."""
52+
content = _PYPROJECT.read_text()
53+
assert "\nisaac = [" in content or "\nisaac=[" in content, (
54+
"Expected `isaac = [...]` under [project.optional-dependencies] declaring "
55+
"the pip-installable subset of Isaac Sim's runtime deps (usd-core, warp-lang, pytest)."
56+
)
57+
58+
def test_isaac_extra_includes_pytest(self):
59+
"""``[isaac]`` ships pytest so ``pip install '.[isaac]'`` is enough to run the suite."""
60+
content = _PYPROJECT.read_text()
61+
# crude but durable: locate the [isaac] block and check its body
62+
idx = content.find("\nisaac = [")
63+
assert idx != -1, "[isaac] extras block not found"
64+
block_end = content.find("]", idx)
65+
block = content[idx:block_end]
66+
assert "pytest" in block, (
67+
"[isaac] extras must include pytest so `pip install '.[isaac]'` covers the test deps "
68+
"without requiring a separate dev extra on CI hosts."
69+
)
70+
71+
def test_entry_points_visible_via_importlib_metadata_when_installed(self):
72+
"""If the package is pip-installed in this env, entry points are discoverable."""
73+
try:
74+
eps = importlib.metadata.entry_points()
75+
if hasattr(eps, "select"):
76+
backend_eps = list(eps.select(group="strands_robots.backends"))
77+
else:
78+
backend_eps = eps.get("strands_robots.backends", [])
79+
except Exception as exc: # pragma: no cover - defensive
80+
pytest.skip(f"importlib.metadata unavailable: {exc}")
81+
82+
if not backend_eps:
83+
pytest.skip(
84+
"Package not installed (no entry points discoverable). "
85+
"Run `pip install -e .` to validate this assertion locally."
86+
)
87+
88+
names = {ep.name for ep in backend_eps}
89+
if "isaac" not in names and "isaac_sim" not in names:
90+
pytest.skip(
91+
"Package installed but entry-point cache is stale -- reinstall after "
92+
"pyproject.toml change: `pip install -e . --force-reinstall --no-deps`."
93+
)
94+
95+
for ep in backend_eps:
96+
if ep.name in {"isaac", "isaac_sim"}:
97+
assert ep.value == "strands_robots_sim.isaac.simulation:IsaacSimulation", (
98+
f"Entry point {ep.name!r} resolves to {ep.value!r}; expected "
99+
"'strands_robots_sim.isaac.simulation:IsaacSimulation'."
100+
)
101+
102+
103+
class TestLazyImportSurface:
104+
"""Validate the PEP 562 lazy-import contract on the ``isaac`` subpackage."""
105+
106+
def test_import_isaac_does_not_load_omni(self):
107+
"""Importing ``strands_robots_sim.isaac`` adds zero ``omni.*`` modules."""
108+
import sys
109+
110+
before = {k for k in sys.modules if k.startswith("omni")}
111+
import strands_robots_sim.isaac # noqa: F401
112+
113+
added = {k for k in sys.modules if k.startswith("omni")} - before
114+
assert added == set(), (
115+
f"Importing strands_robots_sim.isaac loaded omni modules: {sorted(added)}. "
116+
"The PEP 562 lazy stub must defer `omni` resolution until an attribute is accessed."
117+
)
118+
119+
def test_isaac_subpackage_exposes_lazy_attrs_in___all__(self):
120+
"""``__all__`` advertises the planned public surface."""
121+
import strands_robots_sim.isaac as isaac_pkg
122+
123+
assert "IsaacSimulation" in isaac_pkg.__all__
124+
assert "IsaacConfig" in isaac_pkg.__all__
125+
126+
def test_unknown_attr_raises_attributeerror(self):
127+
"""Unknown attribute access raises AttributeError, not ImportError."""
128+
import strands_robots_sim.isaac as isaac_pkg
129+
130+
with pytest.raises(AttributeError, match="no attribute 'NotARealClass'"):
131+
_ = isaac_pkg.NotARealClass
132+
133+
def test_dunder_getattr_is_present(self):
134+
"""The PEP 562 hook is defined at module level."""
135+
import strands_robots_sim.isaac as isaac_pkg
136+
137+
assert hasattr(
138+
isaac_pkg, "__getattr__"
139+
), "PEP 562 module-level __getattr__ must be defined for lazy import to work."
140+
assert callable(isaac_pkg.__getattr__)

0 commit comments

Comments
 (0)