Skip to content

Commit fcfb8ac

Browse files
committed
Add checked_apply helper for forwarding configclass fields onto upstream dataclasses
When an Isaac Lab configclass mirrors an upstream library's dataclass (for example NewtonShapeCfg → 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 isaac-sim#5289 fixed for ShapeConfig.contact_margin → margin). Add isaaclab.utils.checked_apply(src, target): - Iterates fields(src) — single source of truth for declared fields - Raises AttributeError if target lacks a declared field — failure surfaces at startup, not at runtime - One-line apply at the call site, no per-field setattr noise Documents the IL pattern of 'wrap upstream cfg + checked_apply' in AGENTS.md so future cross-dep writes adopt it.
1 parent 63da1f4 commit fcfb8ac

6 files changed

Lines changed: 131 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@
4141

4242
- **Avoid adding new required dependencies.** IsaacLab's core should remain lightweight and minimize external requirements.
4343
- **Strongly prefer not adding new optional dependencies.** If additional functionality requires a new package, carefully consider whether the benefit justifies the added complexity and maintenance burden. When possible, implement functionality using existing dependencies, including Warp functions and kernels, NumPy, or the standard library.
44+
- **Mirror upstream library configs with an Isaac Lab wrapper, then forward via :func:`isaaclab.utils.checked_apply`.** Plain ``@dataclass`` types and modules silently accept unknown attribute writes, so an upstream rename turns a bare ``setattr`` into a dead no-op (see PR #5289). Define an IL wrapper that lists only the fields you override, then call ``checked_apply`` once at the apply site:
45+
46+
```python
47+
# IL wrapper (source of truth for IL defaults)
48+
@configclass
49+
class NewtonShapeCfg:
50+
gap: float = 0.01
51+
margin: float = 0.0
52+
53+
# apply site
54+
from isaaclab.utils import checked_apply
55+
checked_apply(cfg.default_shape_cfg, builder.default_shape_cfg)
56+
```
57+
58+
``checked_apply`` raises :class:`AttributeError` if upstream renames or removes a declared field, so the failure surfaces at startup instead of silently degrading runtime behavior.
4459

4560
## Tooling: prefer `./isaaclab.sh -p` for running, testing, and benchmarking
4661

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.12"
4+
version = "4.6.13"
55

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

source/isaaclab/docs/CHANGELOG.rst

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

4+
4.6.13 (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+
Documents the IL pattern of "wrapper + checked_apply" in ``AGENTS.md``
16+
for cross-dep config writes.
17+
18+
419
4.6.12 (2026-04-23)
520
~~~~~~~~~~~~~~~~~~~
621

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
@@ -10,6 +10,7 @@
1010
import types
1111
from collections.abc import Callable
1212
from copy import deepcopy
13+
import dataclasses
1314
from dataclasses import MISSING, Field, dataclass, field, replace
1415
from typing import Any, ClassVar
1516

@@ -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)