[Fix] Defer pxr loading in isaaclab.sim, .assets, .scene, and .sim/utils#5826
[Fix] Defer pxr loading in isaaclab.sim, .assets, .scene, and .sim/utils#5826hujc7 wants to merge 8 commits into
Conversation
Importing AssetBase / BaseArticulation forced pxr (USD) and downstream omni.* extensions to load at module-import time. That ordering broke conditional AppLauncher() patterns: any user script doing `from isaaclab.assets import Articulation` at module top loaded pxr before Kit, and Kit's later USD extension registration then failed with a cascade of "extension class wrapper for base class pxr...::TfNotice has not been created yet" errors and a SEGV. Three changes: * asset_base.py: move `from pxr import Usd` under TYPE_CHECKING; drop module-top `from isaaclab.sim.simulation_context import SimulationContext` and `from isaaclab.sim.utils.stage import get_current_stage`. Call sites use `sim_utils.SimulationContext.instance()` and `sim_utils.get_current_stage()` via the already-lazy isaaclab.sim package, so pxr is loaded on first runtime access instead of at import. * base_articulation.py: defer `from ...sim import SimulationContext` to the body of __init__ (its only use site). `from isaaclab.assets import Articulation` no longer adds pxr / omni / carb / isaacsim to sys.modules. A full smoke test that imports asset classes at module top, then launches AppLauncher, then constructs an Articulation now succeeds end-to-end.
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot
Summary
This PR defers pxr loading in asset base classes to runtime, unblocking workflows that import isaaclab.assets before launching Kit. The implementation correctly leverages the existing lazy-loading infrastructure in isaaclab.sim to achieve deferred loading without introducing new complexity.
Design Assessment
Design is sound. The approach is minimal and surgical:
- Moving
from pxr import UsdunderTYPE_CHECKINGpreserves type hints while deferring the actual import - Using
sim_utils.SimulationContext.instance()instead of direct imports leverages the existinglazy_loaderinfrastructure - Deferring the
SimulationContextimport to__init__inbase_articulation.pyis appropriate since that's the only call site
The PR description is exemplary — it clearly explains the mechanism, provides verification steps, and acknowledges follow-up work for sensors.
Findings
🔵 Suggestion: Consider documenting the lazy-loading pattern — source/isaaclab/isaaclab/assets/asset_base.py
The TYPE_CHECKING pattern for deferring pxr imports could benefit from a brief inline comment explaining why it's structured this way. Future contributors unfamiliar with the import ordering constraints might inadvertently revert to direct imports.
if TYPE_CHECKING:
# pxr is imported under TYPE_CHECKING to defer loading until runtime.
# This allows `from isaaclab.assets import Articulation` before AppLauncher.
from pxr import UsdTest Coverage
- Bug fix: This is a fix for import-time side effects
- Regression test: The PR description mentions manual verification steps, and references
test_env_cfg_no_forbidden_imports.pyas a potential extension. The existing articulation tests should catch any runtime regressions. - Gaps: Consider adding a test that explicitly validates
from isaaclab.assets import Articulationdoesn't triggerpxrloading (as mentioned in "Out of scope / follow-ups" section 4)
CI Status
Several checks still in progress (Build Base Docker Image, Installation Tests). Core checks (pre-commit, changelog, Build Wheel) have passed.
Verdict
No issues found
Clean implementation with proper lazy-loading patterns. The changes are minimal, correct, and well-documented. The only suggestion is adding a clarifying comment for maintainability.
Pushes the pxr deferral down to the producer module. simulation_context.py no longer does `from pxr import Gf, Usd, UsdGeom, UsdPhysics, UsdUtils` at module top; instead `__init__` does a local `from pxr import UsdUtils` and `_init_usd_physics_scene` does a local `from pxr import Gf, UsdGeom, UsdPhysics`. The `Usd` annotation on `_predicate(prim: Usd.Prim)` stays typed via the existing `from __future__ import annotations` (already at line 6) + a TYPE_CHECKING import. stage_utils, create_new_stage, and close_stage are reached via sim_utils.X / a function-local `from isaaclab.sim.utils import stage` so simulation_context.py's module-top imports no longer transitively load pxr through stage.py either. With this in place, `from isaaclab.sim import SimulationContext` is pxr-free at module top, which means the earlier `base_articulation.py:__init__`-local `from ...sim import SimulationContext` workaround is no longer needed -- reverted to the original module-top form.
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot
Update: This review reflects the expanded scope (now 10 files, ~155 LOC) since the initial review on commit
80b86a01.
Summary
This PR addresses NVBug 6121889 by deferring pxr (USD) imports that were loading at module-import time, which broke Kit's USD binding registration when AppLauncher started after env-cfg parsing. The fix extends lazy-loading to isaaclab.sim, .assets, .scene, and .sim/utils modules.
Architecture & Design ✅
The approach of using lazy_export() with .pyi stubs and TYPE_CHECKING guards is well-designed and maintains type-safety while achieving the deferred import behavior. The pattern is consistent across all modified modules.
Implementation Quality
Positive aspects:
- Clean, consistent pattern applied across all affected modules
- Type hints preserved via
TYPE_CHECKINGblocks - Proper
noqa: F401annotations to suppress unused import warnings from type-only imports
No Blocking Issues Found
The implementation appears correct and follows best practices for deferred imports in Python.
Update (2573abe):
The author has introduced a cleaner architecture with the new lazy_imports() utility function:
- New
_LazyModuleproxy class — Creates transparent proxies that import on first attribute access and self-replace in caller globals lazy_imports(package, names)API — One-liner that binds multiple lazy submodules; returnsNone(side-effect-only API keeps call sites clean)- Comprehensive test suite — 12 pytest tests covering: no-preload guarantees, per-name isolation, self-replacement semantics, error handling for missing submodules, caller isolation between modules, and multi-call composition
- Enhanced changelog — Now documents both the new utility (Added section) and provides detailed technical context for the fix
This is a significant improvement over the previous commit's ad-hoc approach. The lazy_imports() + _LazyModule pattern is well-documented, properly encapsulated, and thoroughly tested. Nice work! 👍
Update (28514a4):
Minor cleanup commit — moves TYPE_CHECKING imports and lazy_imports() calls to proper positions in several isaaclab.sim.utils.* modules:
queries.py,semantics.py,stage.py,transforms.py: Added missingTYPE_CHECKINGimport, ensuredlazy_imports()is called before any code that might reference the lazy-bound namesinteractive_scene.py: Moved theTYPE_CHECKINGblock belowlazy_imports()call (correct ordering)
No functional changes — just import ordering fixes to ensure the lazy-import pattern works correctly. LGTM! ✅
Update (a82e275):
This commit improves the _LazyModule proxy class with better robustness and documentation:
-
Documentation enhancement — Added a note explaining that attribute assignment on the proxy is blocked by
__slots__before first access. Suggests either triggering first access first, or patchingsys.modules[fully_qualified_name]directly for testing scenarios. -
Defensive
__getattr__fix — The self-replace logic now usessys.modules.get()instead of direct indexing, gracefully handling cases where the owner module was evicted fromsys.modules(e.g., viaimportlib.reloador test cleanup withsys.modules.pop()). The proxy continues to work correctly without the native-lookup speedup. -
New test case —
test_lazy_module_owner_evicted_before_first_access()validates that the proxy doesn't crash withKeyErrorwhen the owner module is removed before first attribute access. -
Docstring ordering — Swapped the
lazy_imports()call before theTYPE_CHECKINGblock in the example, reflecting best practice.
Solid defensive improvement for edge cases. LGTM! ✅
Update (701cc34):
Significant simplification! The author removed the lazy_imports() / _LazyModule infrastructure in favor of direct local imports inside function bodies:
Changes:
- ❌ Removed
lazy_imports()function and_LazyModuleclass (~100 LOC) - ❌ Removed
test_module.pytest file (~265 LOC) - ✅ All
pxrimports now use explicitfrom pxr import X # noqa: PLC0415inside each function that needs them - ✅ Changelog updated to reflect the simpler approach
Assessment:
This is a good architectural pivot. The local-import pattern is:
- Simpler — no custom proxy classes to understand
- Explicit — every function shows exactly what it needs
- Standard Python —
PLC0415(import-outside-toplevel) is a well-known pattern for deferred imports
Trade-off: slightly more import statements scattered across functions vs. maintaining custom lazy-loading machinery. For this use case (deferring a handful of pxr imports), the explicit approach wins on maintainability.
LGTM! ✅
Update (dd576b0):
Minimal-diff cleanup! This commit reverts incidental import reroutes that were no longer needed:
Changes in asset_base.py:
- Now imports
SimulationContextandget_current_stagedirectly at module top, instead of accessing them viasim_utils.SimulationContext/sim_utils.get_current_stage() - This is cleaner — the module directly expresses its dependencies rather than going through the lazy namespace indirection
Changes in simulation_context.py:
- Uses
stage_utils.close_stage()directly instead ofsim_utils.close_stage() - Uses
create_new_stagedirectly from the explicit import, not throughsim_utils - The
build_simulation_context()context manager now correctly uses the locally-importedcreate_new_stagefunction
Rationale (from commit message):
The pxr fix only requires deferring the foreign
from pxr import ...statements; it does not require changing how these two modules import their own isaaclab dependencies.
This is exactly right. The earlier commits had rerouted isaaclab-internal imports through the lazy sim_utils namespace as a workaround during development. Now that pxr is deferred everywhere, those reroutes are unnecessary overhead. Reverting them keeps the diff scoped to the actual bug fix.
LGTM! ✅ Nice attention to minimal, focused changes.
Update (3ca9899):
Bug fix for name shadowing in build_simulation_context():
- In
simulation_context.py, the function parametercreate_new_stage: boolwas shadowing the importedcreate_new_stagefunction - Changed call from
create_new_stage()tosim_utils.create_new_stage()to access the function through the namespace - Added clarifying comment explaining the shadowing issue
This is a necessary fix — without it, build_simulation_context(create_new_stage=True) would fail because Python would try to call the boolean parameter instead of the function.
LGTM! ✅
Update (1b53736):
Changelog wording refinement only. The .rst file now uses more concise phrasing:
- "Each affected module keeps its type hints under
TYPE_CHECKINGand defers the runtime import" → "Type hints stay underTYPE_CHECKING; where pxr is used at runtime, the import is deferred into the function body"
No code changes, just documentation polish. LGTM! ✅
73dab50 to
2573abe
Compare
Module-import time `from pxr import ...` in scene/interactive_scene, sim/spawners/from_files, sim/utils/prims, sim/utils/queries, sim/utils/semantics, sim/utils/stage, and sim/utils/transforms still forced pxr into sys.modules during env-cfg parse. When AppLauncher later started SimulationApp, Kit's USD binding registration failed with TfNotice / UsdAPISchemaBase / GfVec3f converter errors. This change moves the pxr imports under TYPE_CHECKING and adds an idempotent `_load_pxr()` helper (or function-local `from pxr import ...`) inside each public entry point so pxr is only loaded after AppLauncher has booted Kit. In `isaaclab.sim.utils.stage`, the `isaacsim.core.experimental.utils.stage._context` bridge is also deferred into `_ensure_simstage_bridge()` so the kitless path no longer pulls isaacsim eagerly. Verified with the QA repro (NVBug 6121889): `Isaac-Cartpole-Direct-Warp-v0 + --visualizer kit` no longer triggers the "Modules: ['pxr', ...] were loaded before SimulationApp was started" warning and AppLauncher.startup completes cleanly.
2573abe to
28514a4
Compare
Greptile SummaryThis PR fixes a crash triggered when env-cfg parsing (which runs before
Confidence Score: 4/5Safe to merge — the change is purely additive (new helper, proxy wrappers) with no functional changes to any function bodies or business logic. The core mechanism is well-tested and the root cause fix is correct. The findings are edge-case robustness gaps in the new source/isaaclab/isaaclab/utils/module.py — the new Important Files Changed
Sequence DiagramsequenceDiagram
participant EC as env-cfg parse
participant IA as isaaclab.assets
participant AB as asset_base.py
participant SU as isaaclab.sim (lazy_export)
participant SG as sim/utils/stage.py
participant LI as lazy_imports proxy
participant PXR as pxr (USD)
participant AL as AppLauncher / SimulationApp
Note over EC,AL: BEFORE this PR — crash path
EC->>IA: from isaaclab.assets import Articulation
IA->>AB: import asset_base
AB->>PXR: from pxr import Usd ← module-top import
PXR-->>AB: pxr loaded into sys.modules
EC->>AL: AppLauncher.__init__()
AL->>AL: SimulationApp.startup()
AL-->>AL: ❌ pxr already initialised — TfNotice / GfVec3f cascade
Note over EC,AL: AFTER this PR — fixed path
EC->>IA: from isaaclab.assets import Articulation
IA->>AB: import asset_base
AB->>SU: import isaaclab.sim as sim_utils (lazy_export, no pxr)
AB->>SG: stage.py lazy_imports proxy bound (pxr NOT loaded)
SG->>LI: _LazyModule stored in module globals
EC->>AL: AppLauncher.__init__()
AL->>AL: SimulationApp.startup() ✅ pxr not in sys.modules
AL->>PXR: Kit registers USD bindings cleanly
AL-->>AL: SimulationContext.__init__() called
AL->>PXR: from pxr import UsdUtils ← first actual pxr load
LI->>PXR: _LazyModule.__getattr__ → importlib.import_module
PXR-->>LI: real module returned, proxy self-replaced in globals
Reviews (1): Last reviewed commit: "Extend pxr lazy-load to scene, spawners,..." | Re-trigger Greptile |
| def __getattr__(self, attr: str): | ||
| real = importlib.import_module(self._full_name) | ||
| setattr(sys.modules[self._owner_mod_name], self._local_name, real) | ||
| return getattr(real, attr) |
There was a problem hiding this comment.
KeyError instead of AttributeError when owner module is evicted from sys.modules
If the owner module is removed from sys.modules (e.g. during a importlib.reload cycle or a test that does sys.modules.pop(name)) between proxy construction and first attribute access, sys.modules[self._owner_mod_name] raises a bare KeyError. Callers see a confusing KeyError: '__some_internal_module__' instead of an AttributeError or the real ModuleNotFoundError. The self-replacement optimization can safely be skipped in this case — the proxy will still return the correct attribute from the real module on every call via __getattr__.
Using sys.modules.get() instead avoids the error without changing observable behaviour for the common path.
| Example: | ||
| :: | ||
|
|
||
| from isaaclab.utils.module import lazy_imports | ||
|
|
||
| if TYPE_CHECKING: | ||
| from pxr import Sdf, Usd, UsdGeom # noqa: F401 | ||
|
|
||
| lazy_imports("pxr", ["Sdf", "Usd", "UsdGeom"]) |
There was a problem hiding this comment.
lazy_imports docstring example reverses the convention used at every call site
The docstring shows if TYPE_CHECKING: placed before the lazy_imports() call, but all 9 call sites in this PR place it after (and the PR description itself shows the same "after" order). A developer copying from the docstring as a template would produce code with the opposite ordering.
| Example: | |
| :: | |
| from isaaclab.utils.module import lazy_imports | |
| if TYPE_CHECKING: | |
| from pxr import Sdf, Usd, UsdGeom # noqa: F401 | |
| lazy_imports("pxr", ["Sdf", "Usd", "UsdGeom"]) | |
| Example: | |
| :: | |
| from isaaclab.utils.module import lazy_imports | |
| lazy_imports("pxr", ["Sdf", "Usd", "UsdGeom"]) | |
| if TYPE_CHECKING: | |
| from pxr import Sdf, Usd, UsdGeom # noqa: F401 |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| class _LazyModule: | ||
| """Proxy that imports its target on first attribute access. | ||
|
|
||
| On the first ``proxy.X`` access, this imports the real submodule via | ||
| ``importlib.import_module`` AND rebinds the same local name in the | ||
| owning module's globals to the real module. Subsequent ``proxy.X`` | ||
| accesses go straight to the real module — no proxy hop, native module- | ||
| attribute lookup. | ||
|
|
||
| Constructed by :func:`lazy_imports`; not meant to be instantiated by | ||
| hand. | ||
| """ | ||
|
|
||
| __slots__ = ("_full_name", "_local_name", "_owner_mod_name") | ||
|
|
||
| def __init__(self, full_name: str, local_name: str, owner_mod_name: str): | ||
| object.__setattr__(self, "_full_name", full_name) | ||
| object.__setattr__(self, "_local_name", local_name) | ||
| object.__setattr__(self, "_owner_mod_name", owner_mod_name) | ||
|
|
||
| def __getattr__(self, attr: str): | ||
| real = importlib.import_module(self._full_name) | ||
| setattr(sys.modules[self._owner_mod_name], self._local_name, real) | ||
| return getattr(real, attr) |
There was a problem hiding this comment.
_LazyModule silently drops __setattr__ calls before first access
__slots__ prevents arbitrary attribute writes on the proxy, so any code that tries to assign to a lazy-module name before the real module is loaded — e.g., a mock that patches module.Sdf — will raise AttributeError: '_LazyModule' object has no attribute 'X' rather than transparently forwarding the write to the real module. The current use patterns (read-only accesses inside function bodies) are not affected, but this is an undocumented limitation worth calling out in the class docstring for future adopters.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
* `_LazyModule.__getattr__`: fall back gracefully when the owner module is evicted from `sys.modules` (e.g. during `importlib.reload` or a test that does `sys.modules.pop`). Skips the self-replace step in that case; the proxy still resolves attributes correctly via `__getattr__` on every subsequent call. * `lazy_imports` docstring: reorder the example so the `lazy_imports` call appears before the `if TYPE_CHECKING:` block, matching the convention used at every call site in this PR. * `_LazyModule` docstring: note that attribute assignment on the proxy before first access is unsupported (`__slots__` blocks writes); callers wishing to monkeypatch should access an attribute first (triggering self-replace) or patch `sys.modules`. * New test `test_lazy_module_owner_evicted_before_first_access` exercises the eviction fallback.
Defer pxr (USD) imports in the nine modules reached by env-cfg parse by moving each `from pxr import ...` into the function body that uses it, rather than through the dedicated lazy_imports()/_LazyModule proxy. Function-local imports are the idiomatic pattern already used across the repo (the existing PLC0415 noqa sites) and give the same guarantee -- pxr is never pulled into sys.modules at import time -- without adding a bespoke public helper, a proxy class, or a separate test module. Drop the now-unused helper and its unit tests accordingly. Type hints stay under TYPE_CHECKING so static checkers and IDEs still resolve Usd.Stage, Sdf.Path, etc.
The pxr fix only requires deferring the foreign `from pxr import ...` statements; it does not require changing how these two modules import their own isaaclab dependencies. Earlier commits rerouted asset_base and SimulationContext through the lazy `isaaclab.sim` namespace (`sim_utils.X`) and inlined the stage import, which was a workaround for an intermediate state where SimulationContext still imported pxr at module top. Now that pxr is deferred everywhere, those reroutes are unnecessary, so restore the module-top isaaclab imports to keep the diff scoped to the pxr deferral only. Verified: no circular import, no pxr leak in env-cfg parse, and the forbidden-imports guard still passes.
|
what's the main reason why we can't import pxr before these modules? |
Kit registers a specialized pxr such that, if pxr is imported before appLaunch, the later kit registration will have problems. The crashing behavior could be different but it seems the root cause is the same. |
The previous commit's blanket rename of `sim_utils.create_new_stage()` to `create_new_stage()` also rewrote the call inside `build_simulation_context`, where the bool parameter `create_new_stage` shadows the module-level function. That made the call target the bool, raising `TypeError: 'bool' object is not callable` at runtime and failing every package test that uses this helper. Call it via `sim_utils.create_new_stage()` at that site (matching develop); the unshadowed `__init__` calls keep the bare name.
1. Summary
--visualizer kitcrashes duringSimulationApp.startupwith aTfNotice/UsdAPISchemaBase/GfVec3fconverter cascade.from pxr import …in IsaacLab library code. Env-cfg parse runs beforeAppLauncherand transitively pullspxrintosys.modules, so Kit's USD-binding registration fails against an already partially-initializedpxr.pxrinto the function bodies that use it across the 9 modules on the env-cfg parse path. 0pxr/omni/carb/isaacsimmodules now leak across all 197Isaac-*env-cfg parses (the Cartpole path previously pulled in 63pxr/omni/isaacsimmodules).2. Mechanism
2.1 Per-file pattern
Each module-top
from pxr import …is removed. The runtime import moves into each function that uses USD; type hints stay underTYPE_CHECKINGso static checkers and IDE autocomplete keep resolvingUsd.Stage,Sdf.Path, etc. against the real classes.# noqa: PLC0415matches the function-local import sites already present across the repo; theTYPE_CHECKINGguard matches the 200+ files already usingif TYPE_CHECKING: from X import Y.2.2 Affected modules
isaaclab.sim.simulation_contextGf, UsdGeom, UsdPhysics, UsdUtilsisaaclab.assets.asset_baseUsd(type-only — no runtime pxr use)isaaclab.scene.interactive_sceneSdfisaaclab.sim.spawners.from_files.from_filesGf, Sdf, UsdGeomisaaclab.sim.utils.primsSdf, Usd, UsdGeom, UsdPhysics, UsdShade, UsdUtilsisaaclab.sim.utils.queriesSdf, Usd, UsdPhysicsisaaclab.sim.utils.semanticsUsd, UsdGeom, UsdSemanticsisaaclab.sim.utils.stageSdf, Usd, UsdUtilsisaaclab.sim.utils.transformsGf, Sdf, Usd, UsdGeom3. Verification
source/isaaclab_tasks/test/test_env_cfg_no_forbidden_imports.py(existing integration guard): all 197Isaac-*tasks parse their env-cfg with 0pxr/omni/carb/isaacsim/scipyimports.pxrsymbol use in the 9 modules is covered by a function-localfrom pxr import …; type-only uses remain underTYPE_CHECKING(no module-scopepxrreference remains)../isaaclab.sh -fclean.4. Test plan
./isaaclab.sh -fclean.test_env_cfg_no_forbidden_imports.py— 197 tasks, no backend leak.--visualizer kit: env-cfg parse loads 0pxrmodules;SimulationApp.startupproceeds without theTfNotice/GfVec3fcascade.pxrstill imports on first use.5. Out of scope / follow-ups
~32 more files in
isaaclab/,isaaclab_newton/,isaaclab_physx/stillfrom pxr import …at module top. They are not on the Cartpole-Warp env-cfg parse path so they don't block this fix, but should be converted the same way as the bug class will resurface for new envs that touch them:cloner/cloner_utils,sim/spawners/{lights,materials,meshes,shapes,sensors,wrappers},sim/schemas/schemas,sim/utils/{legacy,newton_model_utils},sim/views/usd_frame_view,isaaclab_newton/{assets,cloner,sensors,sim/views}.isaaclab_physx/*.sensors/{camera,ray_caster},envs/mdp/actions/task_space_actions,envs/ui/base_env_window,ui/xr_widgets,sim/converters/mesh_converter,utils/mesh.