deepdiff-rs 0.9.1__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/Cargo.lock +4 -4
  2. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/PKG-INFO +7 -7
  4. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/README.md +6 -6
  5. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/array.rs +32 -11
  6. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/dispatch.rs +63 -20
  7. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/mod.rs +2 -2
  8. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/object.rs +2 -2
  9. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/scalar.rs +17 -7
  10. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/tests.rs +263 -65
  11. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/distance.rs +44 -21
  12. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/hash.rs +11 -7
  13. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/tests.rs +53 -87
  14. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/lcs.rs +5 -3
  15. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/path.rs +186 -96
  16. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/report.rs +22 -22
  17. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/report_tests.rs +10 -7
  18. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/test_support.rs +28 -6
  19. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/unified_diff.rs +11 -1
  20. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/value.rs +758 -61
  21. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/value_tests.rs +478 -19
  22. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/tests/golden.rs +20 -7
  23. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/tests/ignore_order_memory.rs +9 -8
  24. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/deepdiff_rs.pyi +7 -1
  25. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/convert.rs +416 -173
  26. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/deepdiff.rs +40 -17
  27. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/fast_path.rs +5 -2
  28. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/guard.rs +116 -49
  29. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/lib.rs +1 -0
  30. deepdiff_rs-0.9.3/crates/onix-py/tests/test_conversions.py +564 -0
  31. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_datetimes.py +76 -0
  32. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_differential_fuzz.py +311 -9
  33. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_sets.py +49 -11
  34. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_stub_signatures.py +20 -2
  35. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_tuples.py +41 -22
  36. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/pyproject.toml +9 -6
  37. deepdiff_rs-0.9.1/crates/onix-py/tests/test_conversions.py +0 -404
  38. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/Cargo.toml +0 -0
  39. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
  40. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  41. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/error.rs +0 -0
  42. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/json_rows.rs +0 -0
  43. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/lib.rs +0 -0
  44. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/options.rs +0 -0
  45. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/row_diff.rs +0 -0
  46. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/schema.rs +0 -0
  47. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/table_diff.rs +0 -0
  48. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/Cargo.toml +0 -0
  49. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  50. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/datetime.rs +0 -0
  51. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/datetime_tests.rs +0 -0
  52. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/options.rs +0 -0
  53. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/set.rs +0 -0
  54. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/error.rs +0 -0
  55. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
  56. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/memo.rs +0 -0
  57. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  58. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  59. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/lcs_tests.rs +0 -0
  60. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/lib.rs +0 -0
  61. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  62. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/tests/memory_footprint.rs +0 -0
  63. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/tests/proptest_diff.rs +0 -0
  64. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  65. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/.python-version +0 -0
  66. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/Cargo.toml +0 -0
  67. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  68. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/arrow.rs +0 -0
  69. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/src/errors.rs +0 -0
  70. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/conftest.py +0 -0
  71. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  72. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_depth_guard.py +0 -0
  73. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_golden_parity.py +0 -0
  74. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_non_finite.py +0 -0
  75. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_signed_zero.py +0 -0
  76. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_smoke.py +0 -0
  77. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  78. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  79. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_table_diff.py +0 -0
  80. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  81. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_timedeltas.py +0 -0
  82. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_times.py +0 -0
  83. {deepdiff_rs-0.9.1 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_wheel_contents.py +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.9.1"
632
+ version = "0.9.3"
633
633
  dependencies = [
634
634
  "arrow-array",
635
635
  "arrow-buffer",
@@ -646,7 +646,7 @@ dependencies = [
646
646
 
647
647
  [[package]]
648
648
  name = "onix-cli"
649
- version = "0.9.1"
649
+ version = "0.9.3"
650
650
  dependencies = [
651
651
  "onix-core",
652
652
  "serde_json",
@@ -654,7 +654,7 @@ dependencies = [
654
654
 
655
655
  [[package]]
656
656
  name = "onix-core"
657
- version = "0.9.1"
657
+ version = "0.9.3"
658
658
  dependencies = [
659
659
  "proptest",
660
660
  "serde",
@@ -665,7 +665,7 @@ dependencies = [
665
665
 
666
666
  [[package]]
667
667
  name = "onix-py"
668
- version = "0.9.1"
668
+ version = "0.9.3"
669
669
  dependencies = [
670
670
  "arrow-array",
671
671
  "arrow-ipc",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py", "crates/onix-arrow"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.9.1"
6
+ version = "0.9.3"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepdiff-rs
3
- Version: 0.9.1
3
+ Version: 0.9.3
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -148,7 +148,7 @@ A key appearing more than once on either side is reported in `duplicate_keys` (w
148
148
 
149
149
  Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` consumes it with no pyarrow needed, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but pandas' own implementation of that protocol needs pyarrow installed regardless of which path you take.
150
150
 
151
- `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
151
+ `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra; `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
152
152
 
153
153
  ## Performance
154
154
 
@@ -195,11 +195,11 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
195
195
  | `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
196
196
  | `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
197
197
 
198
- Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)).
198
+ Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on a seeded 5 GB parquet pair and its 1M-row subset — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): onix's single-threaded row diff trails both parallelized baselines at both sizes; see the linked results for the figures; regenerated with `perf/arrow/bench_tables.py`.
199
199
 
200
200
  ## Reference
201
201
 
202
- **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
202
+ **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, `MAX_DEPTH_CEILING`, and `__version__` (a `str` matching the installed `deepdiff-rs` distribution version).
203
203
 
204
204
  - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be); `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict — with Python types preserved, so a value the diff found in a `tuple`, `set` or `frozenset` comes back as one and a `datetime`/`date`/`time`/`timedelta` comes back as a real one of those — and the instance is falsy when there is no difference. The `set_item_added`/`set_item_removed` categories are lists of path strings, each ending in the item itself (`root['a'][2]`, `root['x']`, `root[(1, 2)]`).
205
205
  - `diff_json(a, b, ignore_order=False, max_depth=None) -> str`: diffs two JSON strings entirely in Rust and returns the report as a JSON string.
@@ -236,12 +236,12 @@ perf/ # cross-language benchmark harness and RESULTS.md
236
236
  ## Known limitations
237
237
 
238
238
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
239
- - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested). `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
240
- - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date`/`time`/`timedelta` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
239
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
240
+ - A subclass of a supported `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, or `datetime.timedelta` (including `namedtuple` and pandas' `Timestamp`) converts and compares as its base type but carries its own class name into a `type_changes` entry, matching DeepDiff's `type(obj).__name__` reporting, with three exceptions: a calendar-type (`datetime`/`date`/`time`/`timedelta`) subclass held as a `set`/`frozenset` member compares as its base with no `type_changes`, matching DeepDiff, since set membership has no pairwise comparison to report one against; a `tuple`/`frozenset` subclass, including a `namedtuple`, is not accepted as a `set`/`frozenset` member at all and raises `TypeError` where DeepDiff accepts and compares it by value (a documented divergence); and `namedtuple` diffs positionally rather than by field (also documented). A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. See [`tests/golden/README.md`](tests/golden/README.md).
241
241
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
242
242
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
243
243
  - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
244
- - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
244
+ - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
245
245
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
246
246
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
247
247
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
@@ -130,7 +130,7 @@ A key appearing more than once on either side is reported in `duplicate_keys` (w
130
130
 
131
131
  Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` consumes it with no pyarrow needed, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but pandas' own implementation of that protocol needs pyarrow installed regardless of which path you take.
132
132
 
133
- `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
133
+ `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra; `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
134
134
 
135
135
  ## Performance
136
136
 
@@ -177,11 +177,11 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
177
177
  | `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
178
178
  | `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
179
179
 
180
- Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)).
180
+ Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on a seeded 5 GB parquet pair and its 1M-row subset — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): onix's single-threaded row diff trails both parallelized baselines at both sizes; see the linked results for the figures; regenerated with `perf/arrow/bench_tables.py`.
181
181
 
182
182
  ## Reference
183
183
 
184
- **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
184
+ **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, `MAX_DEPTH_CEILING`, and `__version__` (a `str` matching the installed `deepdiff-rs` distribution version).
185
185
 
186
186
  - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions, including which types a `dict` key may be); `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict — with Python types preserved, so a value the diff found in a `tuple`, `set` or `frozenset` comes back as one and a `datetime`/`date`/`time`/`timedelta` comes back as a real one of those — and the instance is falsy when there is no difference. The `set_item_added`/`set_item_removed` categories are lists of path strings, each ending in the item itself (`root['a'][2]`, `root['x']`, `root[(1, 2)]`).
187
187
  - `diff_json(a, b, ignore_order=False, max_depth=None) -> str`: diffs two JSON strings entirely in Rust and returns the report as a JSON string.
@@ -218,12 +218,12 @@ perf/ # cross-language benchmark harness and RESULTS.md
218
218
  ## Known limitations
219
219
 
220
220
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
221
- - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested). `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
222
- - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date`/`time`/`timedelta` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
221
+ - Supported value types are `None`, `bool`, `int`, `float` (`NaN`/`Infinity`/`-Infinity` included), `str`, `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests, and a `dict` key may be `str`, `None`, `bool`, `int`, `float`, `datetime.datetime`, `datetime.date`, or a `tuple` of those (never nested), or a `tuple`/`datetime`/`date` subclass key (`namedtuple` included), accepted and matched against its base-type value the same way — see [Known limitations](#known-limitations)'s subclass bullet. `int`s must fit in `i64`/`u64`, and anything else — a custom object or an arbitrary-precision `int` — raises `TypeError`/`ValueError` naming the exact path it was found at. A non-`str` key's path renders via Python's own `repr()`, except a `tuple` key, which splits into one bracket group per element (`root[1][2]` for `(1, 2)`, never `root[(1, 2)]`, with one deliberate exception for a real DeepDiff bug on the empty tuple, and another on a non-finite-float key — see [`tests/golden/README.md`](tests/golden/README.md)). A nested dict value's own `bool`/`None`/`int`/`float` key stringifies the way `json.dumps` does, but its `datetime`/`date`/`tuple` key renders that same `repr()` text where DeepDiff's own `to_json()` raises `TypeError` on one — a superset, not a difference in the findings (see [`tests/golden/README.md`](tests/golden/README.md)). `1`/`1.0`/`True` match as the same key between two dicts (Python `dict`/`set` equality). The **Datetimes**, **Sets** and **Non-finite floats** bullets below cover the deliberate divergences for those types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
222
+ - A subclass of a supported `dict`, `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, or `datetime.timedelta` (including `namedtuple` and pandas' `Timestamp`) converts and compares as its base type but carries its own class name into a `type_changes` entry, matching DeepDiff's `type(obj).__name__` reporting, with three exceptions: a calendar-type (`datetime`/`date`/`time`/`timedelta`) subclass held as a `set`/`frozenset` member compares as its base with no `type_changes`, matching DeepDiff, since set membership has no pairwise comparison to report one against; a `tuple`/`frozenset` subclass, including a `namedtuple`, is not accepted as a `set`/`frozenset` member at all and raises `TypeError` where DeepDiff accepts and compares it by value (a documented divergence); and `namedtuple` diffs positionally rather than by field (also documented). A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. See [`tests/golden/README.md`](tests/golden/README.md).
223
223
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
224
224
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
225
225
  - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
226
- - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
226
+ - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
227
227
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
228
228
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
229
229
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
@@ -3,7 +3,7 @@
3
3
  //! the parent `diff` module's "List diffing" doc section for the full,
4
4
  //! empirically-verified spec this implements.
5
5
 
6
- use crate::value::Value;
6
+ use crate::value::{Value, class_name};
7
7
 
8
8
  use crate::error::Error;
9
9
  use crate::ignore_order::{self, IgnoreOrderMemo};
@@ -12,8 +12,8 @@ use crate::path::PathSegment;
12
12
  use crate::report::{Report, TypeChangeEntry, ValuesChangedEntry};
13
13
 
14
14
  use super::{
15
- DiffOptions, check_traversal_depth, check_value_depth, diff_at, normalized_pair,
16
- python_type_name, scoped,
15
+ DiffOptions, check_traversal_depth, check_value_depth, diff_at, effective_type_name,
16
+ normalized_pair, python_type_name, scoped,
17
17
  };
18
18
 
19
19
  /// Diffs two lists (JSON arrays) at `path`, `depth` levels deep.
@@ -178,17 +178,36 @@ fn insert_lcs_pair_finding(
178
178
  new_segments
179
179
  });
180
180
 
181
- if let (Value::DateTime(old_value), Value::DateTime(new_value)) = (old_value, new_value) {
182
- let (old_value, new_value) = normalized_pair(path, *old_value, *new_value)?;
181
+ if let (Value::DateTime(old_typed), Value::DateTime(new_typed)) = (old_value, new_value) {
182
+ // A `datetime` subclass (e.g. pandas `Timestamp`) still reports
183
+ // `type_changes` against a base `datetime` (or a differently
184
+ // named subclass) even at equal value — see `Typed`'s doc — so
185
+ // this check must run before the normalize-and-compare path
186
+ // below, which only ever produces `values_changed`.
187
+ if old_typed.class_name() != new_typed.class_name() {
188
+ report.insert_type_change(
189
+ path.clone(),
190
+ TypeChangeEntry {
191
+ old_type: effective_type_name(old_value),
192
+ new_type: effective_type_name(new_value),
193
+ old_value: old_value.clone(),
194
+ new_value: new_value.clone(),
195
+ new_path,
196
+ },
197
+ );
198
+ return Ok(());
199
+ }
200
+
201
+ let (old_norm, new_norm) = normalized_pair(path, old_typed.value(), new_typed.value())?;
183
202
 
184
- if old_value != new_value {
203
+ if old_norm != new_norm {
185
204
  report.insert_values_changed(
186
205
  path.clone(),
187
206
  ValuesChangedEntry {
188
207
  // Datetimes are never strings, so `_diff_str` never runs.
189
208
  diff: None,
190
- old_value: Value::DateTime(old_value),
191
- new_value: Value::DateTime(new_value),
209
+ old_value: Value::DateTime(old_norm.into()),
210
+ new_value: Value::DateTime(new_norm.into()),
192
211
  new_path,
193
212
  },
194
213
  );
@@ -197,7 +216,9 @@ fn insert_lcs_pair_finding(
197
216
  return Ok(());
198
217
  }
199
218
 
200
- if python_type_name(old_value) == python_type_name(new_value) {
219
+ if python_type_name(old_value) == python_type_name(new_value)
220
+ && class_name(old_value) == class_name(new_value)
221
+ {
201
222
  report.insert_values_changed(
202
223
  path.clone(),
203
224
  ValuesChangedEntry {
@@ -211,8 +232,8 @@ fn insert_lcs_pair_finding(
211
232
  report.insert_type_change(
212
233
  path.clone(),
213
234
  TypeChangeEntry {
214
- old_type: python_type_name(old_value).to_string(),
215
- new_type: python_type_name(new_value).to_string(),
235
+ old_type: effective_type_name(old_value),
236
+ new_type: effective_type_name(new_value),
216
237
  old_value: old_value.clone(),
217
238
  new_value: new_value.clone(),
218
239
  new_path,
@@ -6,7 +6,7 @@
6
6
  //! See the parent `diff` module's doc for the full recursion-depth hardening
7
7
  //! (its "Hardening" section) this file implements.
8
8
 
9
- use crate::value::{Object, Value};
9
+ use crate::value::{Object, Value, class_name};
10
10
 
11
11
  use crate::error::Error;
12
12
  use crate::ignore_order::IgnoreOrderMemo;
@@ -18,6 +18,18 @@ use super::{
18
18
  type_change_report,
19
19
  };
20
20
 
21
+ /// Whether `a` and `b` — already known to be the same [`Value`] variant —
22
+ /// carry different subclass names, per [`class_name`]. `diff_at` checks
23
+ /// this before recursing into any of the eight variants that can carry one
24
+ /// (`DateTime`, `Date`, `Time`, `TimeDelta`, `Array`, `Tuple`,
25
+ /// `Set`/`FrozenSet`, `Object`): a mismatch means `DeepDiff` would report
26
+ /// `type_changes` here even though the values are the same JSON-ish shape —
27
+ /// see [`crate::value::Typed`]'s doc for why (and why every other matching
28
+ /// identity in the crate stays class-agnostic instead).
29
+ fn same_class(a: &Value, b: &Value) -> bool {
30
+ class_name(a) == class_name(b)
31
+ }
32
+
21
33
  /// The recursive core of [`diff_with_max_depth()`](super::diff_with_max_depth): identical dispatch, but
22
34
  /// carrying the path and depth accumulated so far, so that nested findings
23
35
  /// get their full deep path and the recursion-depth bound can be enforced.
@@ -55,33 +67,64 @@ pub(crate) fn diff_at(
55
67
  scalar_diff(path, old == new, a, b, depth, opts.max_depth)
56
68
  }
57
69
  (Value::DateTime(old), Value::DateTime(new)) => {
58
- datetime_diff(path, *old, *new, depth, opts.max_depth)
70
+ if same_class(a, b) {
71
+ datetime_diff(path, old.value(), new.value(), depth, opts.max_depth)
72
+ } else {
73
+ type_change_report(path, a, b, depth, opts.max_depth)
74
+ }
59
75
  }
60
76
  (Value::Date(old), Value::Date(new)) => {
61
- scalar_diff(path, old == new, a, b, depth, opts.max_depth)
77
+ if same_class(a, b) {
78
+ scalar_diff(path, old == new, a, b, depth, opts.max_depth)
79
+ } else {
80
+ type_change_report(path, a, b, depth, opts.max_depth)
81
+ }
62
82
  }
63
83
  (Value::Time(old), Value::Time(new)) => {
64
- // Plain `_diff_time` equality — no normalization step, unlike
65
- // `datetime_diff` (see `crate::datetime`'s module doc).
66
- scalar_diff(
67
- path,
68
- crate::datetime::times_equal(*old, *new),
69
- a,
70
- b,
71
- depth,
72
- opts.max_depth,
73
- )
84
+ if same_class(a, b) {
85
+ // Plain `_diff_time` equality — no normalization step,
86
+ // unlike `datetime_diff` (see `crate::datetime`'s module
87
+ // doc).
88
+ scalar_diff(
89
+ path,
90
+ crate::datetime::times_equal(old.value(), new.value()),
91
+ a,
92
+ b,
93
+ depth,
94
+ opts.max_depth,
95
+ )
96
+ } else {
97
+ type_change_report(path, a, b, depth, opts.max_depth)
98
+ }
74
99
  }
75
100
  (Value::TimeDelta(old), Value::TimeDelta(new)) => {
76
- scalar_diff(path, old == new, a, b, depth, opts.max_depth)
101
+ if same_class(a, b) {
102
+ scalar_diff(path, old == new, a, b, depth, opts.max_depth)
103
+ } else {
104
+ type_change_report(path, a, b, depth, opts.max_depth)
105
+ }
77
106
  }
78
107
  (Value::Array(old), Value::Array(new)) | (Value::Tuple(old), Value::Tuple(new)) => {
79
- array_diff(path, old, new, depth, opts, memo)
108
+ if same_class(a, b) {
109
+ array_diff(path, old, new, depth, opts, memo)
110
+ } else {
111
+ type_change_report(path, a, b, depth, opts.max_depth)
112
+ }
80
113
  }
81
114
  (Value::Set(old), Value::Set(new)) | (Value::FrozenSet(old), Value::FrozenSet(new)) => {
82
- set_diff(path, old, new, depth, opts, memo)
115
+ if same_class(a, b) {
116
+ set_diff(path, old, new, depth, opts, memo)
117
+ } else {
118
+ type_change_report(path, a, b, depth, opts.max_depth)
119
+ }
120
+ }
121
+ (Value::Object(old), Value::Object(new)) => {
122
+ if same_class(a, b) {
123
+ object_diff(path, old, new, depth, opts, memo)
124
+ } else {
125
+ type_change_report(path, a, b, depth, opts.max_depth)
126
+ }
83
127
  }
84
- (Value::Object(old), Value::Object(new)) => object_diff(path, old, new, depth, opts, memo),
85
128
  _ => type_change_report(path, a, b, depth, opts.max_depth),
86
129
  }
87
130
  }
@@ -179,7 +222,7 @@ pub(crate) fn check_value_depth(
179
222
  ) -> Result<(), Error> {
180
223
  if deeper_than(value, max_depth.saturating_sub(depth)) {
181
224
  return Err(Error::MaxDepthExceeded {
182
- path: render_path(path),
225
+ path: render_path(path).to_string(),
183
226
  max_depth,
184
227
  });
185
228
  }
@@ -223,7 +266,7 @@ pub(crate) fn check_map_depth(
223
266
  ) -> Result<(), Error> {
224
267
  if map_deeper_than(map, max_depth.saturating_sub(depth)) {
225
268
  return Err(Error::MaxDepthExceeded {
226
- path: render_path(path),
269
+ path: render_path(path).to_string(),
227
270
  max_depth,
228
271
  });
229
272
  }
@@ -251,7 +294,7 @@ pub(crate) fn check_traversal_depth(
251
294
  ) -> Result<(), Error> {
252
295
  if depth > max_depth {
253
296
  return Err(Error::MaxDepthExceeded {
254
- path: render_path(path),
297
+ path: render_path(path).to_string(),
255
298
  max_depth,
256
299
  });
257
300
  }
@@ -228,7 +228,7 @@ pub(crate) use object::object_diff;
228
228
  #[cfg(test)]
229
229
  pub(crate) use options::diff_with_options_memo;
230
230
  pub(crate) use scalar::{
231
- datetime_diff, normalized_pair, numbers_equal, numeric_diff, python_type_name, scalar_diff,
232
- type_change_report,
231
+ datetime_diff, effective_type_name, normalized_pair, numbers_equal, numeric_diff,
232
+ python_type_name, scalar_diff, type_change_report,
233
233
  };
234
234
  pub(crate) use set::set_diff;
@@ -113,7 +113,7 @@ pub(crate) fn object_diff(
113
113
  // own path sits at), not the *parent* dict's `depth`.
114
114
  for (key, old_value) in a {
115
115
  scoped(path, key_segment(key), |path| -> Result<(), Error> {
116
- match key.as_str().and_then(|s| b.get_str(s)) {
116
+ match b.get(key) {
117
117
  None => check_value_depth(path, old_value, depth + 1, opts.max_depth).map(|()| {
118
118
  report.insert_dictionary_item_removed(path.clone(), old_value.clone());
119
119
  }),
@@ -124,7 +124,7 @@ pub(crate) fn object_diff(
124
124
  }
125
125
 
126
126
  for (key, new_value) in b {
127
- if key.as_str().is_none_or(|s| !a.contains_key_str(s)) {
127
+ if !a.contains_key(key) {
128
128
  scoped(path, key_segment(key), |path| {
129
129
  check_value_depth(path, new_value, depth + 1, opts.max_depth).map(|()| {
130
130
  report.insert_dictionary_item_added(path.clone(), new_value.clone());
@@ -4,7 +4,7 @@
4
4
  //! arrays.
5
5
 
6
6
  use crate::datetime::DateTime;
7
- use crate::value::{Number, Value};
7
+ use crate::value::{Number, Value, class_name};
8
8
 
9
9
  use crate::error::Error;
10
10
  use crate::path::{PathSegment, render_path};
@@ -12,7 +12,9 @@ use crate::report::{Report, TypeChangeEntry, ValuesChangedEntry};
12
12
 
13
13
  use super::check_value_depth;
14
14
 
15
- /// The Python type name `DeepDiff` would report for a given [`Value`].
15
+ /// The base-type name for a given [`Value`], ignoring any subclass name it
16
+ /// carries; see [`effective_type_name`] for the name `DeepDiff` actually
17
+ /// reports.
16
18
  ///
17
19
  /// Numbers are split into `"int"` and `"float"` by the compact [`Number`]'s
18
20
  /// preserved representation (which carries `serde_json`'s original parse: a
@@ -37,6 +39,14 @@ pub(crate) fn python_type_name(value: &Value) -> &'static str {
37
39
  Value::Object(_) => "dict",
38
40
  }
39
41
  }
42
+
43
+ /// [`python_type_name`], overridden by [`class_name`] when `value` carries a
44
+ /// subclass name — the name `DeepDiff` actually reports for `old_type`/
45
+ /// `new_type` (`type(obj).__name__`, not the base type it structurally
46
+ /// compares as). See [`crate::value::Typed`]'s doc.
47
+ pub(crate) fn effective_type_name(value: &Value) -> String {
48
+ class_name(value).map_or_else(|| python_type_name(value).to_string(), str::to_string)
49
+ }
40
50
  /// Builds a single-entry `type_changes` report at `path`, `depth` levels
41
51
  /// deep.
42
52
  ///
@@ -60,8 +70,8 @@ pub(crate) fn type_change_report(
60
70
  report.insert_type_change(
61
71
  path.to_vec(),
62
72
  TypeChangeEntry {
63
- old_type: python_type_name(a).to_string(),
64
- new_type: python_type_name(b).to_string(),
73
+ old_type: effective_type_name(a),
74
+ new_type: effective_type_name(b),
65
75
  old_value: a.clone(),
66
76
  new_value: b.clone(),
67
77
  new_path: None,
@@ -128,8 +138,8 @@ pub(crate) fn datetime_diff(
128
138
  scalar_diff(
129
139
  path,
130
140
  old == new,
131
- &Value::DateTime(old),
132
- &Value::DateTime(new),
141
+ &Value::DateTime(old.into()),
142
+ &Value::DateTime(new.into()),
133
143
  depth,
134
144
  max_depth,
135
145
  )
@@ -149,7 +159,7 @@ pub(crate) fn normalized_pair(
149
159
  new: DateTime,
150
160
  ) -> Result<(DateTime, DateTime), Error> {
151
161
  let out_of_range = || Error::DateTimeOutOfRange {
152
- path: render_path(path),
162
+ path: render_path(path).to_string(),
153
163
  };
154
164
 
155
165
  Ok((