Skip to content

Commit d76c153

Browse files
hujc7kellyguo11
andauthored
Add checked_apply helper for forwarding configclass fields onto upstream dataclasses (#5365)
# Description Adds `isaaclab.utils.checked_apply` for forwarding the declared fields of one dataclass onto another, raising `AttributeError` if the target is missing a declared field. The use case is Isaac Lab configclasses that mirror an upstream library's dataclass (for example, Newton's `ShapeConfig`). Bare `setattr` loops are fragile: if upstream renames or removes a field, every write becomes a silent no-op (the failure mode PR #5289 fixed for `ShapeConfig.contact_margin` → `margin`). With `checked_apply`, the failure surfaces at startup with a clear message instead of degrading runtime behavior. ## API ```python from isaaclab.utils import checked_apply @configclass class NewtonShapeCfg: margin: float = 0.0 gap: float = 0.01 # at apply site (one line, no per-field setattr noise) checked_apply(cfg.default_shape_cfg, builder.default_shape_cfg) ``` Internally: 1. Iterates `dataclasses.fields(src)` — single source of truth for declared fields. 2. Raises `AttributeError` if `target` lacks a declared field. 3. Rejects non-dataclass `src` with `TypeError`. ## What's included 1. `source/isaaclab/isaaclab/utils/configclass.py` — `checked_apply` function (lives next to `@configclass` since it operates on dataclasses). 2. `source/isaaclab/isaaclab/utils/__init__.pyi` — export. 3. `source/isaaclab/test/utils/test_configclass.py` — three tests (forwards all fields, raises on missing target field, rejects non-dataclass src). 4. `source/isaaclab/docs/CHANGELOG.rst` — `4.6.13` entry. 5. `source/isaaclab/config/extension.toml` — version bump. ## Dependents This PR is a dependency for the rough-terrain Newton stack: 1. PR #5248 — quadrupeds rough terrain, uses `checked_apply` to forward `NewtonShapeCfg` onto Newton's upstream `ShapeConfig`. Without it, `default_shape_cfg.margin` is left at Newton's upstream default of `0.0`, breaking all non-Anymal-D robots on triangle-mesh terrain. 2. PR #5298 — bipeds (chains on #5248). 3. PR #5312 — G1 (chains on #5298). ## Type of change - New feature (non-breaking). ## Checklist - [x] Tests added (3 new in `test_configclass.py`) - [x] Pre-commit checks pass - [x] CHANGELOG + extension.toml bumped - [x] No new dependencies --------- Signed-off-by: Kelly Guo <kellyg@nvidia.com> Co-authored-by: Kelly Guo <kellyg@nvidia.com>
1 parent 4d58ce4 commit d76c153

5 files changed

Lines changed: 114 additions & 2 deletions

File tree

source/isaaclab/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 = "4.6.15"
4+
version = "4.6.16"
55

66
# Description
77
title = "Isaac Lab framework for Robot Learning"

source/isaaclab/docs/CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
Changelog
22
---------
33

4+
4.6.16 (2026-04-24)
5+
~~~~~~~~~~~~~~~~~~~
6+
7+
Added
8+
^^^^^
9+
10+
* Added :func:`~isaaclab.utils.checked_apply` for forwarding declared
11+
fields from an Isaac Lab configclass onto an external dataclass
12+
(typically an upstream library config object). Raises
13+
:class:`AttributeError` if the target is missing a declared field, so
14+
upstream renames surface at startup instead of as silent no-ops.
15+
16+
417
4.6.15 (2026-04-24)
518
~~~~~~~~~~~~~~~~~~~
619

source/isaaclab/isaaclab/utils/__init__.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ __all__ = [
5656
"compare_versions",
5757
"configclass",
5858
"resolve_cfg_presets",
59+
"checked_apply",
5960
]
6061

6162
from .timer import Timer
@@ -106,4 +107,4 @@ from .string import (
106107
)
107108
from .types import ArticulationActions
108109
from .version import has_kit, get_isaac_sim_version, compare_versions
109-
from .configclass import configclass, resolve_cfg_presets
110+
from .configclass import checked_apply, configclass, resolve_cfg_presets

source/isaaclab/isaaclab/utils/configclass.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

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

8+
import dataclasses
89
import inspect
910
import re
1011
import types
@@ -633,3 +634,37 @@ def resolve_cfg_presets(cfg: object) -> object:
633634
else:
634635
resolve_cfg_presets(value)
635636
return cfg
637+
638+
639+
def checked_apply(src: Any, target: Any) -> None:
640+
"""Forward every declared field on ``src`` (a dataclass) onto ``target``.
641+
642+
Used by Isaac Lab configclasses that mirror an upstream/external dataclass
643+
(for example, Newton's ``ShapeConfig``): declare the overridable fields
644+
once on the wrapper, then forward them to the upstream object via this
645+
helper instead of writing ``setattr`` lines per field.
646+
647+
Raises :class:`AttributeError` if ``target`` is missing a field declared
648+
on ``src``. The two structures must match — the check guards against
649+
silent no-ops when the upstream API drifts (the bug class PR #5289 fixed
650+
for Newton ``ShapeConfig.contact_margin`` → ``margin``).
651+
652+
Args:
653+
src: Dataclass instance whose declared fields will be forwarded.
654+
Field names live here; this is the single source of truth.
655+
target: Object to receive the field values. Must already expose
656+
an attribute for every declared field on ``src``.
657+
658+
Raises:
659+
AttributeError: If ``target`` does not already have an attribute
660+
matching one of ``src``'s declared field names.
661+
"""
662+
if not hasattr(src, "__dataclass_fields__"):
663+
raise TypeError(f"checked_apply: src must be a dataclass, got {type(src).__name__}")
664+
for f in dataclasses.fields(src):
665+
if not hasattr(target, f.name):
666+
target_path = f"{type(target).__module__}.{type(target).__name__}"
667+
raise AttributeError(
668+
f"{target_path} has no attribute `{f.name}`. {type(src).__name__} is out of sync with target."
669+
)
670+
setattr(target, f.name, getattr(src, f.name))

source/isaaclab/test/utils/test_configclass.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,3 +1145,66 @@ class ChildCfg(ParentCfg):
11451145
assert _field_module_dir(child, "class_type") == "some_package.sub_package"
11461146
# extra should resolve to the child's module dir
11471147
assert _field_module_dir(child, "extra") == "test_some_feature"
1148+
1149+
1150+
# =============================================================================
1151+
# Tests: checked_apply
1152+
# =============================================================================
1153+
1154+
1155+
def test_checked_apply_forwards_all_fields():
1156+
"""checked_apply forwards every declared field on src onto target."""
1157+
from dataclasses import dataclass as plain_dataclass
1158+
1159+
from isaaclab.utils import checked_apply
1160+
1161+
@configclass
1162+
class WrapperCfg:
1163+
gap: float = 0.01
1164+
margin: float = 0.0
1165+
1166+
@plain_dataclass
1167+
class UpstreamLike:
1168+
gap: float = 99.0
1169+
margin: float = 99.0
1170+
unrelated: str = "keep me"
1171+
1172+
src = WrapperCfg(margin=0.005)
1173+
target = UpstreamLike()
1174+
checked_apply(src, target)
1175+
1176+
assert target.gap == 0.01
1177+
assert target.margin == 0.005
1178+
# fields not declared on src are not touched
1179+
assert target.unrelated == "keep me"
1180+
1181+
1182+
def test_checked_apply_raises_on_missing_target_field():
1183+
"""checked_apply fails loudly when target lacks a declared field."""
1184+
from dataclasses import dataclass as plain_dataclass
1185+
1186+
from isaaclab.utils import checked_apply
1187+
1188+
@configclass
1189+
class WrapperCfg:
1190+
margin: float = 0.01
1191+
renamed_in_upstream: float = 0.0
1192+
1193+
@plain_dataclass
1194+
class UpstreamMissingField:
1195+
margin: float = 0.0
1196+
# 'renamed_in_upstream' was renamed/removed upstream
1197+
1198+
with pytest.raises(AttributeError, match="renamed_in_upstream"):
1199+
checked_apply(WrapperCfg(), UpstreamMissingField())
1200+
1201+
1202+
def test_checked_apply_rejects_non_dataclass_src():
1203+
"""checked_apply requires src to be a dataclass."""
1204+
from isaaclab.utils import checked_apply
1205+
1206+
class NotADataclass:
1207+
margin = 0.01
1208+
1209+
with pytest.raises(TypeError, match="must be a dataclass"):
1210+
checked_apply(NotADataclass(), object())

0 commit comments

Comments
 (0)