Skip to content

Fix update_class_from_dict rejecting union-annotated overrides - #7873

Open
harshal-96 wants to merge 2 commits into
isaac-sim:developfrom
harshal-96:fix/update-class-from-dict-union-annotations
Open

harshal-96 wants to merge 2 commits into
isaac-sim:developfrom
harshal-96:fix/update-class-from-dict-union-annotations

Conversation

@harshal-96

Copy link
Copy Markdown

Description

update_class_from_dict checks an override value's type against the stored value's runtime type. A field annotated as a union and currently holding one member of it therefore rejects every override of the other member type. The reported case: seed: int | None defaults to None, so from_dict({"seed": 123}) on an environment config raises [Config]: Incorrect type under namespace: /seed. Expected: <class 'NoneType'>, Received: <class 'int'>.

This PR makes the type check consult the field's type annotation before rejecting a value whose type differs from the stored value's:

  • _annotation_admits_value checks a value against an annotation, member-wise for unions (both X | Y and typing.Union[X, Y]) and against the origin container for parameterized generics, mirroring the runtime check it backs up.
  • _field_annotation_admits_value resolves the annotation via typing.get_type_hints on the object's class. When no annotation can be resolved (plain dicts, unannotated attributes, unresolvable forward references), it returns False and the stored-value check stays authoritative, so existing behavior is preserved everywhere outside the union case.
  • The annotation lookup only runs on the previously-fatal path, so configs that worked before take the exact same code path as before.

The acceptance criterion from the issue (the MRE runs without raising) now holds:

@configclass
class TestConfig:
    param: int | None = None

config = TestConfig()
config.from_dict({"param": 42})  # previously raised ValueError, now sets 42

Fixes #3236

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Testing

Three new regression tests in source/isaaclab/test/utils/test_configclass.py:

  • test_config_update_dict_union_annotated_none: the reported failure; fails on develop, passes with the fix.
  • test_config_update_dict_union_rejects_non_member_type: a value outside the union is still rejected.
  • test_config_update_dict_type_mismatch_without_annotation: unannotated attributes keep the stored-value type check.

Results (Windows, Isaac Sim 6.0.0-rc.59 install, its bundled Python 3.12.13 / torch 2.10.0+cu128):

  • pytest source/isaaclab/test/utils/test_dict.py source/isaaclab/test/utils/test_configclass.py: 59 passed (56 baseline + 3 new), run with the Isaac Sim python.bat.
  • The verbatim MRE from [Bug Report] update_class_from_dict does not handle union types properly #3236 (with AppLauncher(headless=True, kit_args="--no-window") booting the real simulator): raises the reported ValueError on develop, prints param = 42 with this PR.
  • Verified the new union test fails on develop before the fix and passes after; the two guard tests pass on both, confirming they pin pre-existing behavior.
  • ruff check --fix and ruff format (pinned v0.14.10 from .pre-commit-config.yaml) produce no further changes; tools/changelog/cli.py check develop passes.

Checklist

  • I have read and understood the contribution guidelines
  • I have run the pre-commit checks
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • I have added a changelog fragment under source/<pkg>/changelog.d/ for every touched package
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

update_class_from_dict checked an override value's type against the
stored value's runtime type. A field annotated as a union and currently
holding one member of it (e.g. seed: int | None defaulting to None)
therefore rejected every override of the other member type with
'[Config]: Incorrect type under namespace'.

Consult the field's type annotation before rejecting a value whose type
differs from the stored value's. Objects without a resolvable annotation
for the key keep the stored-value check, so plain dicts and unannotated
attributes behave as before.

Fixes isaac-sim#3236

Signed-off-by: harshal-96 <harshal.dhandrut@gmail.com>
@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

The PR is not yet safe to merge because valid overrides can still fail for Literal unions and classes containing unresolved forward references.

Findings

  1. P1 Literal Origins Raise TypeError
  2. P1 Unrelated Hint Blocks Overrides

Summary

This PR extends update_class_from_dict to consult class annotations when an override differs from the stored runtime type, allowing values from another member of a union-annotated field. It also adds regression tests, a changelog fragment, and a contributor entry.

  • Supports both PEP 604 and typing.Union annotations.
  • Preserves stored-runtime-type validation when no annotation can be resolved.
  • Contains unresolved edge cases for special typing forms and classes with runtime-unresolvable annotations.

Reviews (1) · Last reviewed commit: "Fix update_class_from_dict rejecting uni..."

Comment thread source/isaaclab/isaaclab/utils/dict.py Outdated
Comment on lines +106 to +107
if origin is not None:
return isinstance(value, origin)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Literal origins raise TypeError

A valid override for an existing Literal[...] | None field, such as drive_type: Literal["force", "acceleration"] | None = None, reaches this code with origin set to typing.Literal. Because typing.Literal cannot be passed to isinstance, the update raises TypeError instead of accepting the valid literal value. Invalid values can also leak TypeError instead of the documented ValueError.

Comment thread source/isaaclab/isaaclab/utils/dict.py Outdated
Comment on lines +134 to +139
try:
hints = get_type_hints(type(obj))
except Exception:
# unresolvable forward references or exotic annotations; fall back to
# the stored-value type check
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated hint blocks overrides

Resolving every annotation on the class means one unrelated unresolved forward reference disables annotation-based validation for all fields. VisualizerCfg, for example, references BaseVisualizer, which is imported only under TYPE_CHECKING; resolving that class's hints therefore fails and returns False. Valid updates to its other union-annotated fields then fall back to the stored NoneType check and are rejected, so the union fix remains incomplete for these config classes.

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isaac Lab Review Bot

The targeted relaxation fixes union-annotated scalar overrides while preserving prior successful paths, but the annotation walker can raise TypeError for union members whose typing origin is not a runtime class.

  • Design and architecture: The private helpers keep annotation handling localized to the previously fatal scalar mismatch branch. However, the generic-origin abstraction assumes all origins are valid isinstance targets, which is not true for typing special forms such as Literal.
  • API: The public signature remains unchanged and ordinary union overrides are widened as intended. For annotations such as Literal["a", "b"] | None, however, an override can now leak TypeError rather than completing the update or producing the documented mismatch ValueError.
  • Implementation: Union recursion, NoneType, and plain runtime classes are handled correctly. The unconditional isinstance(value, origin) must guard against non-class typing origins or otherwise handle them explicitly so annotation inspection cannot crash.

Minor fixes needed. Posted 1 actionable finding inline.

Automated review; human maintainers own approval decisions.

Comment thread source/isaaclab/isaaclab/utils/dict.py Outdated
if origin is Union or origin is types.UnionType:
return any(_annotation_admits_value(arg, value) for arg in get_args(annotation))
if origin is not None:
return isinstance(value, origin)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Implementation — Non-class annotation origins crash isinstance check

get_origin() returns non-class special forms for annotations such as Literal[...] or ClassVar[...]. For a field annotated Literal["a", "b"] | None = None holding None, an override reaches the union recursion and then isinstance(value, typing.Literal), raising TypeError: typing.Literal cannot be used with isinstance() instead of accepting the value or raising the ValueError documented by update_class_from_dict. Guard with isinstance(origin, type) and return False otherwise.

…ion checks

Address the two review findings on the annotation-based type check:

- A Literal member of a union (e.g. mode: Literal['force', 'acceleration']
  | None) reached isinstance(value, typing.Literal), which raises
  TypeError. Literal members are now checked by value, with type equality
  so bools do not match int literals, and other non-class typing origins
  are not admitted instead of raising.

- typing.get_type_hints resolves every annotation on the class at once,
  so one name imported only under TYPE_CHECKING (e.g. BaseVisualizer in
  VisualizerCfg) disabled the union fix for all fields of that class.
  The lookup now walks the MRO and evaluates only the requested field's
  annotation, and when that annotation is itself a union with
  unresolvable members it evaluates the members individually, so
  class_type: type[BaseVisualizer] | str | None accepts a string
  override through the str member. Unresolvable members are skipped,
  which can only widen acceptance, never reject values the stored-value
  check would have accepted.

Signed-off-by: harshal-96 <harshal.dhandrut@gmail.com>
@harshal-96

Copy link
Copy Markdown
Author

Both findings addressed in 922a624. Literal members of a union are now checked by value, with type equality so bools do not match int literals, and non-class typing origins are not admitted instead of raising. The annotation lookup no longer uses get_type_hints: it walks the MRO and evaluates only the requested field's annotation, and for a string union with unresolvable members it evaluates the members individually, so class_type on VisualizerCfg accepts a string override through the str member. Added three regression tests covering the Literal case, the unresolvable sibling field, and the partially resolvable union; all fail on the previous head and pass now. Full suites: 62 passed under both a plain Python 3.12 env and the Isaac Sim 6.0 bundled python.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] update_class_from_dict does not handle union types properly

1 participant