Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion source/isaaclab/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]

# Note: Semantic Versioning is used: https://semver.org/
version = "4.6.14"
version = "4.6.15"

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

4.6.15 (2026-04-24)
~~~~~~~~~~~~~~~~~~~

Added
^^^^^

* Added :func:`~isaaclab.utils.checked_apply` for forwarding declared
fields from an Isaac Lab configclass onto an external dataclass
(typically an upstream library config object). Raises
:class:`AttributeError` if the target is missing a declared field, so
upstream renames surface at startup instead of as silent no-ops.


4.6.14 (2026-04-24)
~~~~~~~~~~~~~~~~~~~

Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/utils/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ __all__ = [
"compare_versions",
"configclass",
"resolve_cfg_presets",
"checked_apply",
]

from .timer import Timer
Expand Down Expand Up @@ -106,4 +107,4 @@ from .string import (
)
from .types import ArticulationActions
from .version import has_kit, get_isaac_sim_version, compare_versions
from .configclass import configclass, resolve_cfg_presets
from .configclass import checked_apply, configclass, resolve_cfg_presets
35 changes: 35 additions & 0 deletions source/isaaclab/isaaclab/utils/configclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

"""Sub-module that provides a wrapper around the Python 3.7 onwards ``dataclasses`` module."""

import dataclasses
import inspect
import re
import types
Expand Down Expand Up @@ -633,3 +634,37 @@ def resolve_cfg_presets(cfg: object) -> object:
else:
resolve_cfg_presets(value)
return cfg


def checked_apply(src: Any, target: Any) -> None:
"""Forward every declared field on ``src`` (a dataclass) onto ``target``.

Used by Isaac Lab configclasses that mirror an upstream/external dataclass
(for example, Newton's ``ShapeConfig``): declare the overridable fields
once on the wrapper, then forward them to the upstream object via this
helper instead of writing ``setattr`` lines per field.

Raises :class:`AttributeError` if ``target`` is missing a field declared
on ``src``. The two structures must match — the check guards against
silent no-ops when the upstream API drifts (the bug class PR #5289 fixed
for Newton ``ShapeConfig.contact_margin`` → ``margin``).

Args:
src: Dataclass instance whose declared fields will be forwarded.
Field names live here; this is the single source of truth.
target: Object to receive the field values. Must already expose
an attribute for every declared field on ``src``.

Raises:
AttributeError: If ``target`` does not already have an attribute
matching one of ``src``'s declared field names.
"""
if not hasattr(src, "__dataclass_fields__"):
raise TypeError(f"checked_apply: src must be a dataclass, got {type(src).__name__}")
for f in dataclasses.fields(src):
if not hasattr(target, f.name):
target_path = f"{type(target).__module__}.{type(target).__name__}"
raise AttributeError(
f"{target_path} has no attribute `{f.name}`. {type(src).__name__} is out of sync with target."
)
setattr(target, f.name, getattr(src, f.name))
63 changes: 63 additions & 0 deletions source/isaaclab/test/utils/test_configclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,3 +1145,66 @@ class ChildCfg(ParentCfg):
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"


# =============================================================================
# Tests: checked_apply
# =============================================================================


def test_checked_apply_forwards_all_fields():
"""checked_apply forwards every declared field on src onto target."""
from dataclasses import dataclass as plain_dataclass

from isaaclab.utils import checked_apply

@configclass
class WrapperCfg:
gap: float = 0.01
margin: float = 0.0

@plain_dataclass
class UpstreamLike:
gap: float = 99.0
margin: float = 99.0
unrelated: str = "keep me"

src = WrapperCfg(margin=0.005)
target = UpstreamLike()
checked_apply(src, target)

assert target.gap == 0.01
assert target.margin == 0.005
# fields not declared on src are not touched
assert target.unrelated == "keep me"


def test_checked_apply_raises_on_missing_target_field():
"""checked_apply fails loudly when target lacks a declared field."""
from dataclasses import dataclass as plain_dataclass

from isaaclab.utils import checked_apply

@configclass
class WrapperCfg:
margin: float = 0.01
renamed_in_upstream: float = 0.0

@plain_dataclass
class UpstreamMissingField:
margin: float = 0.0
# 'renamed_in_upstream' was renamed/removed upstream

with pytest.raises(AttributeError, match="renamed_in_upstream"):
checked_apply(WrapperCfg(), UpstreamMissingField())


def test_checked_apply_rejects_non_dataclass_src():
"""checked_apply requires src to be a dataclass."""
from isaaclab.utils import checked_apply

class NotADataclass:
margin = 0.01

with pytest.raises(TypeError, match="must be a dataclass"):
checked_apply(NotADataclass(), object())
Loading