Skip to content

Commit 4c6584f

Browse files
Fix optional ovphysx preset imports
Add a lazy optional preset helper so task configs can advertise optional backend presets without importing their packages during default config loading. Use it for direct task OV-PhysX physics presets and keep locomotion gear selection independent of the optional package import.
1 parent a906df7 commit 4c6584f

10 files changed

Lines changed: 196 additions & 13 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 direct task physics presets to keep the OV-PhysX backend optional and avoid
11+
importing ``isaaclab_ovphysx`` unless the ``ovphysx`` 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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@
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
1312
from isaaclab.assets import ArticulationCfg
1413
from isaaclab.envs import DirectRLEnvCfg
14+
from isaaclab.physics import PhysicsCfg
1515
from isaaclab.scene import InteractiveSceneCfg
1616
from isaaclab.sim import SimulationCfg
1717
from isaaclab.terrains import TerrainImporterCfg
1818
from isaaclab.utils import configclass
1919

2020
from isaaclab_tasks.utils import PresetCfg
21+
from isaaclab_tasks.utils.physics_presets import ovphysx_preset
2122

2223
from isaaclab_assets.robots.ant import ANT_CFG
2324

@@ -37,7 +38,7 @@ class AntPhysicsCfg(PresetCfg):
3738
num_substeps=1,
3839
debug_mode=False,
3940
)
40-
ovphysx: OvPhysxCfg = OvPhysxCfg()
41+
ovphysx: PhysicsCfg = ovphysx_preset()
4142

4243

4344
@configclass

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@
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
1312
from isaaclab.envs import DirectRLEnvCfg
13+
from isaaclab.physics import PhysicsCfg
1414
from isaaclab.scene import InteractiveSceneCfg
1515
from isaaclab.sim import SimulationCfg
1616
from isaaclab.utils import configclass
1717

1818
from isaaclab_tasks.utils import PresetCfg
19+
from isaaclab_tasks.utils.physics_presets import ovphysx_preset
1920

2021
from isaaclab_assets.robots.cartpole import CARTPOLE_CFG
2122

@@ -36,7 +37,7 @@ class CartpolePhysicsCfg(PresetCfg):
3637
debug_mode=False,
3738
use_cuda_graph=True,
3839
)
39-
ovphysx: OvPhysxCfg = OvPhysxCfg()
40+
ovphysx: PhysicsCfg = ovphysx_preset()
4041

4142

4243
@configclass

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@
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
1312
from isaaclab.assets import ArticulationCfg
1413
from isaaclab.envs import DirectRLEnvCfg
14+
from isaaclab.physics import PhysicsCfg
1515
from isaaclab.scene import InteractiveSceneCfg
1616
from isaaclab.sim import SimulationCfg
1717
from isaaclab.terrains import TerrainImporterCfg
1818
from isaaclab.utils import configclass
1919

2020
from isaaclab_tasks.utils import PresetCfg
21+
from isaaclab_tasks.utils.physics_presets import ovphysx_preset
2122

2223
from isaaclab_assets import HUMANOID_CFG
2324

@@ -38,7 +39,7 @@ class HumanoidPhysicsCfg(PresetCfg):
3839
num_substeps=2,
3940
debug_mode=False,
4041
)
41-
ovphysx: OvPhysxCfg = OvPhysxCfg()
42+
ovphysx: PhysicsCfg = ovphysx_preset()
4243

4344

4445
@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+
"optional_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, optional_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: 88 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,90 @@ class PhysicsCfg(PresetCfg):
5859
pass
5960

6061

62+
class _OptionalPreset:
63+
"""Lazy preset alternative backed by an optional import."""
64+
65+
__slots__ = ("import_path", "install_hint", "kwargs", "preset_name")
66+
67+
def __init__(
68+
self,
69+
import_path: str,
70+
preset_name: str,
71+
install_hint: str | None = None,
72+
kwargs: dict[str, object] | None = None,
73+
):
74+
self.import_path = import_path
75+
self.preset_name = preset_name
76+
self.install_hint = install_hint
77+
self.kwargs = kwargs or {}
78+
79+
def __eq__(self, other: object) -> bool:
80+
return (
81+
isinstance(other, _OptionalPreset)
82+
and self.import_path == other.import_path
83+
and self.preset_name == other.preset_name
84+
and self.install_hint == other.install_hint
85+
and self.kwargs == other.kwargs
86+
)
87+
88+
def load(self) -> object:
89+
module_name, _, attr_name = self.import_path.partition(":")
90+
try:
91+
module = importlib.import_module(module_name)
92+
cfg_cls = getattr(module, attr_name)
93+
except ModuleNotFoundError as exc:
94+
raise ModuleNotFoundError(self._format_error(exc)) from exc
95+
except (AttributeError, ImportError) as exc:
96+
raise ImportError(self._format_error(exc)) from exc
97+
return cfg_cls(**self.kwargs)
98+
99+
def _format_error(self, exc: Exception) -> str:
100+
message = (
101+
f"Preset '{self.preset_name}' requires optional config '{self.import_path}', but it could not be imported."
102+
)
103+
if self.install_hint:
104+
message += f" {self.install_hint}"
105+
message += f" Original error: {exc}"
106+
return message
107+
108+
109+
def optional_preset(
110+
import_path: str,
111+
*,
112+
preset_name: str | None = None,
113+
install_hint: str | None = None,
114+
**kwargs: object,
115+
) -> _OptionalPreset:
116+
"""Create a lazy preset alternative for an optional package.
117+
118+
The target class is imported and instantiated only when the preset is selected.
119+
This lets default task configs advertise optional presets without importing
120+
optional packages during normal config loading.
121+
122+
Args:
123+
import_path: Import path in ``"module:ClassName"`` form.
124+
preset_name: User-facing preset name for error messages. Defaults to the
125+
imported class name.
126+
install_hint: Optional guidance appended to import errors.
127+
**kwargs: Keyword arguments forwarded to the imported config class.
128+
129+
Returns:
130+
A lazy preset alternative that can be used as a :class:`PresetCfg` field.
131+
132+
Raises:
133+
ValueError: If :attr:`import_path` is not in ``"module:ClassName"`` form.
134+
"""
135+
module_name, sep, attr_name = import_path.partition(":")
136+
if not sep or not module_name or not attr_name:
137+
raise ValueError(f"Optional preset import path must use 'module:ClassName' form, got: {import_path!r}.")
138+
return _OptionalPreset(import_path, preset_name or attr_name, install_hint, kwargs)
139+
140+
141+
def _materialize_optional_preset(value) -> object:
142+
"""Instantiate an optional preset if needed."""
143+
return value.load() if isinstance(value, _OptionalPreset) else value
144+
145+
61146
def preset(**options) -> PresetCfg:
62147
"""Create a :class:`PresetCfg` instance from keyword arguments.
63148
@@ -179,9 +264,9 @@ def _pick_alternative(preset_obj: PresetCfg, selected: set[str], path: str = "")
179264
fields = _preset_fields(preset_obj)
180265
for name in selected:
181266
if name in fields:
182-
return fields[name]
267+
return _materialize_optional_preset(fields[name])
183268
if "default" in fields:
184-
return fields["default"]
269+
return _materialize_optional_preset(fields["default"])
185270
raise ValueError(
186271
f"PresetCfg {type(preset_obj).__name__} at '{path}' has no 'default' field "
187272
f"and none of the selected presets {selected} match its fields {set(fields.keys())}."
@@ -522,7 +607,7 @@ def _path_reachable(sec: str, path: str) -> bool:
522607
for full_path in sorted(resolved, key=lambda fp: fp.count(".")):
523608
sec, path, name = resolved[full_path]
524609
if cfgs[sec] is not None and _path_reachable(sec, path):
525-
node = presets[sec][path][name]
610+
node = _materialize_optional_preset(presets[sec][path][name])
526611
node_dict = (
527612
node.to_dict() if hasattr(node, "to_dict") else dict(node) if isinstance(node, Mapping) else node
528613
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Helpers for task physics backend presets."""
7+
8+
from __future__ import annotations
9+
10+
from isaaclab_tasks.utils.hydra import optional_preset
11+
12+
13+
def ovphysx_preset() -> object:
14+
"""Create the lazy optional preset for the OV-PhysX physics backend."""
15+
return optional_preset(
16+
"isaaclab_ovphysx.physics:OvPhysxCfg",
17+
preset_name="ovphysx",
18+
install_hint="Install the OV-PhysX optional backend before selecting the 'ovphysx' preset.",
19+
)

source/isaaclab_tasks/test/test_hydra.py

Lines changed: 65 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+
optional_preset,
2024
parse_overrides,
2125
preset,
2226
resolve_presets,
@@ -570,6 +574,22 @@ class EnvWithOptionalFeatureCfg:
570574
optional_feature: OptionalFeaturePresetCfg = OptionalFeaturePresetCfg()
571575

572576

577+
@configclass
578+
class LazyOptionalBackendPresetCfg(PresetCfg):
579+
default: PhysxCfg = PhysxCfg()
580+
optional_backend = optional_preset(
581+
"test_hydra_optional_backend:OptionalBackendCfg",
582+
preset_name="optional_backend",
583+
install_hint="Install the optional backend package.",
584+
)
585+
586+
587+
@configclass
588+
class EnvWithLazyOptionalBackendCfg:
589+
decimation: int = 4
590+
backend: LazyOptionalBackendPresetCfg = LazyOptionalBackendPresetCfg()
591+
592+
573593
def test_presetcfg_none_default_auto_applies():
574594
"""PresetCfg with default=None auto-applies None without crashing."""
575595
env_cfg, _ = _apply(EnvWithOptionalFeatureCfg())
@@ -588,6 +608,51 @@ def test_presetcfg_none_default_cli_selects_enabled():
588608
assert env_cfg.optional_feature.buffer_size == 200
589609

590610

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

0 commit comments

Comments
 (0)