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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/Cargo.lock +4 -4
  2. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/PKG-INFO +3 -3
  4. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/README.md +2 -2
  5. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/dispatch.rs +3 -3
  6. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/object.rs +2 -2
  7. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/scalar.rs +1 -1
  8. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/tests.rs +13 -7
  9. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/distance.rs +41 -20
  10. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/hash.rs +9 -5
  11. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/tests.rs +2 -2
  12. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/lcs.rs +4 -2
  13. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/path.rs +150 -68
  14. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/report.rs +18 -21
  15. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/report_tests.rs +10 -7
  16. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/test_support.rs +1 -1
  17. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/unified_diff.rs +11 -1
  18. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/value.rs +530 -47
  19. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/value_tests.rs +461 -8
  20. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/tests/golden.rs +11 -1
  21. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/tests/ignore_order_memory.rs +4 -4
  22. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/convert.rs +107 -55
  23. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/deepdiff.rs +35 -16
  24. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/fast_path.rs +5 -2
  25. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/guard.rs +116 -49
  26. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_conversions.py +92 -39
  27. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_differential_fuzz.py +159 -0
  28. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/Cargo.toml +0 -0
  29. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
  30. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  31. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/error.rs +0 -0
  32. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/json_rows.rs +0 -0
  33. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/lib.rs +0 -0
  34. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/options.rs +0 -0
  35. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/row_diff.rs +0 -0
  36. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/schema.rs +0 -0
  37. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-arrow/src/table_diff.rs +0 -0
  38. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/Cargo.toml +0 -0
  39. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  40. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/datetime.rs +0 -0
  41. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/datetime_tests.rs +0 -0
  42. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/array.rs +0 -0
  43. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/mod.rs +0 -0
  44. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/options.rs +0 -0
  45. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/diff/set.rs +0 -0
  46. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/error.rs +0 -0
  47. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
  48. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/memo.rs +0 -0
  49. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  50. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  51. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/lcs_tests.rs +0 -0
  52. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/lib.rs +0 -0
  53. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  54. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/tests/memory_footprint.rs +0 -0
  55. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/tests/proptest_diff.rs +0 -0
  56. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  57. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/.python-version +0 -0
  58. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/Cargo.toml +0 -0
  59. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  60. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/deepdiff_rs.pyi +0 -0
  61. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/arrow.rs +0 -0
  62. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/errors.rs +0 -0
  63. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/src/lib.rs +0 -0
  64. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/conftest.py +0 -0
  65. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  66. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_datetimes.py +0 -0
  67. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_depth_guard.py +0 -0
  68. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_golden_parity.py +0 -0
  69. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_non_finite.py +0 -0
  70. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_sets.py +0 -0
  71. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_signed_zero.py +0 -0
  72. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_smoke.py +0 -0
  73. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_stub_mypy.py +0 -0
  74. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_stub_signatures.py +0 -0
  75. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  76. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_table_diff.py +0 -0
  77. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  78. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_timedeltas.py +0 -0
  79. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_times.py +0 -0
  80. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_tuples.py +0 -0
  81. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/crates/onix-py/tests/test_wheel_contents.py +0 -0
  82. {deepdiff_rs-0.9.2 → deepdiff_rs-0.9.3}/pyproject.toml +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.9.2"
632
+ version = "0.9.3"
633
633
  dependencies = [
634
634
  "arrow-array",
635
635
  "arrow-buffer",
@@ -646,7 +646,7 @@ dependencies = [
646
646
 
647
647
  [[package]]
648
648
  name = "onix-cli"
649
- version = "0.9.2"
649
+ version = "0.9.3"
650
650
  dependencies = [
651
651
  "onix-core",
652
652
  "serde_json",
@@ -654,7 +654,7 @@ dependencies = [
654
654
 
655
655
  [[package]]
656
656
  name = "onix-core"
657
- version = "0.9.2"
657
+ version = "0.9.3"
658
658
  dependencies = [
659
659
  "proptest",
660
660
  "serde",
@@ -665,7 +665,7 @@ dependencies = [
665
665
 
666
666
  [[package]]
667
667
  name = "onix-py"
668
- version = "0.9.2"
668
+ version = "0.9.3"
669
669
  dependencies = [
670
670
  "arrow-array",
671
671
  "arrow-ipc",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py", "crates/onix-arrow"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.9.2"
6
+ version = "0.9.3"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepdiff-rs
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -148,7 +148,7 @@ A key appearing more than once on either side is reported in `duplicate_keys` (w
148
148
 
149
149
  Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` consumes it with no pyarrow needed, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but pandas' own implementation of that protocol needs pyarrow installed regardless of which path you take.
150
150
 
151
- `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
151
+ `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra; `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
152
152
 
153
153
  ## Performance
154
154
 
@@ -241,7 +241,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
241
241
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
242
242
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
243
243
  - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
244
- - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
244
+ - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
245
245
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
246
246
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
247
247
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
@@ -130,7 +130,7 @@ A key appearing more than once on either side is reported in `duplicate_keys` (w
130
130
 
131
131
  Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings that carry the same logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes. The full normalization rules are documented on `normalized_type` (and `map_entries`) in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs); nullability is ignored but reported in each record. Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` consumes it with no pyarrow needed, and `diff.schema_arrow.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but pandas' own implementation of that protocol needs pyarrow installed regardless of which path you take.
132
132
 
133
- `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
133
+ `pyarrow` is optional: install it with `pip install deepdiff-rs[arrow]`. It is needed only for `to_pyarrow()` and for passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. Passing an object that implements neither Arrow protocol raises `TypeError`; calling `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra; `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed at all; see [Known limitations](#known-limitations) for its row cap.
134
134
 
135
135
  ## Performance
136
136
 
@@ -223,7 +223,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
223
223
  - **Datetimes** compare by instant, with a naive value read as UTC, matching DeepDiff. A changed pair is reported normalized to UTC (`to_json()` renders `...+00:00`, `to_dict()` returns UTC-aware `datetime`s); everywhere else a datetime keeps its raw value. Three deliberate departures: `to_json()` renders a `date` as `YYYY-MM-DD` where DeepDiff's own `to_json()` raises `TypeError` (a documented superset); a `zoneinfo`/`pytz` tzinfo comes back from `to_dict()` as a fixed-offset `datetime.timezone` carrying the offset it was in force at, not the original zone object; and a set holding both a naive and an aware value at one instant reports both as members, where DeepDiff's own digest cache can report only one (see [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs)). Comparing two datetimes whose UTC form would leave year 1..=9999 raises `ValueError` naming the path, where DeepDiff raises `OverflowError`; under `ignore_order` DeepDiff's hasher normalizes every datetime and so raises for such a value even when it is only added, removed, or shuffled, where onix hashes by instant and reports it normally (see [`tests/golden/README.md`](tests/golden/README.md)). `truncate_datetime` is not supported; the normalized-versus-raw split is documented there too. A `time`/`timedelta`, unlike a datetime, is never normalized for report (DeepDiff compares `time`/`date`/`timedelta` with a plain `!=`), and a naive `time` is never equal to an aware one; `to_json()` renders a `time` as `time.isoformat()`'s bytes and a `timedelta` as `str(timedelta)`'s, both supersets. Under `ignore_order`, `DeepHash` hashes a `time` by whole seconds-of-day only — dropping the microsecond and any offset, a confirmed upstream quirk — while a `timedelta` hashes exactly; see [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
224
224
  - **Sets** are diffed deterministically, where DeepDiff's own answers depend on the order the running process happens to iterate a set in (hash order, and `PYTHONHASHSEED`-dependent for `str` members) or on how its digest cache/computation handles a tuple, frozenset, or calendar member independently of Python's own `==`. Each consequence — entry order, which member of an equality class is reported, set-versus-sequence coercion, and a tuple/frozenset member's own (positional, not order-/repetition-insensitive) matching rule — is shown with both tools' output in [`tests/golden/README.md`](tests/golden/README.md)'s "Set iteration order" section. A report holding a `frozenset` value also serializes to JSON here, where DeepDiff's own `to_json()` raises `TypeError` — a superset, not a difference in the findings.
225
225
  - **Non-finite floats** (`NaN`, `Infinity`, `-Infinity`) compare and hash like real Python: two `NaN`s are never equal, `Infinity == Infinity`, `to_json()` renders the same bare `NaN`/`Infinity`/`-Infinity` tokens Python's `json.dumps` does, and `ignore_order` matching treats every `NaN` as one shared item, matching `DeepHash`. The one divergence, always deterministic: this crate's value model carries no Python object identity, so two independently-obtained `NaN`s always compare unequal here, where DeepDiff sometimes reports no difference (`t1 is t2`) or lets one collapse into another (a `set` member, an ordered-list match) when the two objects, or their containers, happen to be the same one. See [`tests/golden/README.md`](tests/golden/README.md)'s "Non-finite floats" section.
226
- - A `str` containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) raises `ValueError` naming the exact path on either side, before the two values are ever compared — including a pair DeepDiff would call equal and report as no change, since DeepDiff's scalar equality is plain Python `==` and never hits the encoding problem; DeepDiff does report a plain change for a *differing* pair, and crashes with an unhandled `UnicodeEncodeError` if such a string is ever hashed (a `set`/`frozenset` member). See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
226
+ - A `str` (or `dict` key) containing a lone (unpaired) surrogate code point (e.g. `'\udc80'`, legal in Python but not encodable as UTF-8) is compared and reported exactly like any other `str`, matching DeepDiff's plain `==`; `to_json()` renders it with `json.dumps`'s own single-backslash `\uXXXX` escape. The one accepted divergence, always deterministic: hashing one — a `set`/`frozenset` member, or *any* value at all once `ignore_order=True` (`DeepHash` hashes every value there, not just a set's members) — crashes real DeepDiff with an unhandled `UnicodeEncodeError`, where onix hashes by code point and reports normally. See [`tests/golden/README.md`](tests/golden/README.md)'s "Known DeepDiff quirks" section.
227
227
  - A `str` inside a `tuple` or `frozenset` set item is escaped exactly as Python's `repr()` escapes it, against Unicode 16.0.0; on a Python older than 3.14 (an older `unicodedata` table), a code point assigned to Unicode after that Python's own version is escaped by DeepDiff and rendered literally by onix. See [`tests/golden/README.md`](tests/golden/README.md)'s "Pinned versions" section.
228
228
  - Adversarially deep input raises `MaxDepthError` instead of crashing: the default `max_depth` is 512 and the hard ceiling is `MAX_DEPTH_CEILING` (20,000). See [`crates/onix-py/src/guard.rs`](crates/onix-py/src/guard.rs).
229
229
  - `ignore_order` pairing is `O(N^2)` in unpaired elements per side and carries a polynomial cost in both time and memory with input depth; it has no `max_passes`/`max_diffs` cutoff, so bound the size and depth of untrusted input yourself. See [`crates/onix-core/src/ignore_order/mod.rs`](crates/onix-core/src/ignore_order/mod.rs).
@@ -222,7 +222,7 @@ pub(crate) fn check_value_depth(
222
222
  ) -> Result<(), Error> {
223
223
  if deeper_than(value, max_depth.saturating_sub(depth)) {
224
224
  return Err(Error::MaxDepthExceeded {
225
- path: render_path(path),
225
+ path: render_path(path).to_string(),
226
226
  max_depth,
227
227
  });
228
228
  }
@@ -266,7 +266,7 @@ pub(crate) fn check_map_depth(
266
266
  ) -> Result<(), Error> {
267
267
  if map_deeper_than(map, max_depth.saturating_sub(depth)) {
268
268
  return Err(Error::MaxDepthExceeded {
269
- path: render_path(path),
269
+ path: render_path(path).to_string(),
270
270
  max_depth,
271
271
  });
272
272
  }
@@ -294,7 +294,7 @@ pub(crate) fn check_traversal_depth(
294
294
  ) -> Result<(), Error> {
295
295
  if depth > max_depth {
296
296
  return Err(Error::MaxDepthExceeded {
297
- path: render_path(path),
297
+ path: render_path(path).to_string(),
298
298
  max_depth,
299
299
  });
300
300
  }
@@ -113,7 +113,7 @@ pub(crate) fn object_diff(
113
113
  // own path sits at), not the *parent* dict's `depth`.
114
114
  for (key, old_value) in a {
115
115
  scoped(path, key_segment(key), |path| -> Result<(), Error> {
116
- match key.as_str().and_then(|s| b.get_str(s)) {
116
+ match b.get(key) {
117
117
  None => check_value_depth(path, old_value, depth + 1, opts.max_depth).map(|()| {
118
118
  report.insert_dictionary_item_removed(path.clone(), old_value.clone());
119
119
  }),
@@ -124,7 +124,7 @@ pub(crate) fn object_diff(
124
124
  }
125
125
 
126
126
  for (key, new_value) in b {
127
- if key.as_str().is_none_or(|s| !a.contains_key_str(s)) {
127
+ if !a.contains_key(key) {
128
128
  scoped(path, key_segment(key), |path| {
129
129
  check_value_depth(path, new_value, depth + 1, opts.max_depth).map(|()| {
130
130
  report.insert_dictionary_item_added(path.clone(), new_value.clone());
@@ -159,7 +159,7 @@ pub(crate) fn normalized_pair(
159
159
  new: DateTime,
160
160
  ) -> Result<(DateTime, DateTime), Error> {
161
161
  let out_of_range = || Error::DateTimeOutOfRange {
162
- path: render_path(path),
162
+ path: render_path(path).to_string(),
163
163
  };
164
164
 
165
165
  Ok((
@@ -1235,20 +1235,23 @@ fn threshold_collapse_rejects_a_deep_side_on_a_constrained_stack_instead_of_cras
1235
1235
  };
1236
1236
  let a = CValue::Object(CObject::from_pairs(vec![
1237
1237
  (
1238
- ObjectKey::Str(std::sync::Arc::from("p")),
1238
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("p"))),
1239
1239
  CValue::from(json!(1)),
1240
1240
  ),
1241
1241
  (
1242
- ObjectKey::Str(std::sync::Arc::from("q")),
1242
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("q"))),
1243
1243
  CValue::from(json!(2)),
1244
1244
  ),
1245
1245
  ]));
1246
1246
  let b = CValue::Object(CObject::from_pairs(vec![
1247
1247
  (
1248
- ObjectKey::Str(std::sync::Arc::from("r")),
1248
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("r"))),
1249
1249
  CValue::from(json!(3)),
1250
1250
  ),
1251
- (ObjectKey::Str(std::sync::Arc::from("deep")), deep),
1251
+ (
1252
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("deep"))),
1253
+ deep,
1254
+ ),
1252
1255
  ]));
1253
1256
 
1254
1257
  let err = super::diff_with_max_depth(&a, &b, 1).unwrap_err();
@@ -1992,7 +1995,7 @@ fn list_vs_tuple_nested_in_a_dict_is_a_type_change() {
1992
1995
  let mut a = Map::new();
1993
1996
  a.insert("a".to_string(), json!([1, 2]));
1994
1997
  let b = CValue::Object(CObject::from_pairs(vec![(
1995
- ObjectKey::Str(std::sync::Arc::from("a")),
1998
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("a"))),
1996
1999
  ctup(&[json!(1), json!(2)]),
1997
2000
  )]));
1998
2001
  let report = super::diff(&cv(&Value::Object(a)), &b).unwrap();
@@ -2127,7 +2130,7 @@ fn a_dict_value_that_is_a_too_deep_tuple_errors_instead_of_being_cloned() {
2127
2130
  deep = ctuple(vec![deep]);
2128
2131
  }
2129
2132
  let b = CValue::Object(CObject::from_pairs(vec![(
2130
- ObjectKey::Str(std::sync::Arc::from("a")),
2133
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("a"))),
2131
2134
  deep,
2132
2135
  )]));
2133
2136
  let error = super::diff_with_max_depth(&cv(&json!({})), &b, 4).unwrap_err();
@@ -2711,7 +2714,10 @@ fn two_set_subclasses_of_the_same_class_diff_by_membership() {
2711
2714
 
2712
2715
  #[test]
2713
2716
  fn an_object_subclass_versus_the_base_type_is_a_type_change_at_equal_value() {
2714
- let entries = vec![(ObjectKey::Str(std::sync::Arc::from("a")), cv(&json!(1)))];
2717
+ let entries = vec![(
2718
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from("a"))),
2719
+ cv(&json!(1)),
2720
+ )];
2715
2721
  let base = CValue::Object(CObject::from_pairs(entries.clone()));
2716
2722
  let subclass = CValue::Object(
2717
2723
  CObject::from_pairs(entries).with_type_name(Some(std::sync::Arc::from("MyDict"))),
@@ -256,9 +256,11 @@ 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
- // 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".
259
+ // A non-`str` key, and a `str` key holding a lone surrogate (never
260
+ // equal to one of the plain-ASCII names below — see `ObjectKey::
261
+ // as_str`, `None` for both), can never match the literal exclusion
262
+ // list, so it is always counted — `Option::is_none_or` reads as
263
+ // "excluded only when this is a plain `str` key that matches".
262
264
  .filter(|(key, _)| key.as_str().is_none_or(|s| !is_length_excluded_key(s)))
263
265
  .map(|(_, v)| item_length(v))
264
266
  .sum()
@@ -524,7 +526,7 @@ fn coerce_for_type_change(old_value: &Value, new_value: &Value) -> Option<Value>
524
526
  coerce_to_f64(old_value).map(|f| Value::Number(Number::from_f64(f)))
525
527
  }
526
528
  Value::Number(_) => coerce_to_i64(old_value).map(|i| Value::Number(Number::from_i64(i))),
527
- Value::Str(_) => coerce_to_python_str(old_value).map(|s| Value::Str(s.into_boxed_str())),
529
+ Value::Str(_) => coerce_to_python_str(old_value).map(|s| Value::Str(s.into())),
528
530
  Value::Null
529
531
  | Value::DateTime(_)
530
532
  | Value::Date(_)
@@ -561,6 +563,9 @@ fn is_truthy(value: &Value) -> bool {
561
563
  }
562
564
  Value::Bool(b) => *b,
563
565
  Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
566
+ // Never empty by construction for `Str::Wtf8` (see `Str::is_empty`),
567
+ // so this is always `true` there — matching `bool("\udc80") ==
568
+ // True` in real Python.
564
569
  Value::Str(s) => !s.is_empty(),
565
570
  Value::Array(items) | Value::Tuple(items) => !items.is_empty(),
566
571
  Value::Set(items) | Value::FrozenSet(items) => !items.is_empty(),
@@ -587,7 +592,9 @@ fn coerce_to_f64(value: &Value) -> Option<f64> {
587
592
  | Value::Object(_) => None,
588
593
  Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
589
594
  Value::Number(n) => n.as_f64(),
590
- Value::Str(s) => s.trim().parse::<f64>().ok(),
595
+ // A `Str::Wtf8` (a lone surrogate present) is never a valid float
596
+ // literal, matching `float("\udc80")` raising in real Python.
597
+ Value::Str(s) => s.as_utf8().and_then(|s| s.trim().parse::<f64>().ok()),
591
598
  }
592
599
  }
593
600
 
@@ -642,7 +649,8 @@ fn coerce_to_i64(value: &Value) -> Option<i64> {
642
649
  .or_else(|| n.as_u64().and_then(|u| i64::try_from(u).ok()))
643
650
  }
644
651
  }
645
- Value::Str(s) => s.trim().parse::<i64>().ok(),
652
+ // See `coerce_to_f64`'s doc for the `Str::Wtf8` case.
653
+ Value::Str(s) => s.as_utf8().and_then(|s| s.trim().parse::<i64>().ok()),
646
654
  }
647
655
  }
648
656
 
@@ -690,7 +698,13 @@ fn coerce_to_python_str(value: &Value) -> Option<String> {
690
698
  n.as_u64().map(|u| u.to_string())
691
699
  }
692
700
  }
693
- Value::Str(s) => Some(s.to_string()),
701
+ // `str(x)` is the identity for a value already a `str`, including
702
+ // one holding a lone surrogate — but this coercion only ever needs
703
+ // to build a `String`, which cannot hold one, so a `Str::Wtf8`
704
+ // falls through to `None` here (an accepted, narrow gap: it only
705
+ // costs an unnecessary type-change inclusion, never a wrong one —
706
+ // see this function's own doc).
707
+ Value::Str(s) => s.as_utf8().map(ToString::to_string),
694
708
  }
695
709
  }
696
710
 
@@ -731,7 +745,7 @@ enum DictKeyIdentity {
731
745
  fn dict_key_identity(key: &ObjectKey) -> Option<DictKeyIdentity> {
732
746
  match key {
733
747
  ObjectKey::Str(s) => Some(DictKeyIdentity::Scalar(crate::lcs::ScalarKey::Str(
734
- s.to_string(),
748
+ s.as_bytes().to_vec(),
735
749
  ))),
736
750
  ObjectKey::Other(value) => match value.as_ref() {
737
751
  Value::Tuple(items) => items
@@ -831,20 +845,27 @@ pub(crate) fn is_below_threshold_to_diff_deeper(a: &Object, b: &Object) -> bool
831
845
  matched.shared.len(),
832
846
  )
833
847
  } else {
834
- // Unchanged from before `ObjectKey` existed: every key here is a
835
- // `Str`, so structural and python-equality matching coincide, and
836
- // `as_str` never filters anything out.
848
+ // Every key here is an `ObjectKey::Str`, so structural and
849
+ // python-equality matching coincide. Counted by WTF-8 bytes, not
850
+ // `ObjectKey::as_str` (which is `None` for a lone-surrogate key —
851
+ // `ObjectKey` has no `Hash` impl at all, see its own doc, so this
852
+ // is also the only way to put one in a `HashSet` here), so a
853
+ // surrogate key is counted correctly instead of silently dropped.
854
+ fn key_bytes(key: &ObjectKey) -> &[u8] {
855
+ match key {
856
+ ObjectKey::Str(s) => s.as_bytes(),
857
+ ObjectKey::Other(_) => {
858
+ unreachable!("has_non_str_keys() is false on both sides in this branch")
859
+ }
860
+ }
861
+ }
837
862
  let union_len = a
838
863
  .keys()
839
- .filter_map(ObjectKey::as_str)
840
- .chain(b.keys().filter_map(ObjectKey::as_str))
864
+ .map(key_bytes)
865
+ .chain(b.keys().map(key_bytes))
841
866
  .collect::<HashSet<_>>()
842
867
  .len();
843
- let intersect_len = a
844
- .keys()
845
- .filter_map(ObjectKey::as_str)
846
- .filter(|key| b.contains_key_str(key))
847
- .count();
868
+ let intersect_len = a.keys().filter(|key| b.contains_key(key)).count();
848
869
  (union_len, intersect_len)
849
870
  };
850
871
  #[allow(
@@ -890,13 +911,13 @@ pub(crate) fn count_object_diff_leaves(
890
911
  let mut total = 0;
891
912
 
892
913
  for (key, old_value) in a {
893
- total += match key.as_str().and_then(|s| b.get_str(s)) {
914
+ total += match b.get(key) {
894
915
  None => item_length(old_value),
895
916
  Some(new_value) => count_diff_leaves(old_value, new_value, depth + 1, opts, memo),
896
917
  };
897
918
  }
898
919
  for (key, new_value) in b {
899
- if key.as_str().is_none_or(|s| !a.contains_key_str(s)) {
920
+ if !a.contains_key(key) {
900
921
  total += item_length(new_value);
901
922
  }
902
923
  }
@@ -195,7 +195,10 @@ pub(crate) enum ItemKey {
195
195
  /// fixed representative for *every* `NaN` regardless of its bits, since
196
196
  /// `DeepHash` digests any `NaN` the same way (see that function's doc).
197
197
  Float(u64),
198
- Str(String),
198
+ /// WTF-8 bytes (see `crate::value::Str`) rather than `String`, so a
199
+ /// lone surrogate still has a distinct, correct content key instead of
200
+ /// failing to compile or silently colliding with a different string.
201
+ Str(Vec<u8>),
199
202
  /// A `datetime`, keyed by its instant with a naive value read as UTC —
200
203
  /// `DeepHash._prep_datetime` runs `datetime_normalize` before formatting
201
204
  /// its digest string, so a naive and an aware value at the same moment
@@ -257,7 +260,8 @@ pub(crate) enum ItemKey {
257
260
  /// keyed too — a plain `String` cannot represent a non-`str` dict key
258
261
  /// (`ItemKey` already covers every key kind this crate's dicts allow,
259
262
  /// scalar or a `tuple` of scalars, via the same recursion [`item_key`]
260
- /// runs on a value).
263
+ /// runs on a value) and, via [`ItemKey::Str`]'s own WTF-8 bytes, a
264
+ /// `str` key holding a lone surrogate code point too.
261
265
  Dict(BTreeMap<ItemKey, ItemKey>),
262
266
  }
263
267
 
@@ -667,7 +671,7 @@ fn scalar_content_key(value: &Value) -> ItemKey {
667
671
  Value::Null => ItemKey::Null,
668
672
  Value::Bool(b) => ItemKey::Bool(*b),
669
673
  Value::Number(n) => number_key(n),
670
- Value::Str(s) => ItemKey::Str(s.to_string()),
674
+ Value::Str(s) => ItemKey::Str(s.as_bytes().to_vec()),
671
675
  Value::DateTime(dt) => ItemKey::DateTime(dt.instant()),
672
676
  Value::Date(date) => ItemKey::Date(date.ordinal()),
673
677
  Value::Time(time) => ItemKey::Time(time.hash_seconds_of_day()),
@@ -763,7 +767,7 @@ fn keyed(value: &Value, memo: &IgnoreOrderMemo, want_part: bool) -> (ItemKey, Op
763
767
  match value {
764
768
  Value::Null => (ItemKey::Null, part()),
765
769
  Value::Bool(b) => (ItemKey::Bool(*b), part()),
766
- Value::Str(s) => (ItemKey::Str(s.to_string()), part()),
770
+ Value::Str(s) => (ItemKey::Str(s.as_bytes().to_vec()), part()),
767
771
  Value::DateTime(value) => (ItemKey::DateTime(value.instant()), part()),
768
772
  Value::Date(value) => (ItemKey::Date(value.ordinal()), part()),
769
773
  Value::Time(value) => (ItemKey::Time(value.hash_seconds_of_day()), part()),
@@ -833,7 +837,7 @@ fn keyed(value: &Value, memo: &IgnoreOrderMemo, want_part: bool) -> (ItemKey, Op
833
837
  /// inline match, for the unkeyed `DistKey` case) instead.
834
838
  fn object_key_item_key(key: &crate::value::ObjectKey, memo: &IgnoreOrderMemo) -> ItemKey {
835
839
  match key {
836
- crate::value::ObjectKey::Str(s) => ItemKey::Str(s.to_string()),
840
+ crate::value::ObjectKey::Str(s) => ItemKey::Str(s.as_bytes().to_vec()),
837
841
  crate::value::ObjectKey::Other(value) => item_key(value, memo),
838
842
  }
839
843
  }
@@ -1865,7 +1865,7 @@ fn tuple_and_list_leaf_lengths_follow_python_equality() {
1865
1865
  /// `serde_json` literal cannot express.
1866
1866
  fn cobj_of(key: &str, value: CValue) -> CValue {
1867
1867
  CValue::Object(crate::value::Object::from_pairs(vec![(
1868
- ObjectKey::Str(std::sync::Arc::from(key)),
1868
+ ObjectKey::Str(crate::value::Key::Utf8(std::sync::Arc::from(key))),
1869
1869
  value,
1870
1870
  )]))
1871
1871
  }
@@ -3166,7 +3166,7 @@ fn arb_cvalue() -> impl Strategy<Value = CValue> {
3166
3166
  any::<bool>().prop_map(CValue::Bool),
3167
3167
  any::<i64>().prop_map(|i| CValue::Number(crate::value::Number::from_i64(i))),
3168
3168
  arb_float,
3169
- "[a-z]{0,3}".prop_map(|s| CValue::Str(s.into_boxed_str())),
3169
+ "[a-z]{0,3}".prop_map(|s| CValue::Str(s.into())),
3170
3170
  arb_datetime,
3171
3171
  arb_date,
3172
3172
  ];
@@ -87,7 +87,9 @@ use crate::value::Value;
87
87
  #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
88
88
  pub(crate) enum ScalarKey {
89
89
  Null,
90
- Str(String),
90
+ /// WTF-8 bytes (see `crate::value::Str`), so a lone surrogate still
91
+ /// compares correctly instead of failing to compile or colliding.
92
+ Str(Vec<u8>),
91
93
  Int(i128),
92
94
  /// Bit pattern of a non-integral (or too-large-to-be-exact) float —
93
95
  /// hashed through [`mix_float_bits`]; see this type's hand-written `Hash`.
@@ -252,7 +254,7 @@ fn scalar_key(value: &Value) -> ScalarKey {
252
254
  pub(crate) fn python_scalar_key(value: &Value) -> Option<ScalarKey> {
253
255
  Some(match value {
254
256
  Value::Null => ScalarKey::Null,
255
- Value::Str(s) => ScalarKey::Str(s.to_string()),
257
+ Value::Str(s) => ScalarKey::Str(s.as_bytes().to_vec()),
256
258
  Value::Bool(b) => ScalarKey::Int(i128::from(*b)),
257
259
  Value::Number(n) => {
258
260
  if let Some(i) = n