deepdiff-rs 0.6.2__tar.gz → 0.7.1__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.6.2 → deepdiff_rs-0.7.1}/Cargo.lock +59 -4
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/Cargo.toml +1 -1
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/PKG-INFO +21 -18
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/README.md +20 -17
- deepdiff_rs-0.7.1/crates/onix-arrow/Cargo.toml +42 -0
- deepdiff_rs-0.7.1/crates/onix-arrow/examples/row_diff_rss.rs +189 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/error.rs +62 -4
- deepdiff_rs-0.7.1/crates/onix-arrow/src/lib.rs +388 -0
- deepdiff_rs-0.7.1/crates/onix-arrow/src/row_diff.rs +2657 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/table_diff.rs +160 -85
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/Cargo.toml +1 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/lcs.rs +4 -1
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/path.rs +125 -25
- deepdiff_rs-0.7.1/crates/onix-py/.python-version +1 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/Cargo.toml +9 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/arrow.rs +118 -20
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_sets.py +77 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_table_diff.py +65 -8
- deepdiff_rs-0.7.1/crates/onix-py/tests/test_table_row_diff.py +368 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/pyproject.toml +7 -6
- deepdiff_rs-0.6.2/crates/onix-arrow/Cargo.toml +0 -23
- deepdiff_rs-0.6.2/crates/onix-arrow/src/lib.rs +0 -266
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/options.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/schema.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/datetime.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/datetime_tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/array.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/dispatch.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/mod.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/object.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/options.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/scalar.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/set.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/error.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/distance.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/hash.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/memo.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/mod.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/lcs_tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/lib.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/report.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/report_tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/test_support.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/unified_diff.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/unified_diff_tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/value.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/src/value_tests.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/tests/golden.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/tests/memory_footprint.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/tests/proptest_diff.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/convert.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/deepdiff.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/errors.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/fast_path.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/guard.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/src/lib.rs +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_bindings_memory.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_conversions.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_datetimes.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_depth_guard.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_differential_fuzz.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_golden_parity.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_signed_zero.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_smoke.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
- {deepdiff_rs-0.6.2 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_tuples.py +0 -0
|
@@ -92,6 +92,20 @@ dependencies = [
|
|
|
92
92
|
"num-traits",
|
|
93
93
|
]
|
|
94
94
|
|
|
95
|
+
[[package]]
|
|
96
|
+
name = "arrow-ipc"
|
|
97
|
+
version = "59.3.0"
|
|
98
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
99
|
+
checksum = "dcc41681ea80f521df14c36725b74d4c60702c47f0793af2be469c04527e2599"
|
|
100
|
+
dependencies = [
|
|
101
|
+
"arrow-array",
|
|
102
|
+
"arrow-buffer",
|
|
103
|
+
"arrow-data",
|
|
104
|
+
"arrow-schema",
|
|
105
|
+
"arrow-select",
|
|
106
|
+
"flatbuffers",
|
|
107
|
+
]
|
|
108
|
+
|
|
95
109
|
[[package]]
|
|
96
110
|
name = "arrow-ord"
|
|
97
111
|
version = "59.3.0"
|
|
@@ -291,6 +305,16 @@ version = "0.1.12"
|
|
|
291
305
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
292
306
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
|
293
307
|
|
|
308
|
+
[[package]]
|
|
309
|
+
name = "flatbuffers"
|
|
310
|
+
version = "25.12.19"
|
|
311
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
312
|
+
checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3"
|
|
313
|
+
dependencies = [
|
|
314
|
+
"bitflags",
|
|
315
|
+
"rustc_version",
|
|
316
|
+
]
|
|
317
|
+
|
|
294
318
|
[[package]]
|
|
295
319
|
name = "fnv"
|
|
296
320
|
version = "1.0.7"
|
|
@@ -605,17 +629,24 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
605
629
|
|
|
606
630
|
[[package]]
|
|
607
631
|
name = "onix-arrow"
|
|
608
|
-
version = "0.
|
|
632
|
+
version = "0.7.1"
|
|
609
633
|
dependencies = [
|
|
610
634
|
"arrow-array",
|
|
635
|
+
"arrow-buffer",
|
|
636
|
+
"arrow-cast",
|
|
611
637
|
"arrow-schema",
|
|
638
|
+
"arrow-select",
|
|
639
|
+
"getrandom 0.3.4",
|
|
640
|
+
"half",
|
|
641
|
+
"proptest",
|
|
612
642
|
"serde",
|
|
613
643
|
"serde_json",
|
|
644
|
+
"siphasher",
|
|
614
645
|
]
|
|
615
646
|
|
|
616
647
|
[[package]]
|
|
617
648
|
name = "onix-cli"
|
|
618
|
-
version = "0.
|
|
649
|
+
version = "0.7.1"
|
|
619
650
|
dependencies = [
|
|
620
651
|
"onix-core",
|
|
621
652
|
"serde_json",
|
|
@@ -623,25 +654,28 @@ dependencies = [
|
|
|
623
654
|
|
|
624
655
|
[[package]]
|
|
625
656
|
name = "onix-core"
|
|
626
|
-
version = "0.
|
|
657
|
+
version = "0.7.1"
|
|
627
658
|
dependencies = [
|
|
628
659
|
"proptest",
|
|
629
660
|
"serde",
|
|
630
661
|
"serde_json",
|
|
631
662
|
"stats_alloc",
|
|
663
|
+
"unicode-general-category",
|
|
632
664
|
]
|
|
633
665
|
|
|
634
666
|
[[package]]
|
|
635
667
|
name = "onix-py"
|
|
636
|
-
version = "0.
|
|
668
|
+
version = "0.7.1"
|
|
637
669
|
dependencies = [
|
|
638
670
|
"arrow-array",
|
|
671
|
+
"arrow-ipc",
|
|
639
672
|
"arrow-schema",
|
|
640
673
|
"onix-arrow",
|
|
641
674
|
"onix-core",
|
|
642
675
|
"pyo3",
|
|
643
676
|
"pyo3-arrow",
|
|
644
677
|
"serde_json",
|
|
678
|
+
"tempfile",
|
|
645
679
|
]
|
|
646
680
|
|
|
647
681
|
[[package]]
|
|
@@ -884,6 +918,15 @@ version = "2.1.3"
|
|
|
884
918
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
885
919
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
886
920
|
|
|
921
|
+
[[package]]
|
|
922
|
+
name = "rustc_version"
|
|
923
|
+
version = "0.4.1"
|
|
924
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
925
|
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
|
926
|
+
dependencies = [
|
|
927
|
+
"semver",
|
|
928
|
+
]
|
|
929
|
+
|
|
887
930
|
[[package]]
|
|
888
931
|
name = "rustix"
|
|
889
932
|
version = "1.1.4"
|
|
@@ -921,6 +964,12 @@ version = "1.0.23"
|
|
|
921
964
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
922
965
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
|
923
966
|
|
|
967
|
+
[[package]]
|
|
968
|
+
name = "semver"
|
|
969
|
+
version = "1.0.28"
|
|
970
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
971
|
+
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
|
972
|
+
|
|
924
973
|
[[package]]
|
|
925
974
|
name = "serde"
|
|
926
975
|
version = "1.0.229"
|
|
@@ -1064,6 +1113,12 @@ version = "0.1.4"
|
|
|
1064
1113
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1065
1114
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
|
1066
1115
|
|
|
1116
|
+
[[package]]
|
|
1117
|
+
name = "unicode-general-category"
|
|
1118
|
+
version = "1.1.0"
|
|
1119
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1120
|
+
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
|
1121
|
+
|
|
1067
1122
|
[[package]]
|
|
1068
1123
|
name = "unicode-ident"
|
|
1069
1124
|
version = "1.0.24"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepdiff-rs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
|
5
5
|
Classifier: Programming Language :: Rust
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -118,34 +118,36 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
|
|
|
118
118
|
|
|
119
119
|
`diff_tables` compares two tables the way `DeepDiff` compares two objects. It takes any object implementing the [Arrow PyCapsule interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html) — a pyarrow `Table` or `RecordBatch`, a polars `DataFrame`, a DuckDB relation — and imports it with no Python round trip. The two tables are matched on a required, non-empty set of key columns (the table's primary key).
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
It reports the **schema** diff (which columns were added, removed, or changed type) and the keyed **row** diff (which rows were added, removed, or changed, and which keys are duplicated). Rows are matched by the key columns; `rows_added`, `rows_removed`, and `duplicate_keys` return Arrow tables, and `summary()` counts each outcome. The per-cell diff (`cells_changed`) arrives in a later version and raises `NotImplementedError` until then.
|
|
122
122
|
|
|
123
123
|
```python
|
|
124
124
|
import pyarrow as pa
|
|
125
125
|
from deepdiff_rs import diff_tables
|
|
126
126
|
|
|
127
127
|
left = pa.table({
|
|
128
|
-
"id": pa.array([1, 2], pa.int64()),
|
|
129
|
-
"amount": pa.array([
|
|
128
|
+
"id": pa.array([1, 2, 3], pa.int64()),
|
|
129
|
+
"amount": pa.array([10, 20, 30], pa.int32()),
|
|
130
130
|
})
|
|
131
131
|
right = pa.table({
|
|
132
|
-
"id": pa.array([
|
|
133
|
-
"amount": pa.array([
|
|
134
|
-
"note": pa.array(["a", "b"], pa.string()),
|
|
132
|
+
"id": pa.array([2, 3, 4], pa.int64()),
|
|
133
|
+
"amount": pa.array([20, 31, 40], pa.int64()),
|
|
134
|
+
"note": pa.array(["a", "b", "c"], pa.string()),
|
|
135
135
|
})
|
|
136
136
|
|
|
137
137
|
diff = diff_tables(left, right, key=["id"])
|
|
138
138
|
print(diff.summary())
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
print("added ids:", pa.table(diff.rows_added()).column("id").to_pylist())
|
|
140
|
+
print("removed ids:", pa.table(diff.rows_removed()).column("id").to_pylist())
|
|
141
141
|
```
|
|
142
142
|
|
|
143
143
|
```
|
|
144
|
-
{'columns_added': 1, 'columns_removed': 0, 'columns_type_changed': 1}
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
{'columns_added': 1, 'columns_removed': 0, 'columns_type_changed': 1, 'rows_added': 1, 'rows_removed': 1, 'rows_changed': 1, 'duplicate_keys': 0, 'null_keys': 0}
|
|
145
|
+
added ids: [4]
|
|
146
|
+
removed ids: [1]
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
A key appearing more than once on either side is reported in `duplicate_keys` (with `left_count` and `right_count`) and excluded from the added/removed/changed sets; a null key matches its counterpart and is counted in `null_keys`. Rows are compared by the non-key columns present on *both* sides, with onix's value semantics (integers and integral floats fold together, `1.00` equals `1.0000`, a timestamp compares by its instant across units, dictionary-encoded values equal their plain form, and null equals null); a nested non-key column is out of scope and is skipped rather than compared. The exact value-comparison rules are documented on the hashing functions in [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
150
|
+
|
|
149
151
|
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` and `pandas` consume it directly, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`.
|
|
150
152
|
|
|
151
153
|
`pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra.
|
|
@@ -158,15 +160,16 @@ Type comparison uses the full logical Arrow type (timestamp unit and timezone, d
|
|
|
158
160
|
- **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`, `time` and `timedelta` are not supported. The normalized-versus-raw split is documented in [`tests/golden/README.md`](tests/golden/README.md).
|
|
159
161
|
- **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.
|
|
160
162
|
- A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
|
|
161
|
-
- A `str` inside a `tuple` or `frozenset` set item is
|
|
163
|
+
- A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
|
|
162
164
|
- Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
|
|
163
165
|
- `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
|
|
164
166
|
- A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
|
|
165
167
|
- Ordered sequences (`list` or `tuple`) of scalars (null, bool, number, string, datetime, date) run a `difflib`-style `O(N*M)` matcher on the default path with its popular-element (autojunk) purge disabled for `DeepDiff` parity, worst for sequences of a few repeated values with dense edits (about 620 s for two 8,000-element lists of two repeated values with every other element changed, growing faster than quadratically), so bound the size of untrusted sequences yourself. See [`crates/onix-core/src/lcs.rs`](crates/onix-core/src/lcs.rs).
|
|
166
|
-
-
|
|
167
|
-
- In `diff_tables`, a list of structs named exactly `key`/`value` with a nullable key and a real map are not distinguished, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type); see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs).
|
|
168
|
+
- `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
|
|
168
169
|
- In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
|
|
169
170
|
- `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
|
|
171
|
+
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M) and time is `N·log N` (about 2 s for a 10M-row pair), plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; and temp disk, because each input is read twice and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: bound the row count and, for duplicate-heavy data, the key width of untrusted input. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss`, single run, macOS on an Apple M-series laptop, 2026-09-05, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
172
|
+
- `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced).
|
|
170
173
|
- Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
|
|
171
174
|
|
|
172
175
|
## Performance
|
|
@@ -218,12 +221,12 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
|
|
|
218
221
|
|
|
219
222
|
## Reference
|
|
220
223
|
|
|
221
|
-
**Python API.** The public surface is `DeepDiff`, `diff_json`, `MaxDepthError`, and `MAX_DEPTH_CEILING`.
|
|
224
|
+
**Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
|
|
222
225
|
|
|
223
226
|
- `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`, and `datetime.date` (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` comes back as a real `datetime`/`date` — 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)]`).
|
|
224
227
|
- `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.
|
|
225
228
|
- `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
|
|
226
|
-
- `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 change counts, `.to_json()`
|
|
229
|
+
- `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()`, and `.duplicate_keys()` return Arrow tables, and `.cells_changed()` raises `NotImplementedError` until a later version.
|
|
227
230
|
|
|
228
231
|
**CLI.** `onix diff <a.json> <b.json> [--max-depth N] [--ignore-order] [--timing]` reads both files as JSON and prints a compact, single-line DeepDiff-compatible report to stdout (`{}` when there is no difference).
|
|
229
232
|
|
|
@@ -245,7 +248,7 @@ Exit codes:
|
|
|
245
248
|
```
|
|
246
249
|
crates/onix-core # the diff engine (library, no I/O)
|
|
247
250
|
crates/onix-cli # the `onix` binary (thin CLI over the core)
|
|
248
|
-
crates/onix-arrow # Arrow table diffing (schema diff
|
|
251
|
+
crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
|
|
249
252
|
crates/onix-py # PyO3 bindings, published as `deepdiff-rs`
|
|
250
253
|
scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
|
|
251
254
|
tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
|
|
@@ -100,34 +100,36 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
|
|
|
100
100
|
|
|
101
101
|
`diff_tables` compares two tables the way `DeepDiff` compares two objects. It takes any object implementing the [Arrow PyCapsule interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html) — a pyarrow `Table` or `RecordBatch`, a polars `DataFrame`, a DuckDB relation — and imports it with no Python round trip. The two tables are matched on a required, non-empty set of key columns (the table's primary key).
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
It reports the **schema** diff (which columns were added, removed, or changed type) and the keyed **row** diff (which rows were added, removed, or changed, and which keys are duplicated). Rows are matched by the key columns; `rows_added`, `rows_removed`, and `duplicate_keys` return Arrow tables, and `summary()` counts each outcome. The per-cell diff (`cells_changed`) arrives in a later version and raises `NotImplementedError` until then.
|
|
104
104
|
|
|
105
105
|
```python
|
|
106
106
|
import pyarrow as pa
|
|
107
107
|
from deepdiff_rs import diff_tables
|
|
108
108
|
|
|
109
109
|
left = pa.table({
|
|
110
|
-
"id": pa.array([1, 2], pa.int64()),
|
|
111
|
-
"amount": pa.array([
|
|
110
|
+
"id": pa.array([1, 2, 3], pa.int64()),
|
|
111
|
+
"amount": pa.array([10, 20, 30], pa.int32()),
|
|
112
112
|
})
|
|
113
113
|
right = pa.table({
|
|
114
|
-
"id": pa.array([
|
|
115
|
-
"amount": pa.array([
|
|
116
|
-
"note": pa.array(["a", "b"], pa.string()),
|
|
114
|
+
"id": pa.array([2, 3, 4], pa.int64()),
|
|
115
|
+
"amount": pa.array([20, 31, 40], pa.int64()),
|
|
116
|
+
"note": pa.array(["a", "b", "c"], pa.string()),
|
|
117
117
|
})
|
|
118
118
|
|
|
119
119
|
diff = diff_tables(left, right, key=["id"])
|
|
120
120
|
print(diff.summary())
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
print("added ids:", pa.table(diff.rows_added()).column("id").to_pylist())
|
|
122
|
+
print("removed ids:", pa.table(diff.rows_removed()).column("id").to_pylist())
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
```
|
|
126
|
-
{'columns_added': 1, 'columns_removed': 0, 'columns_type_changed': 1}
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
{'columns_added': 1, 'columns_removed': 0, 'columns_type_changed': 1, 'rows_added': 1, 'rows_removed': 1, 'rows_changed': 1, 'duplicate_keys': 0, 'null_keys': 0}
|
|
127
|
+
added ids: [4]
|
|
128
|
+
removed ids: [1]
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
+
A key appearing more than once on either side is reported in `duplicate_keys` (with `left_count` and `right_count`) and excluded from the added/removed/changed sets; a null key matches its counterpart and is counted in `null_keys`. Rows are compared by the non-key columns present on *both* sides, with onix's value semantics (integers and integral floats fold together, `1.00` equals `1.0000`, a timestamp compares by its instant across units, dictionary-encoded values equal their plain form, and null equals null); a nested non-key column is out of scope and is skipped rather than compared. The exact value-comparison rules are documented on the hashing functions in [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
132
|
+
|
|
131
133
|
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` and `pandas` consume it directly, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`.
|
|
132
134
|
|
|
133
135
|
`pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra.
|
|
@@ -140,15 +142,16 @@ Type comparison uses the full logical Arrow type (timestamp unit and timezone, d
|
|
|
140
142
|
- **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`, `time` and `timedelta` are not supported. The normalized-versus-raw split is documented in [`tests/golden/README.md`](tests/golden/README.md).
|
|
141
143
|
- **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.
|
|
142
144
|
- A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
|
|
143
|
-
- A `str` inside a `tuple` or `frozenset` set item is
|
|
145
|
+
- A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
|
|
144
146
|
- Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
|
|
145
147
|
- `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
|
|
146
148
|
- A `values_changed` between two multi-line strings runs a `difflib`-style `O(N*M)` line diff on the default path with no opt-out, worst when changes are spread evenly through the text (about 35 s for a heavily edited 1 MB string and growing quadratically, so a few megabytes is minutes), so bound the size of untrusted strings yourself. See [`crates/onix-core/src/unified_diff.rs`](crates/onix-core/src/unified_diff.rs).
|
|
147
149
|
- Ordered sequences (`list` or `tuple`) of scalars (null, bool, number, string, datetime, date) run a `difflib`-style `O(N*M)` matcher on the default path with its popular-element (autojunk) purge disabled for `DeepDiff` parity, worst for sequences of a few repeated values with dense edits (about 620 s for two 8,000-element lists of two repeated values with every other element changed, growing faster than quadratically), so bound the size of untrusted sequences yourself. See [`crates/onix-core/src/lcs.rs`](crates/onix-core/src/lcs.rs).
|
|
148
|
-
-
|
|
149
|
-
- In `diff_tables`, a list of structs named exactly `key`/`value` with a nullable key and a real map are not distinguished, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type); see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs).
|
|
150
|
+
- `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
|
|
150
151
|
- In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
|
|
151
152
|
- `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
|
|
153
|
+
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M) and time is `N·log N` (about 2 s for a 10M-row pair), plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; and temp disk, because each input is read twice and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), both spools resident at once, peaking at the decoded size of both inputs (about 315 MB — 161 + 154 MB uncompressed Arrow IPC — for the 1M-row fixture pair; on the order of 10 GB for the full 5 GB-per-side fixture pair, which on Linux may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: bound the row count and, for duplicate-heavy data, the key width of untrusted input. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss`, single run, macOS on an Apple M-series laptop, 2026-09-05, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
154
|
+
- `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced).
|
|
152
155
|
- Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
|
|
153
156
|
|
|
154
157
|
## Performance
|
|
@@ -200,12 +203,12 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
|
|
|
200
203
|
|
|
201
204
|
## Reference
|
|
202
205
|
|
|
203
|
-
**Python API.** The public surface is `DeepDiff`, `diff_json`, `MaxDepthError`, and `MAX_DEPTH_CEILING`.
|
|
206
|
+
**Python API.** The public surface is `DeepDiff`, `diff_json`, `diff_tables` (returning a `TableDiff`), `MaxDepthError`, and `MAX_DEPTH_CEILING`.
|
|
204
207
|
|
|
205
208
|
- `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`, and `datetime.date` (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` comes back as a real `datetime`/`date` — 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
209
|
- `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.
|
|
207
210
|
- `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
|
|
208
|
-
- `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 change counts, `.to_json()`
|
|
211
|
+
- `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()`, and `.duplicate_keys()` return Arrow tables, and `.cells_changed()` raises `NotImplementedError` until a later version.
|
|
209
212
|
|
|
210
213
|
**CLI.** `onix diff <a.json> <b.json> [--max-depth N] [--ignore-order] [--timing]` reads both files as JSON and prints a compact, single-line DeepDiff-compatible report to stdout (`{}` when there is no difference).
|
|
211
214
|
|
|
@@ -227,7 +230,7 @@ Exit codes:
|
|
|
227
230
|
```
|
|
228
231
|
crates/onix-core # the diff engine (library, no I/O)
|
|
229
232
|
crates/onix-cli # the `onix` binary (thin CLI over the core)
|
|
230
|
-
crates/onix-arrow # Arrow table diffing (schema diff
|
|
233
|
+
crates/onix-arrow # Arrow table diffing (schema diff and keyed row diff)
|
|
231
234
|
crates/onix-py # PyO3 bindings, published as `deepdiff-rs`
|
|
232
235
|
scripts/ # gen_goldens.py: regenerates tests/golden/ from real DeepDiff
|
|
233
236
|
tests/golden # DeepDiff-generated expected outputs (the compatibility corpus)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "onix-arrow"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
publish = false
|
|
7
|
+
description = "Arrow table diffing (schema and keyed rows) built on the onix diff engine"
|
|
8
|
+
|
|
9
|
+
[lints]
|
|
10
|
+
workspace = true
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
# Pinned to an exact Arrow version so this crate and the `pyo3-arrow` bridge
|
|
14
|
+
# in `onix-py` (which requires `arrow` ^59) resolve to the identical Arrow
|
|
15
|
+
# types — a mismatch would make the record batches handed across the FFI
|
|
16
|
+
# boundary a different, incompatible type. 59.3.0 is the newest 59.x at the
|
|
17
|
+
# time this crate landed. Only the schema/array sub-crates are pulled in: the
|
|
18
|
+
# C Data Interface glue lives in `onix-py` behind `pyo3-arrow`, so this crate
|
|
19
|
+
# needs no `ffi` feature.
|
|
20
|
+
arrow-schema = "=59.3.0"
|
|
21
|
+
arrow-array = "=59.3.0"
|
|
22
|
+
# `i256` for hashing `Decimal256` cells the same way as `Decimal128`.
|
|
23
|
+
arrow-buffer = "=59.3.0"
|
|
24
|
+
# `arrow-cast` decodes dictionary-encoded columns to their value type before
|
|
25
|
+
# hashing, and casts a right-only duplicate key's columns to the left key
|
|
26
|
+
# types; `arrow-select` filters and concatenates the output row batches. The
|
|
27
|
+
# row diff re-reads each side through the caller's re-openable `TableInput`, so
|
|
28
|
+
# no IPC spooling lives here — the Python bindings own that.
|
|
29
|
+
arrow-cast = "=59.3.0"
|
|
30
|
+
arrow-select = "=59.3.0"
|
|
31
|
+
# Row identity is a keyed 128-bit SipHash-1-3 (`siphasher`), keyed from 16
|
|
32
|
+
# bytes of OS randomness per diff (`getrandom`), so the row-matching table on
|
|
33
|
+
# this default path cannot be forced into collisions by chosen input.
|
|
34
|
+
siphasher = "1"
|
|
35
|
+
getrandom = "0.3"
|
|
36
|
+
serde = { version = "1", features = ["derive"] }
|
|
37
|
+
serde_json = "1"
|
|
38
|
+
|
|
39
|
+
[dev-dependencies]
|
|
40
|
+
proptest = "1"
|
|
41
|
+
# `half::f16` builds a Float16 test column; the same version arrow-array uses.
|
|
42
|
+
half = "2"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
//! Measures the keyed row diff's peak memory and wall time, to check the memory
|
|
2
|
+
//! bounds the README states.
|
|
3
|
+
//!
|
|
4
|
+
//! Run under the OS's max-RSS reporter:
|
|
5
|
+
//!
|
|
6
|
+
//! ```sh
|
|
7
|
+
//! cargo build -p onix-arrow --release --example row_diff_rss
|
|
8
|
+
//! # linear shape (default): mostly-matching rows, 1% added/removed, ~2% changed
|
|
9
|
+
//! /usr/bin/time -l target/release/examples/row_diff_rss 1000000
|
|
10
|
+
//! /usr/bin/time -l target/release/examples/row_diff_rss 10000000
|
|
11
|
+
//! # duplicate-heavy shape: every key duplicated, wide string key
|
|
12
|
+
//! /usr/bin/time -l target/release/examples/row_diff_rss 1000000 dup 16
|
|
13
|
+
//! /usr/bin/time -l target/release/examples/row_diff_rss 200000 dup 1024
|
|
14
|
+
//! ```
|
|
15
|
+
//!
|
|
16
|
+
//! Each side is generated on the fly, batch by batch, and nothing is retained
|
|
17
|
+
//! between batches, so the process's peak RSS is the diff's own state, not the
|
|
18
|
+
//! table data. The **linear** shape (`id`, `value` int64 columns) exercises the
|
|
19
|
+
//! per-row hash vectors: the left is ids `0..n`, the right `step..n + step` with
|
|
20
|
+
//! `step = n / 100`, so 1% removed, 1% added, ~2% changed. The **dup** shape
|
|
21
|
+
//! (`key` Utf8 of the given width, `value` int64) makes every key appear twice
|
|
22
|
+
//! on each side, so every distinct key is a duplicate and the whole
|
|
23
|
+
//! `duplicate_keys` report is materialized — the term that scales with distinct
|
|
24
|
+
//! duplicated keys times the key width.
|
|
25
|
+
|
|
26
|
+
use std::sync::Arc;
|
|
27
|
+
|
|
28
|
+
use arrow_array::{ArrayRef, Int64Array, RecordBatch, RecordBatchReader, StringArray};
|
|
29
|
+
use arrow_schema::{ArrowError, DataType, Field, Schema, SchemaRef};
|
|
30
|
+
use onix_arrow::{TableDiffError, TableDiffOptions, TableInput, diff_tables};
|
|
31
|
+
|
|
32
|
+
const BATCH: i64 = 65_536;
|
|
33
|
+
|
|
34
|
+
/// The generated table shape.
|
|
35
|
+
#[derive(Clone, Copy)]
|
|
36
|
+
enum Shape {
|
|
37
|
+
/// `(id, value)` int64 columns; `id_offset` shifts the key range and
|
|
38
|
+
/// `change_every` perturbs a fraction of values.
|
|
39
|
+
Linear { id_offset: i64, change_every: i64 },
|
|
40
|
+
/// `(key, value)`; `key` is a `key_width`-byte string and each key value
|
|
41
|
+
/// appears twice, so every key is a duplicate.
|
|
42
|
+
Dup { key_width: usize },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// A table generated on demand, retaining nothing between batches.
|
|
46
|
+
struct Generated {
|
|
47
|
+
schema: SchemaRef,
|
|
48
|
+
rows: i64,
|
|
49
|
+
shape: Shape,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl TableInput for Generated {
|
|
53
|
+
fn schema(&self) -> SchemaRef {
|
|
54
|
+
self.schema.clone()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn open(&self) -> Result<Box<dyn RecordBatchReader + Send>, TableDiffError> {
|
|
58
|
+
Ok(Box::new(GenReader {
|
|
59
|
+
schema: self.schema.clone(),
|
|
60
|
+
rows: self.rows,
|
|
61
|
+
shape: self.shape,
|
|
62
|
+
next: 0,
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
struct GenReader {
|
|
68
|
+
schema: SchemaRef,
|
|
69
|
+
rows: i64,
|
|
70
|
+
shape: Shape,
|
|
71
|
+
next: i64,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
impl Iterator for GenReader {
|
|
75
|
+
type Item = Result<RecordBatch, ArrowError>;
|
|
76
|
+
|
|
77
|
+
fn next(&mut self) -> Option<Self::Item> {
|
|
78
|
+
if self.next >= self.rows {
|
|
79
|
+
return None;
|
|
80
|
+
}
|
|
81
|
+
let end = (self.next + BATCH).min(self.rows);
|
|
82
|
+
let columns: Vec<ArrayRef> = match self.shape {
|
|
83
|
+
Shape::Linear {
|
|
84
|
+
id_offset,
|
|
85
|
+
change_every,
|
|
86
|
+
} => {
|
|
87
|
+
let ids: Int64Array = (self.next..end).map(|i| Some(i + id_offset)).collect();
|
|
88
|
+
// The value is a function of the id, so a shared key holds the
|
|
89
|
+
// same value on both sides except every `change_every`-th id.
|
|
90
|
+
let values: Int64Array = (self.next..end)
|
|
91
|
+
.map(|i| {
|
|
92
|
+
let id = i + id_offset;
|
|
93
|
+
Some(if id % change_every == 0 { id + 1 } else { id })
|
|
94
|
+
})
|
|
95
|
+
.collect();
|
|
96
|
+
vec![Arc::new(ids), Arc::new(values)]
|
|
97
|
+
}
|
|
98
|
+
Shape::Dup { key_width } => {
|
|
99
|
+
// Key value `i / 2`, so each distinct key appears twice.
|
|
100
|
+
let keys: StringArray = (self.next..end)
|
|
101
|
+
.map(|i| Some(format!("{:0>width$}", i / 2, width = key_width)))
|
|
102
|
+
.collect();
|
|
103
|
+
let values: Int64Array = (self.next..end).map(Some).collect();
|
|
104
|
+
vec![Arc::new(keys), Arc::new(values)]
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
self.next = end;
|
|
108
|
+
|
|
109
|
+
Some(RecordBatch::try_new(self.schema.clone(), columns))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl RecordBatchReader for GenReader {
|
|
114
|
+
fn schema(&self) -> SchemaRef {
|
|
115
|
+
self.schema.clone()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn main() {
|
|
120
|
+
let args: Vec<String> = std::env::args().collect();
|
|
121
|
+
let rows: i64 = args
|
|
122
|
+
.get(1)
|
|
123
|
+
.and_then(|a| a.parse().ok())
|
|
124
|
+
.unwrap_or(1_000_000);
|
|
125
|
+
let dup = args.get(2).is_some_and(|a| a == "dup");
|
|
126
|
+
let key_width: usize = args.get(3).and_then(|a| a.parse().ok()).unwrap_or(16);
|
|
127
|
+
|
|
128
|
+
let (schema, left_shape, right_shape, key) = if dup {
|
|
129
|
+
let schema = Arc::new(Schema::new(vec![
|
|
130
|
+
Field::new("key", DataType::Utf8, false),
|
|
131
|
+
Field::new("value", DataType::Int64, false),
|
|
132
|
+
]));
|
|
133
|
+
(
|
|
134
|
+
schema,
|
|
135
|
+
Shape::Dup { key_width },
|
|
136
|
+
Shape::Dup { key_width },
|
|
137
|
+
"key",
|
|
138
|
+
)
|
|
139
|
+
} else {
|
|
140
|
+
let schema = Arc::new(Schema::new(vec![
|
|
141
|
+
Field::new("id", DataType::Int64, false),
|
|
142
|
+
Field::new("value", DataType::Int64, false),
|
|
143
|
+
]));
|
|
144
|
+
let step = (rows / 100).max(1);
|
|
145
|
+
(
|
|
146
|
+
schema,
|
|
147
|
+
Shape::Linear {
|
|
148
|
+
id_offset: 0,
|
|
149
|
+
change_every: i64::MAX,
|
|
150
|
+
},
|
|
151
|
+
Shape::Linear {
|
|
152
|
+
id_offset: step,
|
|
153
|
+
change_every: 50,
|
|
154
|
+
},
|
|
155
|
+
"id",
|
|
156
|
+
)
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let left = Generated {
|
|
160
|
+
schema: schema.clone(),
|
|
161
|
+
rows,
|
|
162
|
+
shape: left_shape,
|
|
163
|
+
};
|
|
164
|
+
let right = Generated {
|
|
165
|
+
schema,
|
|
166
|
+
rows,
|
|
167
|
+
shape: right_shape,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let start = std::time::Instant::now();
|
|
171
|
+
let diff = diff_tables(&left, &right, &TableDiffOptions::new(vec![key.to_string()]))
|
|
172
|
+
.expect("diff succeeds");
|
|
173
|
+
let elapsed = start.elapsed();
|
|
174
|
+
let summary = diff.summary();
|
|
175
|
+
|
|
176
|
+
println!(
|
|
177
|
+
"rows per side: {rows}{}",
|
|
178
|
+
if dup {
|
|
179
|
+
format!(" (dup, key_width={key_width})")
|
|
180
|
+
} else {
|
|
181
|
+
String::new()
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
println!("wall: {:.2}s", elapsed.as_secs_f64());
|
|
185
|
+
println!(
|
|
186
|
+
"rows_added={} rows_removed={} rows_changed={} duplicate_keys={}",
|
|
187
|
+
summary.rows_added, summary.rows_removed, summary.rows_changed, summary.duplicate_keys
|
|
188
|
+
);
|
|
189
|
+
}
|