Skip to content
Merged
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
4 changes: 4 additions & 0 deletions source/isaaclab/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Changed
for config defaults. ``"{DIR}.module:Symbol"`` now expands to the declaring config
module directory before resolution.

* Updated :func:`~isaaclab.utils.dict.update_class_from_dict` to stop eagerly resolving
callable strings during updates. Callable-string inputs are now preserved as lazy
:class:`~isaaclab.utils.string.ResolvableString` values and resolve only on first use.


4.2.2 (2026-02-26)
~~~~~~~~~~~~~~~~~~
Expand Down
7 changes: 4 additions & 3 deletions source/isaaclab/isaaclab/devices/device_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@

from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import field
from enum import Enum
from typing import Any

import torch

from isaaclab.devices.retargeter_base import RetargeterBase, RetargeterCfg
from isaaclab.utils import configclass


@dataclass
@configclass
class DeviceCfg:
"""Configuration for teleoperation devices."""

Expand All @@ -32,7 +33,7 @@ class DeviceCfg:
class_type: type[DeviceBase] | None = None


@dataclass
@configclass
class DevicesCfg:
"""Configuration for all supported teleoperation devices."""

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se2_gamepad import Se2Gamepad


@dataclass
@configclass
class Se2GamepadCfg(DeviceCfg):
"""Configuration for SE2 gamepad devices."""

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se3_gamepad import Se3Gamepad


@dataclass
@configclass
class Se3GamepadCfg(DeviceCfg):
"""Configuration for SE3 gamepad devices."""

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/haply/se3_haply.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass

import numpy as np
import torch

from isaaclab.utils import configclass

try:
import websockets

Expand Down Expand Up @@ -377,7 +378,7 @@ async def _websocket_loop(self):
break


@dataclass
@configclass
class HaplyDeviceCfg(DeviceCfg):
"""Configuration for Haply device.

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se2_keyboard import Se2Keyboard


@dataclass
@configclass
class Se2KeyboardCfg(DeviceCfg):
"""Configuration for SE2 keyboard devices."""

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se3_keyboard import Se3Keyboard


@dataclass
@configclass
class Se3KeyboardCfg(DeviceCfg):
"""Configuration for SE3 keyboard devices."""

Expand Down
5 changes: 3 additions & 2 deletions source/isaaclab/isaaclab/devices/retargeter_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@

import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any

from isaaclab.utils import configclass

@dataclass

@configclass
class RetargeterCfg:
"""Base configuration for hand tracking retargeters.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se2_spacemouse import Se2SpaceMouse


@dataclass
@configclass
class Se2SpaceMouseCfg(DeviceCfg):
"""Configuration for SE2 space mouse devices."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab.utils import configclass

from ..device_base import DeviceCfg

if TYPE_CHECKING:
from .se3_spacemouse import Se3SpaceMouse


@dataclass
@configclass
class Se3SpaceMouseCfg(DeviceCfg):
"""Configuration for SE3 space mouse devices."""

Expand Down
32 changes: 26 additions & 6 deletions source/isaaclab/isaaclab/utils/configclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,24 +208,44 @@ def _field_module_dir(obj: Any, key: str | None = None) -> str | None:
return module_name.rsplit(".", 1)[0] if "." in module_name else (module_name or None)


def _wrap_resolvable_strings(value: Any, module_dir: str | None = None) -> Any:
def _wrap_resolvable_strings(value: Any, module_dir: str | None = None, _seen: set[int] | None = None) -> Any:
"""Recursively wrap callable-like strings with :class:`ResolvableString`."""
if isinstance(value, str) and (_CALLABLE_STR_RE.match(value) or _CALLABLE_STR_WITH_DIR_RE.match(value)):
if "{DIR}" in value:
if module_dir is None:
raise ValueError(f"Cannot resolve '{{DIR}}' in '{value}' because no module context is available.")
value = value.replace("{DIR}", module_dir)
return ResolvableString(value)
is_dataclass_instance = hasattr(value, "__dataclass_fields__") and hasattr(value, "__dict__")
is_container = isinstance(value, (list, tuple, dict))
if is_dataclass_instance or is_container:
if _seen is None:
_seen = set()
value_id = id(value)
if value_id in _seen:
return value
_seen.add(value_id)
if isinstance(value, list):
return [_wrap_resolvable_strings(item, module_dir=module_dir) for item in value]
wrapped = [_wrap_resolvable_strings(item, module_dir=module_dir, _seen=_seen) for item in value]
if len(wrapped) == len(value) and all(new_item is old_item for new_item, old_item in zip(wrapped, value)):
return value
return wrapped
if isinstance(value, tuple):
return tuple(_wrap_resolvable_strings(item, module_dir=module_dir) for item in value)
wrapped = tuple(_wrap_resolvable_strings(item, module_dir=module_dir, _seen=_seen) for item in value)
if len(wrapped) == len(value) and all(new_item is old_item for new_item, old_item in zip(wrapped, value)):
return value
return wrapped
if isinstance(value, dict):
return {key: _wrap_resolvable_strings(item, module_dir=module_dir) for key, item in value.items()}
if isinstance(getattr(value, "__dataclass_fields__", None), dict) and hasattr(value, "__dict__"):
wrapped = {
key: _wrap_resolvable_strings(item, module_dir=module_dir, _seen=_seen) for key, item in value.items()
}
if len(wrapped) == len(value) and all(wrapped[key] is value[key] for key in value):
return value
return wrapped
if is_dataclass_instance:
for key, item in value.__dict__.items():
nested_module_dir = _field_module_dir(value, key)
setattr(value, key, _wrap_resolvable_strings(item, module_dir=nested_module_dir))
setattr(value, key, _wrap_resolvable_strings(item, module_dir=nested_module_dir, _seen=_seen))
return value


Expand Down
7 changes: 3 additions & 4 deletions source/isaaclab/isaaclab/utils/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,9 @@ def update_class_from_dict(obj, data: dict[str, Any], _ns: str = "") -> None:

# -- 3) callable attribute → keep string lazily resolvable --------------
elif callable(obj_mem):
# Do not eagerly import backend modules while applying config dictionaries.
# Keep callable strings lazy; they resolve only when actually invoked.
if isinstance(value, str) and not isinstance(value, ResolvableString):
value = ResolvableString(value)
if isinstance(value, str):
if not isinstance(value, ResolvableString):
value = ResolvableString(value)
elif not callable(value):
raise ValueError(
f"[Config]: Incorrect type under namespace: {key_ns}."
Expand Down
72 changes: 70 additions & 2 deletions source/isaaclab/test/utils/test_configclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
import pytest
import torch

from isaaclab.utils.configclass import configclass
from isaaclab.utils.configclass import _field_module_dir, configclass
from isaaclab.utils.dict import class_to_dict, dict_to_md5_hash, update_class_from_dict
from isaaclab.utils.io import dump_yaml, load_yaml
from isaaclab.utils.string import ResolvableString

"""
Mock classes and functions.
Expand Down Expand Up @@ -447,7 +448,7 @@ class MissingChildDemoCfg(MissingParentDemoCfg):

basic_demo_cfg_nested_dict_and_list = {
"dict_1": {
"dict_2": {"func": dummy_function2},
"dict_2": {"func": "test_configclass:dummy_function2"},
},
"list_1": [
{"num_envs": 23, "episode_length": 3000, "viewer": {"eye": [5.0, 5.0, 5.0], "lookat": [0.0, 0.0, 0.0]}},
Expand Down Expand Up @@ -643,6 +644,47 @@ def test_config_update_nested_dict():
assert isinstance(cfg.list_1[1].viewer, ViewerCfg)


def test_wrap_resolvable_strings_handles_cyclic_containers():
"""Cyclic container graphs in config values should not recurse forever."""

@configclass
class CyclicContainerCfg:
payload: dict[str, Any] = field(default_factory=dict)

def __post_init__(self):
cycle = {}
cycle["self"] = cycle
cycle["tuple"] = (cycle, {"back": cycle})
self.payload = cycle

cfg = CyclicContainerCfg()

assert cfg.payload["self"] is cfg.payload
assert cfg.payload["tuple"][0] is cfg.payload
assert cfg.payload["tuple"][1]["back"] is cfg.payload


def test_dir_resolution_uses_declaring_class_for_inherited_field():
"""{DIR} expansion should use the field declaring class, not subclass module."""

@configclass
class _BaseCfg:
class_type: type | str = "{DIR}.base_mod:BaseSymbol"

@configclass
class _ChildCfg(_BaseCfg):
pass

# Simulate subclass declared in a different package than the parent config.
_BaseCfg.__module__ = "test_pkg.parent.base_cfg"
_ChildCfg.__module__ = "other_pkg.child.child_cfg"

cfg = _ChildCfg()

assert isinstance(cfg.class_type, ResolvableString)
assert str(cfg.class_type) == "test_pkg.parent.base_mod:BaseSymbol"


def test_config_update_different_iterable_lengths():
"""Iterables are whole replaced, even if their lengths are different."""

Expand Down Expand Up @@ -1077,3 +1119,29 @@ def test_validity():

# check that no more than the expected missing fields are in the error message
assert len(error_message.split("\n")) - 2 == len(validity_expected_fields)


def test_dir_resolution_in_subclass():
"""Test that {DIR} in inherited fields resolves relative to the declaring class's module."""

@configclass
class ParentCfg:
class_type: str = "{DIR}.my_module:MyClass"
name: str = "default"

@configclass
class ChildCfg(ParentCfg):
extra: int = 42

# Pretend the parent lives in a real package and the child lives in a test file
ParentCfg.__module__ = "some_package.sub_package.parent_cfg"
ChildCfg.__module__ = "test_some_feature"

parent = ParentCfg.__new__(ParentCfg)
child = ChildCfg.__new__(ChildCfg)

# class_type should resolve to the parent's module dir in both cases
assert _field_module_dir(parent, "class_type") == "some_package.sub_package"
assert _field_module_dir(child, "class_type") == "some_package.sub_package"
# extra should resolve to the child's module dir
assert _field_module_dir(child, "extra") == "test_some_feature"
6 changes: 3 additions & 3 deletions source/isaaclab/test/utils/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_string_callable_function_conversion():
# convert function to string
test_string = dict_utils.callable_to_string(_test_function)
# convert string to function
test_function_2 = dict_utils.string_to_callable(test_string)
test_function_2 = string_utils.string_to_callable(test_string)
# check that functions are the same
assert _test_function(2) == test_function_2(2)

Expand All @@ -64,7 +64,7 @@ def test_string_callable_function_with_lambda_in_name_conversion():
# convert function to string
test_string = dict_utils.callable_to_string(_test_lambda_function)
# convert string to function
test_function_2 = dict_utils.string_to_callable(test_string)
test_function_2 = string_utils.string_to_callable(test_string)
# check that functions are the same
assert _test_function(2) == test_function_2(2)

Expand All @@ -77,7 +77,7 @@ def test_string_callable_lambda_conversion():
# convert function to string
test_string = dict_utils.callable_to_string(func)
# convert string to function
func_2 = dict_utils.string_to_callable(test_string)
func_2 = string_utils.string_to_callable(test_string)
# check that functions are the same
assert test_string == "lambda x: x**2"
assert func(2) == func_2(2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ def create_teleop_device(
f"Device configuration '{device_name}' does not declare class_type. "
"Set cfg.class_type to the concrete DeviceBase subclass."
)
if not issubclass(device_constructor, DeviceBase):
check_cls = device_constructor._resolve() if hasattr(device_constructor, "_resolve") else device_constructor
if not issubclass(check_cls, DeviceBase):
raise TypeError(f"class_type for '{device_name}' must be a subclass of DeviceBase; got {device_constructor}")

# Try to create retargeters if they are configured
Expand Down
Loading