Skip to content

Commit cf4bfe0

Browse files
Fix lazy physics preset imports
Add a lazy preset helper so config alternatives can defer backend imports until a preset is selected. Use it for direct task OV-PhysX physics presets and keep locomotion gear selection independent of the optional package import.
1 parent a906df7 commit cf4bfe0

9 files changed

Lines changed: 169 additions & 16 deletions

File tree

source/isaaclab_tasks/config/extension.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22

33
# Note: Semantic Versioning is used: https://semver.org/
4-
version = "1.5.25"
4+
version = "1.5.26"
55

66
# Description
77
title = "Isaac Lab Environments"

source/isaaclab_tasks/docs/CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
Changelog
22
---------
33

4+
1.5.26 (2026-04-28)
5+
~~~~~~~~~~~~~~~~~~~
6+
7+
Fixed
8+
^^^^^
9+
10+
* Fixed lazy preset loading so direct task physics presets can avoid importing
11+
optional backend packages unless their preset is selected.
12+
13+
414
1.5.25 (2026-04-23)
515
~~~~~~~~~~~~~~~~~~~
616

source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from __future__ import annotations
77

88
from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg
9-
from isaaclab_ovphysx.physics import OvPhysxCfg
109
from isaaclab_physx.physics import PhysxCfg
1110

1211
import isaaclab.sim as sim_utils
@@ -17,7 +16,7 @@
1716
from isaaclab.terrains import TerrainImporterCfg
1817
from isaaclab.utils import configclass
1918

20-
from isaaclab_tasks.utils import PresetCfg
19+
from isaaclab_tasks.utils import PresetCfg, lazy_preset
2120

2221
from isaaclab_assets.robots.ant import ANT_CFG
2322

@@ -37,7 +36,10 @@ class AntPhysicsCfg(PresetCfg):
3736
num_substeps=1,
3837
debug_mode=False,
3938
)
40-
ovphysx: OvPhysxCfg = OvPhysxCfg()
39+
ovphysx = lazy_preset(
40+
"isaaclab_ovphysx.physics:OvPhysxCfg",
41+
error_hint="Install the OV-PhysX backend before selecting the 'ovphysx' preset.",
42+
)
4143

4244

4345
@configclass

source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from __future__ import annotations
77

88
from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg
9-
from isaaclab_ovphysx.physics import OvPhysxCfg
109
from isaaclab_physx.physics import PhysxCfg
1110

1211
from isaaclab.assets import ArticulationCfg
@@ -15,7 +14,7 @@
1514
from isaaclab.sim import SimulationCfg
1615
from isaaclab.utils import configclass
1716

18-
from isaaclab_tasks.utils import PresetCfg
17+
from isaaclab_tasks.utils import PresetCfg, lazy_preset
1918

2019
from isaaclab_assets.robots.cartpole import CARTPOLE_CFG
2120

@@ -36,7 +35,10 @@ class CartpolePhysicsCfg(PresetCfg):
3635
debug_mode=False,
3736
use_cuda_graph=True,
3837
)
39-
ovphysx: OvPhysxCfg = OvPhysxCfg()
38+
ovphysx = lazy_preset(
39+
"isaaclab_ovphysx.physics:OvPhysxCfg",
40+
error_hint="Install the OV-PhysX backend before selecting the 'ovphysx' preset.",
41+
)
4042

4143

4244
@configclass

source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from __future__ import annotations
77

88
from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg
9-
from isaaclab_ovphysx.physics import OvPhysxCfg
109
from isaaclab_physx.physics import PhysxCfg
1110

1211
import isaaclab.sim as sim_utils
@@ -17,7 +16,7 @@
1716
from isaaclab.terrains import TerrainImporterCfg
1817
from isaaclab.utils import configclass
1918

20-
from isaaclab_tasks.utils import PresetCfg
19+
from isaaclab_tasks.utils import PresetCfg, lazy_preset
2120

2221
from isaaclab_assets import HUMANOID_CFG
2322

@@ -38,7 +37,10 @@ class HumanoidPhysicsCfg(PresetCfg):
3837
num_substeps=2,
3938
debug_mode=False,
4039
)
41-
ovphysx: OvPhysxCfg = OvPhysxCfg()
40+
ovphysx = lazy_preset(
41+
"isaaclab_ovphysx.physics:OvPhysxCfg",
42+
error_hint="Install the OV-PhysX backend before selecting the 'ovphysx' preset.",
43+
)
4244

4345

4446
@configclass

source/isaaclab_tasks/isaaclab_tasks/direct/locomotion/locomotion_env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import torch
99
import warp as wp
1010
from isaaclab_newton.physics import NewtonCfg
11-
from isaaclab_ovphysx.physics import OvPhysxCfg
1211
from isaaclab_physx.physics import PhysxCfg
1312

1413
import isaaclab.sim as sim_utils
@@ -80,7 +79,8 @@ def __init__(self, cfg: DirectRLEnvCfg, render_mode: str | None = None, **kwargs
8079
self.action_scale = self.cfg.action_scale
8180
# Resolve the joint gears based on the physics type, since they do not have the same joint ordering.
8281
if isinstance(self.cfg.joint_gears, dict):
83-
if isinstance(self.cfg.sim.physics, (PhysxCfg, OvPhysxCfg)):
82+
physics_type = type(self.cfg.sim.physics).__name__
83+
if isinstance(self.cfg.sim.physics, PhysxCfg) or physics_type == "OvPhysxCfg":
8484
joint_gears = self.cfg.joint_gears["physx"]
8585
elif isinstance(self.cfg.sim.physics, NewtonCfg):
8686
joint_gears = self.cfg.joint_gears["newton"]

source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ __all__ = [
99
"load_cfg_from_registry",
1010
"parse_env_cfg",
1111
"PresetCfg",
12+
"lazy_preset",
1213
"preset",
1314
"resolve_task_config",
1415
"hydra_task_config",
@@ -18,7 +19,7 @@ __all__ = [
1819
"compute_kit_requirements",
1920
]
2021

21-
from .hydra import PresetCfg, preset, hydra_task_config, resolve_task_config, resolve_presets
22+
from .hydra import PresetCfg, lazy_preset, preset, hydra_task_config, resolve_task_config, resolve_presets
2223
from .importer import import_packages
2324
from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg
2425
from .sim_launcher import add_launcher_args, launch_simulation, compute_kit_requirements

source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626

2727
import functools
28+
import importlib
2829
import sys
2930
from collections.abc import Callable, Mapping
3031

@@ -58,6 +59,77 @@ class PhysicsCfg(PresetCfg):
5859
pass
5960

6061

62+
class _LazyPreset:
63+
"""Lazy preset alternative backed by an import path."""
64+
65+
__slots__ = ("error_hint", "import_path", "kwargs")
66+
67+
def __init__(self, import_path: str, error_hint: str | None = None, kwargs: dict[str, object] | None = None):
68+
self.import_path = import_path
69+
self.error_hint = error_hint
70+
self.kwargs = kwargs or {}
71+
72+
def __eq__(self, other: object) -> bool:
73+
return (
74+
isinstance(other, _LazyPreset)
75+
and self.import_path == other.import_path
76+
and self.error_hint == other.error_hint
77+
and self.kwargs == other.kwargs
78+
)
79+
80+
def load(self, preset_name: str) -> object:
81+
module_name, _, attr_name = self.import_path.partition(":")
82+
try:
83+
module = importlib.import_module(module_name)
84+
cfg_cls = getattr(module, attr_name)
85+
except ModuleNotFoundError as exc:
86+
raise ModuleNotFoundError(self._format_error(preset_name, exc)) from exc
87+
except (AttributeError, ImportError) as exc:
88+
raise ImportError(self._format_error(preset_name, exc)) from exc
89+
return cfg_cls(**self.kwargs)
90+
91+
def _format_error(self, preset_name: str, exc: Exception) -> str:
92+
message = f"Preset '{preset_name}' requires config '{self.import_path}', but it could not be imported."
93+
if self.error_hint:
94+
message += f" {self.error_hint}"
95+
message += f" Original error: {exc}"
96+
return message
97+
98+
99+
def lazy_preset(
100+
import_path: str,
101+
*,
102+
error_hint: str | None = None,
103+
**kwargs: object,
104+
) -> _LazyPreset:
105+
"""Create a preset alternative that imports its config only when selected.
106+
107+
The target class is imported and instantiated only when the preset is selected.
108+
This lets task configs advertise presets without importing their backend
109+
packages during normal config loading.
110+
111+
Args:
112+
import_path: Import path in ``"module:ClassName"`` form.
113+
error_hint: Optional guidance appended to import errors.
114+
**kwargs: Keyword arguments forwarded to the imported config class.
115+
116+
Returns:
117+
A lazy preset alternative that can be used as a :class:`PresetCfg` field.
118+
119+
Raises:
120+
ValueError: If :attr:`import_path` is not in ``"module:ClassName"`` form.
121+
"""
122+
module_name, sep, attr_name = import_path.partition(":")
123+
if not sep or not module_name or not attr_name:
124+
raise ValueError(f"Lazy preset import path must use 'module:ClassName' form, got: {import_path!r}.")
125+
return _LazyPreset(import_path, error_hint, kwargs)
126+
127+
128+
def _materialize_lazy_preset(value, preset_name: str) -> object:
129+
"""Instantiate a lazy preset if needed."""
130+
return value.load(preset_name) if isinstance(value, _LazyPreset) else value
131+
132+
61133
def preset(**options) -> PresetCfg:
62134
"""Create a :class:`PresetCfg` instance from keyword arguments.
63135
@@ -179,9 +251,9 @@ def _pick_alternative(preset_obj: PresetCfg, selected: set[str], path: str = "")
179251
fields = _preset_fields(preset_obj)
180252
for name in selected:
181253
if name in fields:
182-
return fields[name]
254+
return _materialize_lazy_preset(fields[name], name)
183255
if "default" in fields:
184-
return fields["default"]
256+
return _materialize_lazy_preset(fields["default"], "default")
185257
raise ValueError(
186258
f"PresetCfg {type(preset_obj).__name__} at '{path}' has no 'default' field "
187259
f"and none of the selected presets {selected} match its fields {set(fields.keys())}."
@@ -522,7 +594,7 @@ def _path_reachable(sec: str, path: str) -> bool:
522594
for full_path in sorted(resolved, key=lambda fp: fp.count(".")):
523595
sec, path, name = resolved[full_path]
524596
if cfgs[sec] is not None and _path_reachable(sec, path):
525-
node = presets[sec][path][name]
597+
node = _materialize_lazy_preset(presets[sec][path][name], name)
526598
node_dict = (
527599
node.to_dict() if hasattr(node, "to_dict") else dict(node) if isinstance(node, Mapping) else node
528600
)

source/isaaclab_tasks/test/test_hydra.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
external environment configurations.
1010
"""
1111

12+
import sys
13+
import types
14+
1215
import pytest
1316

1417
from isaaclab.utils import configclass
@@ -17,6 +20,7 @@
1720
PresetCfg,
1821
apply_overrides,
1922
collect_presets,
23+
lazy_preset,
2024
parse_overrides,
2125
preset,
2226
resolve_presets,
@@ -570,6 +574,21 @@ class EnvWithOptionalFeatureCfg:
570574
optional_feature: OptionalFeaturePresetCfg = OptionalFeaturePresetCfg()
571575

572576

577+
@configclass
578+
class LazyBackendPresetCfg(PresetCfg):
579+
default: PhysxCfg = PhysxCfg()
580+
optional_backend = lazy_preset(
581+
"test_hydra_optional_backend:OptionalBackendCfg",
582+
error_hint="Install the optional backend package.",
583+
)
584+
585+
586+
@configclass
587+
class EnvWithLazyBackendCfg:
588+
decimation: int = 4
589+
backend: LazyBackendPresetCfg = LazyBackendPresetCfg()
590+
591+
573592
def test_presetcfg_none_default_auto_applies():
574593
"""PresetCfg with default=None auto-applies None without crashing."""
575594
env_cfg, _ = _apply(EnvWithOptionalFeatureCfg())
@@ -588,6 +607,51 @@ def test_presetcfg_none_default_cli_selects_enabled():
588607
assert env_cfg.optional_feature.buffer_size == 200
589608

590609

610+
def test_lazy_preset_default_does_not_import_missing_module():
611+
"""Lazy preset alternatives are discoverable but not imported when default resolves."""
612+
presets = collect_presets(EnvWithLazyBackendCfg())
613+
assert "optional_backend" in presets["backend"]
614+
assert "test_hydra_optional_backend" not in sys.modules
615+
616+
env_cfg = resolve_presets(EnvWithLazyBackendCfg())
617+
618+
assert isinstance(env_cfg.backend, PhysxCfg)
619+
assert "test_hydra_optional_backend" not in sys.modules
620+
621+
622+
def test_lazy_preset_selection_requires_installed_module():
623+
"""Selecting an unavailable lazy preset raises an actionable import error."""
624+
with pytest.raises(ModuleNotFoundError, match="Install the optional backend package."):
625+
resolve_presets(EnvWithLazyBackendCfg(), {"optional_backend"})
626+
627+
628+
def test_lazy_preset_path_selection_materializes_module(monkeypatch):
629+
"""Path selection imports and instantiates an available lazy preset."""
630+
631+
@configclass
632+
class OptionalBackendCfg:
633+
backend: str = "optional"
634+
substeps: int = 8
635+
636+
module = types.ModuleType("test_hydra_optional_backend")
637+
module.OptionalBackendCfg = OptionalBackendCfg
638+
monkeypatch.setitem(sys.modules, "test_hydra_optional_backend", module)
639+
640+
env_cfg = EnvWithLazyBackendCfg()
641+
agent_cfg = PresetCfgAgentCfg()
642+
presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)}
643+
hydra_cfg = {
644+
"env": resolve_presets(EnvWithLazyBackendCfg()).to_dict(),
645+
"agent": agent_cfg.to_dict(),
646+
}
647+
648+
apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "optional_backend")], [], presets)
649+
650+
assert isinstance(env_cfg.backend, OptionalBackendCfg)
651+
assert env_cfg.backend.backend == "optional"
652+
assert hydra_cfg["env"]["backend"]["substeps"] == 8
653+
654+
591655
def test_root_presetcfg_global_depth_resolves_nested():
592656
"""Global preset=depth on root PresetCfg also resolves nested sensor and renderer."""
593657
env_cfg, _ = _apply(RootPresetEnvCfg(), global_presets=["depth"])

0 commit comments

Comments
 (0)