Skip to content
Closed
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
41 changes: 34 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,32 @@ dev = [
"black",
"isort",
"flake8",
"pytest>=7.0",
]
# Isaac Sim is NOT installable from PyPI -- it's an Omniverse Kit app.
# The user must install it separately:
# - via NVIDIA Omniverse Launcher (Isaac Sim 2024.x), OR
# - via Isaac Lab: git clone IsaacLab && ./isaaclab.sh -i, OR
# - via Docker: nvcr.io/nvidia/isaac-sim:4.5.0
# We declare lightweight deps that Isaac ships and that ARE pip-installable:
isaac = [
"usd-core>=24.5,<26.0",
"warp-lang>=1.13.0",
"pytest>=7.0",
]
# NOTE: the lightweight `sim` extra (libero / robosuite / mujoco / gymnasium)
# was removed in 0.2.0 that backend now lives in `strands-robots` and is
# was removed in 0.2.0 -- that backend now lives in `strands-robots` and is
# installed via `pip install 'strands-robots[sim-mujoco]'`. Heavy GPU-only
# backends (Isaac Sim, Newton) will land here behind `[isaac]` / `[newton]`
# extras in upcoming releases. See examples/MIGRATION.md and
# https://github.com/strands-labs/robots-sim/issues/8.
all = [
"strands-robots-sim[isaac]",
]

[project.entry-points."strands_robots.backends"]
isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"
isaac_sim = "strands_robots_sim.isaac.simulation:IsaacSimulation"

[project.urls]
Homepage = "https://github.com/strands-labs/robots-sim"
Expand All @@ -68,15 +87,18 @@ dependencies = [
"black",
"isort",
"flake8",
"pytest>=7.0",
]

[tool.hatch.envs.default.scripts]
# No tests until backend code lands in R7 (Isaac) / R11 (Newton). For now
# `hatch run test` is an import smoke that exercises the no-op stub.
test = "python -c \"import strands_robots_sim; print('strands-robots-sim', strands_robots_sim.__version__, 'OK')\""
# Lint extends to `examples/` so the runnable example files (which copy-
# paste into PR docstrings + the README matrix) catch the same drift the
# package source does. Keeps each new R8/R12/R23 backend example honest.
# Lint/format scope extends to `examples/` so the runnable example files
# (which copy-paste into PR docstrings + the README matrix) catch the same
# drift the package source does. Keeps each new R8/R12/R23 backend example
# honest. The `test` script targets isaac/tests/ now that PR-1 of #31's
# split lands the first backend slice with real pytest coverage; superseded
# the prior import-smoke placeholder. PR-4 of the split adds the simulation-
# module tests under the same directory; the glob picks them up automatically.
test = "pytest strands_robots_sim/isaac/tests/ -v"
lint = [
"black --check strands_robots_sim examples",
"isort --check-only strands_robots_sim examples",
Expand All @@ -96,3 +118,8 @@ include = '\.pyi?$'
profile = "black"
line_length = 120
multi_line_output = 3

[tool.pytest.ini_options]
markers = [
"gpu: tests requiring NVIDIA GPU + Isaac Sim (gated on STRANDS_GPU_TEST=1)",
]
58 changes: 58 additions & 0 deletions strands_robots_sim/isaac/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""strands_robots_sim.isaac -- GPU-native Isaac Sim simulation backend.

This subpackage will provide :class:`IsaacSimulation`, a ``SimEngine`` backend
built on **NVIDIA Isaac Sim / Omniverse** for photorealistic rendering,
synthetic data generation, and GPU-batched sensor simulation.

Usage (once :mod:`strands_robots_sim.isaac.simulation` and
:mod:`strands_robots_sim.isaac.config` land in subsequent PRs)::

from strands_robots_sim.isaac import IsaacSimulation, IsaacConfig
config = IsaacConfig(num_envs=1, headless=True)
sim = IsaacSimulation(config)
ok, msg = IsaacSimulation.is_available()

This module ships in PR-1 of the #31 split (see issue #42); the lazy stubs
below are wired to the planned module layout so the `[isaac]` extra and
``strands_robots.backends`` entry points already declared in
``pyproject.toml`` resolve to the right import paths once the simulation +
config modules land in PR-2 / PR-4. ``import strands_robots_sim.isaac`` adds
zero ``omni.*`` modules to ``sys.modules`` -- by design, so a CPU-only CI
host can introspect the entry-point graph without paying any GPU import
cost.

Requires NVIDIA Isaac Sim 2024.x+ (not pip-installable).
Install via Omniverse Launcher or ``nvcr.io/nvidia/isaac-sim:4.5.0``.
"""

from __future__ import annotations

__all__ = ["IsaacSimulation", "IsaacConfig"]


def _lazy_isaac_simulation():
"""Lazy import to avoid pulling omni/Isaac at module-import time."""
from strands_robots_sim.isaac.simulation import IsaacSimulation

return IsaacSimulation


def _lazy_isaac_config():
"""Lazy import to avoid pulling dataclass internals at import time."""
from strands_robots_sim.isaac.config import IsaacConfig

return IsaacConfig


def __getattr__(name: str):
"""PEP 562 lazy attribute access.

Returns the real classes if the corresponding submodule is importable,
otherwise raises ``ImportError`` with a hint pointing at the missing
PR in the #31 split.
"""
if name == "IsaacSimulation":
return _lazy_isaac_simulation()
if name == "IsaacConfig":
return _lazy_isaac_config()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
1 change: 1 addition & 0 deletions strands_robots_sim/isaac/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""strands_robots_sim.isaac.tests package."""
140 changes: 140 additions & 0 deletions strands_robots_sim/isaac/tests/test_entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Entry-point + lazy-import tests for the Isaac backend skeleton.

This is the PR-1 slice of #31 (see issue #42): the `[isaac]` extra,
the ``strands_robots.backends`` entry points, and the PEP 562 lazy
``strands_robots_sim.isaac`` import surface.

Class-level contracts that depend on
``strands_robots_sim.isaac.simulation`` (``SimEngine`` subclassing,
abstract-method completeness, ``is_available()`` return shape, no-GPU
constructor) are covered in PR-4 once that module lands; importing
``IsaacSimulation`` here would create a hard dependency on PR-4 that
defeats the point of the split.

Run with:: pytest strands_robots_sim/isaac/tests/test_entrypoint.py -v
"""

from __future__ import annotations

import importlib.metadata
import pathlib

import pytest

_PYPROJECT = pathlib.Path(__file__).resolve().parents[3] / "pyproject.toml"


class TestEntryPointDeclaration:
"""Validate that ``strands_robots.backends`` entry points are declared."""

def test_pyproject_exists(self):
assert _PYPROJECT.exists(), f"pyproject.toml not found at {_PYPROJECT}"

def test_isaac_entry_point_declared_in_pyproject(self):
"""``isaac`` entry point points at the planned simulation module."""
content = _PYPROJECT.read_text()
assert 'isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"' in content, (
'Expected `isaac = "strands_robots_sim.isaac.simulation:IsaacSimulation"` '
'under [project.entry-points."strands_robots.backends"] in pyproject.toml. '
"PR-4 of the #31 split will land the simulation module the entry point resolves to."
)

def test_isaac_sim_alias_entry_point_declared_in_pyproject(self):
"""``isaac_sim`` alias is declared alongside ``isaac``."""
content = _PYPROJECT.read_text()
assert 'isaac_sim = "strands_robots_sim.isaac.simulation:IsaacSimulation"' in content, (
"Expected `isaac_sim` alias entry point alongside `isaac` so users can write either "
'`create_simulation("isaac")` or `create_simulation("isaac_sim")`.'
)

def test_isaac_extra_declared_in_pyproject(self):
"""``[project.optional-dependencies] isaac = [...]`` extra exists."""
content = _PYPROJECT.read_text()
assert "\nisaac = [" in content or "\nisaac=[" in content, (
"Expected `isaac = [...]` under [project.optional-dependencies] declaring "
"the pip-installable subset of Isaac Sim's runtime deps (usd-core, warp-lang, pytest)."
)

def test_isaac_extra_includes_pytest(self):
"""``[isaac]`` ships pytest so ``pip install '.[isaac]'`` is enough to run the suite."""
content = _PYPROJECT.read_text()
# crude but durable: locate the [isaac] block and check its body
idx = content.find("\nisaac = [")
assert idx != -1, "[isaac] extras block not found"
block_end = content.find("]", idx)
block = content[idx:block_end]
assert "pytest" in block, (
"[isaac] extras must include pytest so `pip install '.[isaac]'` covers the test deps "
"without requiring a separate dev extra on CI hosts."
)

def test_entry_points_visible_via_importlib_metadata_when_installed(self):
"""If the package is pip-installed in this env, entry points are discoverable."""
try:
eps = importlib.metadata.entry_points()
if hasattr(eps, "select"):
backend_eps = list(eps.select(group="strands_robots.backends"))
else:
backend_eps = eps.get("strands_robots.backends", [])
except Exception as exc: # pragma: no cover - defensive
pytest.skip(f"importlib.metadata unavailable: {exc}")

if not backend_eps:
pytest.skip(
"Package not installed (no entry points discoverable). "
"Run `pip install -e .` to validate this assertion locally."
)

names = {ep.name for ep in backend_eps}
if "isaac" not in names and "isaac_sim" not in names:
pytest.skip(
"Package installed but entry-point cache is stale -- reinstall after "
"pyproject.toml change: `pip install -e . --force-reinstall --no-deps`."
)

for ep in backend_eps:
if ep.name in {"isaac", "isaac_sim"}:
assert ep.value == "strands_robots_sim.isaac.simulation:IsaacSimulation", (
f"Entry point {ep.name!r} resolves to {ep.value!r}; expected "
"'strands_robots_sim.isaac.simulation:IsaacSimulation'."
)


class TestLazyImportSurface:
"""Validate the PEP 562 lazy-import contract on the ``isaac`` subpackage."""

def test_import_isaac_does_not_load_omni(self):
"""Importing ``strands_robots_sim.isaac`` adds zero ``omni.*`` modules."""
import sys

before = {k for k in sys.modules if k.startswith("omni")}
import strands_robots_sim.isaac # noqa: F401

added = {k for k in sys.modules if k.startswith("omni")} - before
assert added == set(), (
f"Importing strands_robots_sim.isaac loaded omni modules: {sorted(added)}. "
"The PEP 562 lazy stub must defer `omni` resolution until an attribute is accessed."
)

def test_isaac_subpackage_exposes_lazy_attrs_in___all__(self):
"""``__all__`` advertises the planned public surface."""
import strands_robots_sim.isaac as isaac_pkg

assert "IsaacSimulation" in isaac_pkg.__all__
assert "IsaacConfig" in isaac_pkg.__all__

def test_unknown_attr_raises_attributeerror(self):
"""Unknown attribute access raises AttributeError, not ImportError."""
import strands_robots_sim.isaac as isaac_pkg

with pytest.raises(AttributeError, match="no attribute 'NotARealClass'"):
_ = isaac_pkg.NotARealClass

def test_dunder_getattr_is_present(self):
"""The PEP 562 hook is defined at module level."""
import strands_robots_sim.isaac as isaac_pkg

assert hasattr(
isaac_pkg, "__getattr__"
), "PEP 562 module-level __getattr__ must be defined for lazy import to work."
assert callable(isaac_pkg.__getattr__)
Loading