deepdiff-rs 0.8.1__tar.gz → 0.8.2__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 (76) hide show
  1. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/Cargo.lock +4 -4
  2. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/PKG-INFO +3 -3
  4. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/README.md +2 -2
  5. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/object.rs +68 -18
  6. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/tests.rs +84 -7
  7. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/distance.rs +191 -12
  8. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/fxhash.rs +6 -2
  9. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/hash.rs +49 -11
  10. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/memo.rs +7 -1
  11. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/mod.rs +1 -1
  12. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/tests.rs +126 -5
  13. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/path.rs +68 -4
  14. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/report_tests.rs +9 -6
  15. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/test_support.rs +7 -2
  16. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/value.rs +218 -34
  17. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/value_tests.rs +118 -17
  18. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/golden.rs +37 -0
  19. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/convert.rs +140 -37
  20. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_conversions.py +84 -9
  21. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_differential_fuzz.py +152 -21
  22. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/Cargo.toml +0 -0
  23. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
  24. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  25. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/error.rs +0 -0
  26. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/lib.rs +0 -0
  27. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/options.rs +0 -0
  28. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/row_diff.rs +0 -0
  29. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/schema.rs +0 -0
  30. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/table_diff.rs +0 -0
  31. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/Cargo.toml +0 -0
  32. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  33. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/datetime.rs +0 -0
  34. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/datetime_tests.rs +0 -0
  35. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/array.rs +0 -0
  36. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/dispatch.rs +0 -0
  37. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/mod.rs +0 -0
  38. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/options.rs +0 -0
  39. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/scalar.rs +0 -0
  40. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/set.rs +0 -0
  41. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/error.rs +0 -0
  42. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  43. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lcs.rs +0 -0
  44. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lcs_tests.rs +0 -0
  45. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lib.rs +0 -0
  46. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/report.rs +0 -0
  47. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/unified_diff.rs +0 -0
  48. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  49. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  50. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/memory_footprint.rs +0 -0
  51. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/proptest_diff.rs +0 -0
  52. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  53. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/.python-version +0 -0
  54. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/Cargo.toml +0 -0
  55. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  56. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/arrow.rs +0 -0
  57. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/deepdiff.rs +0 -0
  58. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/errors.rs +0 -0
  59. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/fast_path.rs +0 -0
  60. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/guard.rs +0 -0
  61. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/lib.rs +0 -0
  62. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/conftest.py +0 -0
  63. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  64. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_datetimes.py +0 -0
  65. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_depth_guard.py +0 -0
  66. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_golden_parity.py +0 -0
  67. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_sets.py +0 -0
  68. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_signed_zero.py +0 -0
  69. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_smoke.py +0 -0
  70. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  71. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_table_diff.py +0 -0
  72. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  73. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_timedeltas.py +0 -0
  74. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_times.py +0 -0
  75. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_tuples.py +0 -0
  76. {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/pyproject.toml +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.8.1"
632
+ version = "0.8.2"
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.8.1"
649
+ version = "0.8.2"
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.8.1"
657
+ version = "0.8.2"
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.8.1"
668
+ version = "0.8.2"
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.8.1"
6
+ version = "0.8.2"
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.8.1
3
+ Version: 0.8.2
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -203,7 +203,7 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
203
203
 
204
204
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
205
205
 
206
- - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions); `.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)]`).
206
+ - `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)]`).
207
207
  - `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.
208
208
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
209
209
  - `diff_tables(left, right, key=[...]) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the schema diff as JSON; `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
@@ -238,7 +238,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
238
238
  ## Known limitations
239
239
 
240
240
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
241
- - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `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. `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else — a non-`str` dict key, a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. The **Datetimes** and **Sets** 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).
241
+ - Supported value types are `None`, `bool`, `int`, `float`, `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`, `float`s must be finite, and anything else — a custom object, an arbitrary-precision `int`, or a non-finite `float` — 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 — 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** and **Sets** 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).
242
242
  - 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).
243
243
  - **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.
244
244
  - **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.
@@ -185,7 +185,7 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
185
185
 
186
186
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
187
187
 
188
- - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects of supported value types — `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, `datetime.date`, `datetime.time`, and `datetime.timedelta` (see [Known limitations](#known-limitations) for the exact restrictions and exclusions); `.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)]`).
188
+ - `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)]`).
189
189
  - `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.
190
190
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
191
191
  - `diff_tables(left, right, key=[...]) -> TableDiff`: diffs two Arrow tables (see [Diffing tables](#diffing-tables)). `TableDiff.schema` is the list of changed columns, `.schema_arrow` the same as an Arrow table, `.summary()` the schema and row change counts, `.to_json()` the schema diff as JSON; `.rows_added()`, `.rows_removed()`, `.cells_changed()`, and `.duplicate_keys()` return Arrow tables.
@@ -220,7 +220,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
220
220
  ## Known limitations
221
221
 
222
222
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
223
- - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `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. `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else — a non-`str` dict key, a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. The **Datetimes** and **Sets** 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).
223
+ - Supported value types are `None`, `bool`, `int`, `float`, `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`, `float`s must be finite, and anything else — a custom object, an arbitrary-precision `int`, or a non-finite `float` — 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 — 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** and **Sets** 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).
224
224
  - 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).
225
225
  - **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.
226
226
  - **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.
@@ -6,7 +6,7 @@ use crate::value::{Object, Value};
6
6
 
7
7
  use crate::error::Error;
8
8
  use crate::ignore_order::IgnoreOrderMemo;
9
- use crate::path::PathSegment;
9
+ use crate::path::{PathSegment, object_key_path_segment as key_segment};
10
10
  use crate::report::{Report, ValuesChangedEntry};
11
11
 
12
12
  use super::{DiffOptions, check_map_depth, check_value_depth, diff_at, scoped};
@@ -95,6 +95,14 @@ pub(crate) fn object_diff(
95
95
  return Ok(report);
96
96
  }
97
97
 
98
+ // A non-`str` key needs python-equality matching (`object_diff_mixed`'s
99
+ // own doc has the rule); dispatched to a separate function, kept off
100
+ // this function's own frame, to protect the default `max_depth`
101
+ // budget on the hot `object_diff` <-> `diff_at` recursion.
102
+ if a.has_non_str_keys() || b.has_non_str_keys() {
103
+ return object_diff_mixed(path, a, b, depth, opts, memo);
104
+ }
105
+
98
106
  let mut report = Report::new();
99
107
 
100
108
  // Stepping into a key — whether it recurses (shared key) or is a leaf
@@ -104,26 +112,20 @@ pub(crate) fn object_diff(
104
112
  // `check_value_depth` call needs that same `depth + 1` (the depth its
105
113
  // own path sits at), not the *parent* dict's `depth`.
106
114
  for (key, old_value) in a {
107
- scoped(
108
- path,
109
- PathSegment::Key(key.to_string()),
110
- |path| -> Result<(), Error> {
111
- match b.get(key) {
112
- None => {
113
- check_value_depth(path, old_value, depth + 1, opts.max_depth).map(|()| {
114
- report.insert_dictionary_item_removed(path.clone(), old_value.clone());
115
- })
116
- }
117
- Some(new_value) => diff_at(path, old_value, new_value, depth + 1, opts, memo)
118
- .map(|sub_report| report.merge(sub_report)),
119
- }
120
- },
121
- )?;
115
+ scoped(path, key_segment(key), |path| -> Result<(), Error> {
116
+ match key.as_str().and_then(|s| b.get_str(s)) {
117
+ None => check_value_depth(path, old_value, depth + 1, opts.max_depth).map(|()| {
118
+ report.insert_dictionary_item_removed(path.clone(), old_value.clone());
119
+ }),
120
+ Some(new_value) => diff_at(path, old_value, new_value, depth + 1, opts, memo)
121
+ .map(|sub_report| report.merge(sub_report)),
122
+ }
123
+ })?;
122
124
  }
123
125
 
124
126
  for (key, new_value) in b {
125
- if !a.contains_key(key) {
126
- scoped(path, PathSegment::Key(key.to_string()), |path| {
127
+ if key.as_str().is_none_or(|s| !a.contains_key_str(s)) {
128
+ scoped(path, key_segment(key), |path| {
127
129
  check_value_depth(path, new_value, depth + 1, opts.max_depth).map(|()| {
128
130
  report.insert_dictionary_item_added(path.clone(), new_value.clone());
129
131
  })
@@ -133,3 +135,51 @@ pub(crate) fn object_diff(
133
135
 
134
136
  Ok(report)
135
137
  }
138
+
139
+ /// [`object_diff`]'s walk for the (rare) case where `a` or `b` has a
140
+ /// non-`str` key — kept out of `object_diff`'s own body; see the call
141
+ /// site's doc for why.
142
+ ///
143
+ /// A non-`str` key matches across `a` and `b` by Python `==`, not this
144
+ /// crate's own structural `ObjectKey` equality, via
145
+ /// [`crate::ignore_order::match_dict_keys`] — see that function's doc for
146
+ /// the exact rule and `tests/golden/README.md`'s "A dict key matches across
147
+ /// two dicts by Python `==`" section for the confirmed example
148
+ /// (`{1: "a"}` vs `{1.0: "a2"}` reports `root[1.0]`, `b`'s key form).
149
+ ///
150
+ /// The `threshold_to_diff_deeper` collapse and the depth-counting
151
+ /// convention are exactly [`object_diff`]'s own — see that function's doc.
152
+ fn object_diff_mixed(
153
+ path: &mut Vec<PathSegment>,
154
+ a: &Object,
155
+ b: &Object,
156
+ depth: usize,
157
+ opts: &DiffOptions,
158
+ memo: &IgnoreOrderMemo,
159
+ ) -> Result<Report, Error> {
160
+ let mut report = Report::new();
161
+ let matched = crate::ignore_order::match_dict_keys(a, b);
162
+
163
+ for (key, old_value, new_value) in matched.shared {
164
+ scoped(path, key_segment(key), |path| {
165
+ diff_at(path, old_value, new_value, depth + 1, opts, memo)
166
+ .map(|sub_report| report.merge(sub_report))
167
+ })?;
168
+ }
169
+ for (key, old_value) in matched.only_a {
170
+ scoped(path, key_segment(key), |path| {
171
+ check_value_depth(path, old_value, depth + 1, opts.max_depth).map(|()| {
172
+ report.insert_dictionary_item_removed(path.clone(), old_value.clone());
173
+ })
174
+ })?;
175
+ }
176
+ for (key, new_value) in matched.only_b {
177
+ scoped(path, key_segment(key), |path| {
178
+ check_value_depth(path, new_value, depth + 1, opts.max_depth).map(|()| {
179
+ report.insert_dictionary_item_added(path.clone(), new_value.clone());
180
+ })
181
+ })?;
182
+ }
183
+
184
+ Ok(report)
185
+ }
@@ -3,7 +3,7 @@ use crate::error::Error;
3
3
  use crate::path::PathSegment;
4
4
  use crate::report::Report;
5
5
  use crate::test_support::{cdate, cdt, cdt_at, cfrozen, cnum, cobj, cset, ctup, cv};
6
- use crate::value::{Object as CObject, SetItems, Value as CValue};
6
+ use crate::value::{Object as CObject, ObjectKey, SetItems, Value as CValue};
7
7
  use serde_json::{Map, Number, Value, json};
8
8
 
9
9
  // Thin wrappers routing each `serde_json`-literal-based test through the real
@@ -1234,12 +1234,21 @@ fn threshold_collapse_rejects_a_deep_side_on_a_constrained_stack_instead_of_cras
1234
1234
  value
1235
1235
  };
1236
1236
  let a = CValue::Object(CObject::from_pairs(vec![
1237
- (std::sync::Arc::from("p"), CValue::from(json!(1))),
1238
- (std::sync::Arc::from("q"), CValue::from(json!(2))),
1237
+ (
1238
+ ObjectKey::Str(std::sync::Arc::from("p")),
1239
+ CValue::from(json!(1)),
1240
+ ),
1241
+ (
1242
+ ObjectKey::Str(std::sync::Arc::from("q")),
1243
+ CValue::from(json!(2)),
1244
+ ),
1239
1245
  ]));
1240
1246
  let b = CValue::Object(CObject::from_pairs(vec![
1241
- (std::sync::Arc::from("r"), CValue::from(json!(3))),
1242
- (std::sync::Arc::from("deep"), deep),
1247
+ (
1248
+ ObjectKey::Str(std::sync::Arc::from("r")),
1249
+ CValue::from(json!(3)),
1250
+ ),
1251
+ (ObjectKey::Str(std::sync::Arc::from("deep")), deep),
1243
1252
  ]));
1244
1253
 
1245
1254
  let err = super::diff_with_max_depth(&a, &b, 1).unwrap_err();
@@ -1289,6 +1298,71 @@ fn structure_exactly_at_configured_max_depth_diffs_successfully() {
1289
1298
  );
1290
1299
  }
1291
1300
 
1301
+ /// `object_diff_mixed`'s shared-key recursion steps `depth` by exactly one.
1302
+ #[test]
1303
+ fn mixed_dict_shared_key_recursion_steps_depth_by_one_not_by_multiplication() {
1304
+ let a = CValue::Object(CObject::from_pairs(vec![(
1305
+ ObjectKey::Other(Box::new(CValue::from(json!(1)))),
1306
+ cv(&json!("a")),
1307
+ )]));
1308
+ let b = CValue::Object(CObject::from_pairs(vec![(
1309
+ ObjectKey::Other(Box::new(CValue::from(json!(1)))),
1310
+ cv(&json!("b")),
1311
+ )]));
1312
+
1313
+ let err = super::diff_with_max_depth(&a, &b, 0).unwrap_err();
1314
+
1315
+ assert_eq!(
1316
+ err,
1317
+ Error::MaxDepthExceeded {
1318
+ path: "root[1]".to_string(),
1319
+ max_depth: 0,
1320
+ }
1321
+ );
1322
+ }
1323
+
1324
+ /// `object_diff_mixed`'s removed-key (`only_a`) sink checks the removed
1325
+ /// value against its own path depth plus one, not the parent's.
1326
+ #[test]
1327
+ fn mixed_dict_removed_key_value_is_checked_against_its_own_plus_one_depth() {
1328
+ let a = CValue::Object(CObject::from_pairs(vec![(
1329
+ ObjectKey::Other(Box::new(CValue::from(json!(5)))),
1330
+ cv(&nested_array(10, json!(1))), // one past the correct budget of 9
1331
+ )]));
1332
+ let b = CValue::Object(CObject::from_pairs(vec![]));
1333
+
1334
+ let err = super::diff_with_max_depth(&a, &b, 10).unwrap_err();
1335
+
1336
+ assert_eq!(
1337
+ err,
1338
+ Error::MaxDepthExceeded {
1339
+ path: "root[5]".to_string(),
1340
+ max_depth: 10,
1341
+ }
1342
+ );
1343
+ }
1344
+
1345
+ /// [`mixed_dict_removed_key_value_is_checked_against_its_own_plus_one_depth`]'s
1346
+ /// twin for `object_diff_mixed`'s added-key (`only_b`) sink.
1347
+ #[test]
1348
+ fn mixed_dict_added_key_value_is_checked_against_its_own_plus_one_depth() {
1349
+ let a = CValue::Object(CObject::from_pairs(vec![]));
1350
+ let b = CValue::Object(CObject::from_pairs(vec![(
1351
+ ObjectKey::Other(Box::new(CValue::from(json!(5)))),
1352
+ cv(&nested_array(10, json!(1))), // one past the correct budget of 9
1353
+ )]));
1354
+
1355
+ let err = super::diff_with_max_depth(&a, &b, 10).unwrap_err();
1356
+
1357
+ assert_eq!(
1358
+ err,
1359
+ Error::MaxDepthExceeded {
1360
+ path: "root[5]".to_string(),
1361
+ max_depth: 10,
1362
+ }
1363
+ );
1364
+ }
1365
+
1292
1366
  #[test]
1293
1367
  fn structure_one_level_past_configured_max_depth_errors() {
1294
1368
  let a = nested_dict(4, json!("a"));
@@ -1918,7 +1992,7 @@ fn list_vs_tuple_nested_in_a_dict_is_a_type_change() {
1918
1992
  let mut a = Map::new();
1919
1993
  a.insert("a".to_string(), json!([1, 2]));
1920
1994
  let b = CValue::Object(CObject::from_pairs(vec![(
1921
- std::sync::Arc::from("a"),
1995
+ ObjectKey::Str(std::sync::Arc::from("a")),
1922
1996
  ctup(&[json!(1), json!(2)]),
1923
1997
  )]));
1924
1998
  let report = super::diff(&cv(&Value::Object(a)), &b).unwrap();
@@ -2052,7 +2126,10 @@ fn a_dict_value_that_is_a_too_deep_tuple_errors_instead_of_being_cloned() {
2052
2126
  for _ in 0..5 {
2053
2127
  deep = CValue::Tuple(vec![deep].into_boxed_slice());
2054
2128
  }
2055
- let b = CValue::Object(CObject::from_pairs(vec![(std::sync::Arc::from("a"), deep)]));
2129
+ let b = CValue::Object(CObject::from_pairs(vec![(
2130
+ ObjectKey::Str(std::sync::Arc::from("a")),
2131
+ deep,
2132
+ )]));
2056
2133
  let error = super::diff_with_max_depth(&cv(&json!({})), &b, 4).unwrap_err();
2057
2134
  assert!(matches!(error, Error::MaxDepthExceeded { .. }));
2058
2135
  }
@@ -5,7 +5,7 @@
5
5
  //! (`super::hash`) at all — every function here operates directly on the
6
6
  //! crate's compact [`Value`].
7
7
 
8
- use crate::value::{Number, Object, Value};
8
+ use crate::value::{Number, Object, ObjectKey, Value};
9
9
 
10
10
  use crate::diff::DiffOptions;
11
11
 
@@ -256,7 +256,10 @@ pub(crate) fn item_length(value: &Value) -> usize {
256
256
  /// "new value" is a whole map, not a [`Value`]) can share it directly.
257
257
  fn item_length_of_map(map: &Object) -> usize {
258
258
  map.iter()
259
- .filter(|(key, _)| !is_length_excluded_key(key))
259
+ // A non-`str` key can never match the literal exclusion list below,
260
+ // so it is always counted — `Option::is_none_or` reads as "excluded
261
+ // only when this is a `str` key that matches".
262
+ .filter(|(key, _)| key.as_str().is_none_or(|s| !is_length_excluded_key(s)))
260
263
  .map(|(_, v)| item_length(v))
261
264
  .sum()
262
265
  }
@@ -425,13 +428,21 @@ fn python_eq(a: &Value, b: &Value) -> bool {
425
428
  (Value::Set(x) | Value::FrozenSet(x), Value::Set(y) | Value::FrozenSet(y)) => {
426
429
  unordered_python_eq(x, y)
427
430
  }
431
+ // A non-`str` key needs `match_dict_keys`'s python-equality
432
+ // matching (see its doc); dispatched to a separate function, kept
433
+ // off this frame for the reason `crate::diff::object::object_diff`'s
434
+ // own dispatch documents.
428
435
  (Value::Object(x), Value::Object(y)) => {
429
- x.len() == y.len()
430
- && x.iter()
431
- .zip(y.iter())
432
- .all(|((x_key, x_value), (y_key, y_value))| {
433
- x_key == y_key && python_eq(x_value, y_value)
434
- })
436
+ if x.has_non_str_keys() || y.has_non_str_keys() {
437
+ dict_python_eq_mixed(x, y)
438
+ } else {
439
+ x.len() == y.len()
440
+ && x.iter()
441
+ .zip(y.iter())
442
+ .all(|((x_key, x_value), (y_key, y_value))| {
443
+ x_key == y_key && python_eq(x_value, y_value)
444
+ })
445
+ }
435
446
  }
436
447
  _ => match (
437
448
  crate::lcs::python_scalar_key(a),
@@ -445,6 +456,20 @@ fn python_eq(a: &Value, b: &Value) -> bool {
445
456
  }
446
457
  }
447
458
 
459
+ /// [`python_eq`]'s dict case for the (rare) pair where `a` or `b` has a
460
+ /// non-`str` key — python-equality key matching via [`match_dict_keys`],
461
+ /// kept out of [`python_eq`]'s own body for the reason its call site
462
+ /// documents.
463
+ fn dict_python_eq_mixed(a: &Object, b: &Object) -> bool {
464
+ let matched = match_dict_keys(a, b);
465
+ matched.only_a.is_empty()
466
+ && matched.only_b.is_empty()
467
+ && matched
468
+ .shared
469
+ .iter()
470
+ .all(|(_, a_value, b_value)| python_eq(a_value, b_value))
471
+ }
472
+
448
473
  /// Element-wise [`python_eq`] over two sequences of the same length — what
449
474
  /// `list(x) == y` compares once `list()` has copied `x`'s items in order.
450
475
  fn sequences_python_eq(a: &[Value], b: &[Value]) -> bool {
@@ -693,13 +718,133 @@ fn coerce_to_python_str(value: &Value) -> Option<String> {
693
718
  /// producing a completely different pairing decision).
694
719
  pub(crate) const THRESHOLD_TO_DIFF_DEEPER: f64 = 0.33;
695
720
 
721
+ /// A dict key's Python-equality identity — see [`match_dict_keys`]'s doc
722
+ /// for the matching rule this backs and what a `None` result means.
723
+ #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
724
+ enum DictKeyIdentity {
725
+ Scalar(crate::lcs::ScalarKey),
726
+ Tuple(Vec<crate::lcs::ScalarKey>),
727
+ }
728
+
729
+ fn dict_key_identity(key: &ObjectKey) -> Option<DictKeyIdentity> {
730
+ match key {
731
+ ObjectKey::Str(s) => Some(DictKeyIdentity::Scalar(crate::lcs::ScalarKey::Str(
732
+ s.to_string(),
733
+ ))),
734
+ ObjectKey::Other(value) => match value.as_ref() {
735
+ Value::Tuple(items) => items
736
+ .iter()
737
+ .map(crate::lcs::python_scalar_key)
738
+ .collect::<Option<Vec<_>>>()
739
+ .map(DictKeyIdentity::Tuple),
740
+ other => crate::lcs::python_scalar_key(other).map(DictKeyIdentity::Scalar),
741
+ },
742
+ }
743
+ }
744
+
745
+ /// The result of matching two [`Object`]s' keys by [`DictKeyIdentity`] — see
746
+ /// [`match_dict_keys`].
747
+ pub(crate) struct DictKeyMatch<'a> {
748
+ /// A key present (by identity) on both sides: `b`'s own key (the one
749
+ /// `DeepDiff` renders and recurses with — see `crate::diff::object`'s
750
+ /// doc), `a`'s value, then `b`'s value.
751
+ pub(crate) shared: Vec<(&'a ObjectKey, &'a Value, &'a Value)>,
752
+ /// A key present only in `a`.
753
+ pub(crate) only_a: Vec<(&'a ObjectKey, &'a Value)>,
754
+ /// A key present only in `b`.
755
+ pub(crate) only_b: Vec<(&'a ObjectKey, &'a Value)>,
756
+ }
757
+
758
+ /// Matches `a`'s and `b`'s keys by [`DictKeyIdentity`] rather than
759
+ /// [`Object::get`]/[`Object::contains_key`]'s structural [`ObjectKey`]
760
+ /// equality — the rule every dict-vs-dict comparison in this crate needs
761
+ /// once either side has a non-`str` key: `DeepDiff`'s own `dict`'s `==`
762
+ /// (`_diff_dict`'s `SetOrdered` key intersection) collapses `1`, `1.0` and
763
+ /// `True` into one key and compares a `tuple` key element-wise, confirmed
764
+ /// against real `deepdiff==9.1.0`: `{1: "a"}` vs `{1.0: "a"}` is `{}`, not
765
+ /// a removed+added pair. A key `dict_key_identity` cannot classify
766
+ /// (unreachable from `onix-py`'s conversion, which restricts a dict key to
767
+ /// `None`/`bool`/`int`/`float`/`str`/`datetime`/`date`/a `tuple` of those)
768
+ /// is always reported as added/removed instead of matched.
769
+ /// `crate::diff::object_diff`, [`count_object_diff_leaves`] and
770
+ /// [`is_below_threshold_to_diff_deeper`] each call this once
771
+ /// [`Object::has_non_str_keys`] says either side needs it, so an ordinary
772
+ /// all-`str` object — the overwhelming common case — never builds the
773
+ /// `BTreeMap` this does.
774
+ ///
775
+ /// `O((n + m) log(n + m))`: one lookup map built from `b`'s keys, probed
776
+ /// once per `a` key — the same complexity class `Object::get`'s binary
777
+ /// search already gave the `str`-only path (`n` lookups at `O(log n)`
778
+ /// each), not a quadratic case a non-`str` key newly introduces.
779
+ pub(crate) fn match_dict_keys<'a>(a: &'a Object, b: &'a Object) -> DictKeyMatch<'a> {
780
+ let mut b_by_identity: std::collections::BTreeMap<DictKeyIdentity, usize> =
781
+ std::collections::BTreeMap::new();
782
+ let b_entries: Vec<(&ObjectKey, &Value)> = b.iter().collect();
783
+ for (index, (key, _)) in b_entries.iter().enumerate() {
784
+ if let Some(identity) = dict_key_identity(key) {
785
+ b_by_identity.insert(identity, index);
786
+ }
787
+ }
788
+
789
+ let mut matched_b: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
790
+ let mut shared = Vec::new();
791
+ let mut only_a = Vec::new();
792
+
793
+ for (key, a_value) in a {
794
+ let found =
795
+ dict_key_identity(key).and_then(|identity| b_by_identity.get(&identity).copied());
796
+ match found {
797
+ Some(index) => {
798
+ matched_b.insert(index);
799
+ let (b_key, b_value) = b_entries[index];
800
+ shared.push((b_key, a_value, b_value));
801
+ }
802
+ None => only_a.push((key, a_value)),
803
+ }
804
+ }
805
+
806
+ let only_b = b_entries
807
+ .into_iter()
808
+ .enumerate()
809
+ .filter(|(index, _)| !matched_b.contains(index))
810
+ .map(|(_, entry)| entry)
811
+ .collect();
812
+
813
+ DictKeyMatch {
814
+ shared,
815
+ only_a,
816
+ only_b,
817
+ }
818
+ }
819
+
696
820
  /// The shared `threshold_to_diff_deeper` ratio check backing both
697
821
  /// [`count_object_diff_leaves`] (the count-only distance mirror) and
698
822
  /// `crate::diff::object_diff`'s own unconditional collapse — see
699
823
  /// [`THRESHOLD_TO_DIFF_DEEPER`]'s own doc for why both exist.
700
824
  pub(crate) fn is_below_threshold_to_diff_deeper(a: &Object, b: &Object) -> bool {
701
- let union_len = a.keys().chain(b.keys()).collect::<HashSet<_>>().len();
702
- let intersect_len = a.keys().filter(|key| b.contains_key(key)).count();
825
+ let (union_len, intersect_len) = if a.has_non_str_keys() || b.has_non_str_keys() {
826
+ let matched = match_dict_keys(a, b);
827
+ (
828
+ matched.shared.len() + matched.only_a.len() + matched.only_b.len(),
829
+ matched.shared.len(),
830
+ )
831
+ } else {
832
+ // Unchanged from before `ObjectKey` existed: every key here is a
833
+ // `Str`, so structural and python-equality matching coincide, and
834
+ // `as_str` never filters anything out.
835
+ let union_len = a
836
+ .keys()
837
+ .filter_map(ObjectKey::as_str)
838
+ .chain(b.keys().filter_map(ObjectKey::as_str))
839
+ .collect::<HashSet<_>>()
840
+ .len();
841
+ let intersect_len = a
842
+ .keys()
843
+ .filter_map(ObjectKey::as_str)
844
+ .filter(|key| b.contains_key_str(key))
845
+ .count();
846
+ (union_len, intersect_len)
847
+ };
703
848
  #[allow(
704
849
  clippy::cast_precision_loss,
705
850
  reason = "key counts are small, far under f64's exact-integer range"
@@ -734,16 +879,22 @@ pub(crate) fn count_object_diff_leaves(
734
879
  return item_length_of_map(b);
735
880
  }
736
881
 
882
+ // Dispatched to a separate function, kept off this frame for the
883
+ // reason `crate::diff::object::object_diff`'s own dispatch documents.
884
+ if a.has_non_str_keys() || b.has_non_str_keys() {
885
+ return count_object_diff_leaves_mixed(a, b, depth, opts, memo);
886
+ }
887
+
737
888
  let mut total = 0;
738
889
 
739
890
  for (key, old_value) in a {
740
- total += match b.get(key) {
891
+ total += match key.as_str().and_then(|s| b.get_str(s)) {
741
892
  None => item_length(old_value),
742
893
  Some(new_value) => count_diff_leaves(old_value, new_value, depth + 1, opts, memo),
743
894
  };
744
895
  }
745
896
  for (key, new_value) in b {
746
- if !a.contains_key(key) {
897
+ if key.as_str().is_none_or(|s| !a.contains_key_str(s)) {
747
898
  total += item_length(new_value);
748
899
  }
749
900
  }
@@ -751,6 +902,34 @@ pub(crate) fn count_object_diff_leaves(
751
902
  total
752
903
  }
753
904
 
905
+ /// [`count_object_diff_leaves`]'s walk for the (rare) case where `a` or `b`
906
+ /// has a non-`str` key — matches keys the same way
907
+ /// `crate::diff::object::object_diff_mixed` does (python-equality, via
908
+ /// [`match_dict_keys`]); kept out of [`count_object_diff_leaves`]'s own
909
+ /// body for the reason its call site documents.
910
+ fn count_object_diff_leaves_mixed(
911
+ a: &Object,
912
+ b: &Object,
913
+ depth: usize,
914
+ opts: &DiffOptions,
915
+ memo: &IgnoreOrderMemo,
916
+ ) -> usize {
917
+ let matched = match_dict_keys(a, b);
918
+ let mut total = 0;
919
+
920
+ for (_, old_value, new_value) in &matched.shared {
921
+ total += count_diff_leaves(old_value, new_value, depth + 1, opts, memo);
922
+ }
923
+ for (_, old_value) in &matched.only_a {
924
+ total += item_length(old_value);
925
+ }
926
+ for (_, new_value) in &matched.only_b {
927
+ total += item_length(new_value);
928
+ }
929
+
930
+ total
931
+ }
932
+
754
933
  /// [`count_diff_leaves`]'s set case: `DeepDiff`'s delta view of a set diff
755
934
  /// is `{"set_item_added": {<path>: {<items>}}, "set_item_removed": ...}`,
756
935
  /// and `_get_item_length` sums the [`item_length`] of every added and every
@@ -42,8 +42,12 @@ pub(crate) type HashSet<T> = std::collections::HashSet<T, BuildHasherDefault<FxH
42
42
  /// attacker-controlled member content *and* reached for every set/frozenset
43
43
  /// comparison, **including with the default `ignore_order=false`**, so an
44
44
  /// `FxHash` table there would be a crafted-collision `DoS` on the ordinary
45
- /// path; a `BTreeMap` has no hash to attack (`O(log n)` worst case, always). See
46
- /// that module's "Set-member digests" section. Every remaining `FxHash` table
45
+ /// path; a `BTreeMap` has no hash to attack (`O(log n)` worst case, always,
46
+ /// though each of those comparisons is a full walk of the probed key —
47
+ /// `member_content`'s `MemberContent::UnhashableDict` key is itself keyed by
48
+ /// each dict key's own `ItemKey` tree, not a flat string, since a dict key
49
+ /// may be a `tuple`). See that module's "Set-member digests" section. Every
50
+ /// remaining `FxHash` table
47
51
  /// in this module — `HashedList`, `AddedCandidates`,
48
52
  /// the pairing/`used` sets, and the distance memo — is reached **only** under
49
53
  /// `ignore_order=true`, the pairing hot path already bounded by the `O(N²)`