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
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.15"
version = "4.6.16"

# 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.16 (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.15 (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