Skip to content

Modifies update_class_from_dict() to wholesale replace flat Iterables #2511

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion source/isaaclab/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
[package]

# Note: Semantic Versioning is used: https://semver.org/
version = "0.40.5"
version = "0.40.X"


# Description
title = "Isaac Lab framework for Robot Learning"
Expand Down
9 changes: 9 additions & 0 deletions source/isaaclab/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Changelog
---------

## [Unreleased]
~~~~~~~~~~~~~~~

Fixed
^^^^^

* Fixed :meth:`omni.isaac.lab.utils.dict.update_class_from_dict` preventing setting flat Iterables with different lengths.


0.40.5 (2025-05-22)
~~~~~~~~~~~~~~~~~~~

Expand Down
50 changes: 41 additions & 9 deletions source/isaaclab/isaaclab/utils/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import hashlib
import json
import torch
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Mapping, Sized
from typing import Any

from .array import TENSOR_TYPE_CONVERSIONS, TENSOR_TYPES
Expand Down Expand Up @@ -90,47 +90,79 @@ def update_class_from_dict(obj, data: dict[str, Any], _ns: str = "") -> None:
for key, value in data.items():
# key_ns is the full namespace of the key
key_ns = _ns + "/" + key
# check if key is present in the object
if hasattr(obj, key) or isinstance(obj, dict):

# -- A) if key is present in the object ------------------------------------
if hasattr(obj, key) or (isinstance(obj, dict) and key in obj):
obj_mem = obj[key] if isinstance(obj, dict) else getattr(obj, key)

# -- 1) nested mapping → recurse ---------------------------
if isinstance(value, Mapping):
# recursively call if it is a dictionary
update_class_from_dict(obj_mem, value, _ns=key_ns)
continue

# -- 2) iterable (list / tuple / etc.) ---------------------
if isinstance(value, Iterable) and not isinstance(value, str):
# check length of value to be safe
if len(obj_mem) != len(value) and obj_mem is not None:

# ---- 2a) flat iterable → replace wholesale ----------
if all(not isinstance(el, Mapping) for el in value):
out_val = tuple(value) if isinstance(obj_mem, tuple) else value
if isinstance(obj, dict):
obj[key] = out_val
else:
setattr(obj, key, out_val)
continue

# ---- 2b) existing value is None → abort -------------
if obj_mem is None:
raise ValueError(
f"[Config]: Cannot merge list under namespace: {key_ns} because the existing value is None."
)

# ---- 2c) length mismatch → abort -------------------
if isinstance(obj_mem, Sized) and isinstance(value, Sized) and len(obj_mem) != len(value):
raise ValueError(
f"[Config]: Incorrect length under namespace: {key_ns}."
f" Expected: {len(obj_mem)}, Received: {len(value)}."
)

# ---- 2d) keep tuple/list parity & recurse ----------
if isinstance(obj_mem, tuple):
value = tuple(value)
else:
set_obj = True
# recursively call if iterable contains dictionaries
# recursively call if iterable contains Mappings
for i in range(len(obj_mem)):
if isinstance(value[i], dict):
if isinstance(value[i], Mapping):
update_class_from_dict(obj_mem[i], value[i], _ns=key_ns)
set_obj = False
# do not set value to obj, otherwise it overwrites the cfg class with the dict
if not set_obj:
continue

# -- 3) callable attribute → resolve string --------------
elif callable(obj_mem):
# update function name
value = string_to_callable(value)
elif isinstance(value, type(obj_mem)) or value is None:

# -- 4) simple scalar / explicit None ---------------------
elif value is None or isinstance(value, type(obj_mem)):
pass

# -- 5) type mismatch → abort -----------------------------
else:
raise ValueError(
f"[Config]: Incorrect type under namespace: {key_ns}."
f" Expected: {type(obj_mem)}, Received: {type(value)}."
)
# set value

# -- 6) final assignment ---------------------------------
if isinstance(obj, dict):
obj[key] = value
else:
setattr(obj, key, value)

# -- B) if key is not present ------------------------------------
else:
raise KeyError(f"[Config]: Key not found under namespace: {key_ns}.")

Expand Down
22 changes: 22 additions & 0 deletions source/isaaclab/test/utils/test_configclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,28 @@ def test_config_update_nested_dict():
assert isinstance(cfg.list_1[1].viewer, ViewerCfg)


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

# original cfg has length-6 tuple and list
cfg = RobotDefaultStateCfg()
assert cfg.dof_pos == (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
assert cfg.dof_vel == [0.0, 0.0, 0.0, 0.0, 0.0, 1.0]

# patch uses different lengths
patch = {
"dof_pos": (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0), # longer tuple
"dof_vel": [9.0, 8.0, 7.0], # shorter list
}

# should not raise
update_class_from_dict(cfg, patch)

# whole sequences are replaced
assert cfg.dof_pos == (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0)
assert cfg.dof_vel == [9.0, 8.0, 7.0]


def test_config_update_dict_using_internal():
"""Test updating configclass from a dictionary using configclass method."""
cfg = BasicDemoCfg()
Expand Down