diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 275dd5eb134c..0f93bbd09d09 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -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" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 544a6c4ab1b7..fdfcc95e4738 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -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) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 1ca7ef7866c6..e9f5e201ca21 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -56,6 +56,7 @@ __all__ = [ "compare_versions", "configclass", "resolve_cfg_presets", + "checked_apply", ] from .timer import Timer @@ -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 diff --git a/source/isaaclab/isaaclab/utils/configclass.py b/source/isaaclab/isaaclab/utils/configclass.py index 7b0ed789bceb..59605f2797cc 100644 --- a/source/isaaclab/isaaclab/utils/configclass.py +++ b/source/isaaclab/isaaclab/utils/configclass.py @@ -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 @@ -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)) diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 716c834cc3e1..60f77367e066 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -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())