From fd4853cc8ddd5e304070a84f20422237c0a643be Mon Sep 17 00:00:00 2001 From: Bahtya Date: Thu, 9 Apr 2026 18:17:50 +0800 Subject: [PATCH 1/2] fix(viewer): support cbor2 6.x deserialization changes cbor2 6.0 introduces two breaking changes in deserialization: - Nested dicts return as `cbor2.frozendict` (a Mapping, but no longer a `dict` subclass) - Arrays return as `tuple` instead of `list` The `isinstance(data, dict)` guards in `deserialize()`, `transfer_to_model()`, and `_resolve_cache_refs()` silently short-circuit on `frozendict`, causing recorder round-trip tests to fail. Fix all three sites by switching to `isinstance(x, Mapping)` (already imported from `collections.abc`). Additionally update `_resolve_cache_refs` to treat `tuple` the same as `list` when iterating, and always return a plain `list` so callers aren't surprised by the cbor2 change. Also removes the `<6` upper bound on the cbor2 dependency that was added as a short-term workaround in #2373, and updates the install hint in the ImportError message. Fixes #2375 Bahtya --- newton/_src/viewer/viewer_file.py | 30 +++++++++++++++++------------- pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/newton/_src/viewer/viewer_file.py b/newton/_src/viewer/viewer_file.py index b847dbadc9..c64b953f06 100644 --- a/newton/_src/viewer/viewer_file.py +++ b/newton/_src/viewer/viewer_file.py @@ -235,7 +235,7 @@ def _get_serialization_format(file_path: str) -> str: return "json" elif ext == ".bin": if not HAS_CBOR2: - raise ImportError("cbor2 library is required for .bin files. Install with: pip install 'cbor2>=5.7.0,<6'") + raise ImportError("cbor2 library is required for .bin files. Install with: pip install 'cbor2>=5.7.0'") return "cbor2" else: raise ValueError(f"Unsupported file extension '{ext}'. Supported extensions: .json, .bin") @@ -614,8 +614,9 @@ def transfer_to_model(source_dict, target_obj, post_load_init_callback=None, _pa if not hasattr(target_obj, "__dict__"): return - # Handle case where source_dict is not a dict (primitive value) - if not isinstance(source_dict, dict): + # Handle case where source_dict is not a mapping (primitive value). + # Use Mapping to support cbor2 6.x frozendict. + if not isinstance(source_dict, Mapping): return # Iterate through all attributes of the target object @@ -641,7 +642,7 @@ def transfer_to_model(source_dict, target_obj, post_load_init_callback=None, _pa continue # Handle different types of values - if hasattr(target_value, "__dict__") and isinstance(source_value, dict): + if hasattr(target_value, "__dict__") and isinstance(source_value, Mapping): # Recursively transfer for custom objects # Build path only when needed (optimization: lazy string formatting) current_path = f"{_path}.{attr_name}" if _path else attr_name @@ -693,8 +694,10 @@ def deserialize(data, callback, _path="", format_type="json", cache: ArrayCache if result is not data: return result - # If not a dict with __type__, return as-is - if not isinstance(data, dict) or "__type__" not in data: + # If not a mapping with __type__, return as-is. + # Use Mapping rather than dict to support cbor2 6.x, which returns frozendict + # (a Mapping but not a dict subclass) instead of plain dict. + if not isinstance(data, Mapping) or "__type__" not in data: return data type_name = data["__type__"] @@ -909,8 +912,9 @@ def depointer_as_key(data: dict, format_type: str = "json", cache: ArrayCache | """ def callback(x, path): - # Optimization: extract type once to avoid repeated isinstance and dict lookups - x_type = x.get("__type__") if isinstance(x, dict) else None + # Optimization: extract type once to avoid repeated isinstance and dict lookups. + # Use Mapping to support cbor2 6.x frozendict (not a dict subclass). + x_type = x.get("__type__") if isinstance(x, Mapping) else None if x_type == "warp.array_ref": if cache is None: @@ -997,19 +1001,19 @@ def callback(x, path): result = deserialize(data, callback, format_type=format_type, cache=cache) def _resolve_cache_refs(obj): - if isinstance(obj, dict): + # Use Mapping to support cbor2 6.x frozendict (not a dict subclass). + if isinstance(obj, Mapping): # Optimization: single dict lookup instead of checking membership then accessing cache_ref = obj.get("__cache_ref__") if cache_ref is not None: idx = int(cache_ref["index"]) # Will raise KeyError with clear message if still missing return cache.try_get_value(idx) if cache is not None else obj - # Recurse into dict + # Recurse into mapping; always return a plain dict return {k: _resolve_cache_refs(v) for k, v in obj.items()} - if isinstance(obj, list): + # cbor2 6.x returns tuple instead of list for arrays; handle both + if isinstance(obj, (list, tuple)): return [_resolve_cache_refs(v) for v in obj] - if isinstance(obj, tuple): - return tuple(_resolve_cache_refs(v) for v in obj) if isinstance(obj, set): return {_resolve_cache_refs(v) for v in obj} return obj diff --git a/pyproject.toml b/pyproject.toml index 4d55ffb450..c3db9b8c36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ examples = [ "GitPython>=3.1.44", # for downloading assets via newton.utils.download_asset() "imgui_bundle>=1.92.0", # for viewer GUI "pyyaml>=6.0.2", - "cbor2>=5.7.0, <6", # for binary recording format (.bin files) - more efficient than JSON + "cbor2>=5.7.0", # for binary recording format (.bin files) - more efficient than JSON "Pillow>=9.0.0", # for image processing (viewer icons, heightfield loading, textures) ] From 21de760a9bc17304f8fde4b4a192ed0d19e94cd1 Mon Sep 17 00:00:00 2001 From: Bahtya Date: Fri, 10 Apr 2026 01:27:36 +0800 Subject: [PATCH 2/2] fix(viewer): preserve real tuples in _resolve_cache_refs The previous implementation converted both lists and tuples to lists in the post-pass, causing genuine tuple-valued fields to round-trip as a different type. Now lists resolve to lists and tuples resolve to tuples, preserving the original container type for all non-cache-ref fields. Bahtya --- newton/_src/viewer/viewer_file.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/newton/_src/viewer/viewer_file.py b/newton/_src/viewer/viewer_file.py index c64b953f06..107c26aae4 100644 --- a/newton/_src/viewer/viewer_file.py +++ b/newton/_src/viewer/viewer_file.py @@ -1011,9 +1011,10 @@ def _resolve_cache_refs(obj): return cache.try_get_value(idx) if cache is not None else obj # Recurse into mapping; always return a plain dict return {k: _resolve_cache_refs(v) for k, v in obj.items()} - # cbor2 6.x returns tuple instead of list for arrays; handle both - if isinstance(obj, (list, tuple)): + if isinstance(obj, list): return [_resolve_cache_refs(v) for v in obj] + if isinstance(obj, tuple): + return tuple(_resolve_cache_refs(v) for v in obj) if isinstance(obj, set): return {_resolve_cache_refs(v) for v in obj} return obj