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.
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/Cargo.lock +4 -4
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/Cargo.toml +1 -1
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/PKG-INFO +3 -3
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/README.md +2 -2
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/object.rs +68 -18
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/tests.rs +84 -7
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/distance.rs +191 -12
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/fxhash.rs +6 -2
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/hash.rs +49 -11
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/memo.rs +7 -1
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/mod.rs +1 -1
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/tests.rs +126 -5
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/path.rs +68 -4
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/report_tests.rs +9 -6
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/test_support.rs +7 -2
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/value.rs +218 -34
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/value_tests.rs +118 -17
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/golden.rs +37 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/convert.rs +140 -37
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_conversions.py +84 -9
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_differential_fuzz.py +152 -21
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/Cargo.toml +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/error.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/lib.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/options.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/row_diff.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/schema.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-arrow/src/table_diff.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/Cargo.toml +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/datetime.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/datetime_tests.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/array.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/dispatch.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/mod.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/options.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/scalar.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/diff/set.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/error.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lcs.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lcs_tests.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/lib.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/report.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/unified_diff.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/src/unified_diff_tests.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/memory_footprint.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/proptest_diff.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/.python-version +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/Cargo.toml +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/arrow.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/deepdiff.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/errors.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/fast_path.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/guard.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/src/lib.rs +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/conftest.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_bindings_memory.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_datetimes.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_depth_guard.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_golden_parity.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_sets.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_signed_zero.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_smoke.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_table_diff.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_table_row_diff.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_timedeltas.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_times.py +0 -0
- {deepdiff_rs-0.8.1 → deepdiff_rs-0.8.2}/crates/onix-py/tests/test_tuples.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
668
|
+
version = "0.8.2"
|
|
669
669
|
dependencies = [
|
|
670
670
|
"arrow-array",
|
|
671
671
|
"arrow-ipc",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepdiff-rs
|
|
3
|
-
Version: 0.8.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
126
|
-
scoped(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
|
-
(
|
|
1238
|
-
|
|
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
|
-
(
|
|
1242
|
-
|
|
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![(
|
|
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
|
-
|
|
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.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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.
|
|
702
|
-
|
|
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.
|
|
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.
|
|
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
|
|
46
|
-
///
|
|
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²)`
|