deepdiff-rs 0.7.0__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.
Files changed (73) hide show
  1. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/Cargo.lock +11 -4
  2. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/PKG-INFO +2 -2
  4. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/README.md +1 -1
  5. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/Cargo.toml +1 -0
  6. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/path.rs +125 -25
  7. deepdiff_rs-0.7.1/crates/onix-py/.python-version +1 -0
  8. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_sets.py +77 -0
  9. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/pyproject.toml +3 -5
  10. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/Cargo.toml +0 -0
  11. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/examples/row_diff_rss.rs +0 -0
  12. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
  13. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/error.rs +0 -0
  14. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/lib.rs +0 -0
  15. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/options.rs +0 -0
  16. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/row_diff.rs +0 -0
  17. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/schema.rs +0 -0
  18. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-arrow/src/table_diff.rs +0 -0
  19. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  20. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/datetime.rs +0 -0
  21. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/datetime_tests.rs +0 -0
  22. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/array.rs +0 -0
  23. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/dispatch.rs +0 -0
  24. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/mod.rs +0 -0
  25. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/object.rs +0 -0
  26. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/options.rs +0 -0
  27. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/scalar.rs +0 -0
  28. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/set.rs +0 -0
  29. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/diff/tests.rs +0 -0
  30. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/error.rs +0 -0
  31. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/distance.rs +0 -0
  32. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
  33. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/hash.rs +0 -0
  34. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/memo.rs +0 -0
  35. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/mod.rs +0 -0
  36. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
  37. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/ignore_order/tests.rs +0 -0
  38. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/lcs.rs +0 -0
  39. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/lcs_tests.rs +0 -0
  40. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/lib.rs +0 -0
  41. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/report.rs +0 -0
  42. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/report_tests.rs +0 -0
  43. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/test_support.rs +0 -0
  44. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/unified_diff.rs +0 -0
  45. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/unified_diff_tests.rs +0 -0
  46. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/value.rs +0 -0
  47. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/src/value_tests.rs +0 -0
  48. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/tests/golden.rs +0 -0
  49. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
  50. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/tests/memory_footprint.rs +0 -0
  51. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/tests/proptest_diff.rs +0 -0
  52. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  53. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/Cargo.toml +0 -0
  54. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  55. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/arrow.rs +0 -0
  56. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/convert.rs +0 -0
  57. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/deepdiff.rs +0 -0
  58. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/errors.rs +0 -0
  59. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/fast_path.rs +0 -0
  60. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/guard.rs +0 -0
  61. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/src/lib.rs +0 -0
  62. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  63. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_conversions.py +0 -0
  64. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_datetimes.py +0 -0
  65. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_depth_guard.py +0 -0
  66. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_differential_fuzz.py +0 -0
  67. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_golden_parity.py +0 -0
  68. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_signed_zero.py +0 -0
  69. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_smoke.py +0 -0
  70. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_suite_hygiene.py +0 -0
  71. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_table_diff.py +0 -0
  72. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_table_row_diff.py +0 -0
  73. {deepdiff_rs-0.7.0 → deepdiff_rs-0.7.1}/crates/onix-py/tests/test_tuples.py +0 -0
@@ -629,7 +629,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
629
629
 
630
630
  [[package]]
631
631
  name = "onix-arrow"
632
- version = "0.7.0"
632
+ version = "0.7.1"
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.7.0"
649
+ version = "0.7.1"
650
650
  dependencies = [
651
651
  "onix-core",
652
652
  "serde_json",
@@ -654,17 +654,18 @@ dependencies = [
654
654
 
655
655
  [[package]]
656
656
  name = "onix-core"
657
- version = "0.7.0"
657
+ version = "0.7.1"
658
658
  dependencies = [
659
659
  "proptest",
660
660
  "serde",
661
661
  "serde_json",
662
662
  "stats_alloc",
663
+ "unicode-general-category",
663
664
  ]
664
665
 
665
666
  [[package]]
666
667
  name = "onix-py"
667
- version = "0.7.0"
668
+ version = "0.7.1"
668
669
  dependencies = [
669
670
  "arrow-array",
670
671
  "arrow-ipc",
@@ -1112,6 +1113,12 @@ version = "0.1.4"
1112
1113
  source = "registry+https://github.com/rust-lang/crates.io-index"
1113
1114
  checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
1114
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
+
1115
1122
  [[package]]
1116
1123
  name = "unicode-ident"
1117
1124
  version = "1.0.24"
@@ -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.7.0"
6
+ version = "0.7.1"
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.7.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
@@ -160,7 +160,7 @@ Type comparison uses the full logical Arrow type (timestamp unit and timezone, d
160
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).
161
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.
162
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.
163
- - A `str` inside a `tuple` or `frozenset` set item is rendered with Python's `repr()`, which escapes every non-printable character; onix escapes those below `U+0100` (the complete set in that range) and passes higher non-printable code points through literally, since escaping them would mean carrying a Unicode category table. Exact for all of ASCII and all printable text. See [`crates/onix-core/src/path.rs`](crates/onix-core/src/path.rs).
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.
164
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).
165
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).
166
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).
@@ -142,7 +142,7 @@ Type comparison uses the full logical Arrow type (timestamp unit and timezone, d
142
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).
143
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.
144
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.
145
- - A `str` inside a `tuple` or `frozenset` set item is rendered with Python's `repr()`, which escapes every non-printable character; onix escapes those below `U+0100` (the complete set in that range) and passes higher non-printable code points through literally, since escaping them would mean carrying a Unicode category table. Exact for all of ASCII and all printable text. See [`crates/onix-core/src/path.rs`](crates/onix-core/src/path.rs).
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.
146
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).
147
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).
148
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).
@@ -12,6 +12,7 @@ workspace = true
12
12
  [dependencies]
13
13
  serde = "1"
14
14
  serde_json = "1"
15
+ unicode-general-category = "1.1"
15
16
 
16
17
  [dev-dependencies]
17
18
  proptest = "1"
@@ -10,6 +10,8 @@
10
10
 
11
11
  use std::fmt::Write as _;
12
12
 
13
+ use unicode_general_category::{GeneralCategory, get_general_category};
14
+
13
15
  use crate::datetime::{SECONDS_PER_DAY, div_rem_euclid};
14
16
  use crate::value::{Number, Value};
15
17
 
@@ -209,20 +211,6 @@ pub fn set_item_repr(item: &Value) -> String {
209
211
  /// [`Value`]'s own stack-safety posture: nothing bounds how deep a value a
210
212
  /// caller may render, and a natively recursive renderer would be an
211
213
  /// unguarded overflow sink on adversarially nested input.
212
- ///
213
- /// # Divergence from Python: non-printable non-ASCII characters
214
- ///
215
- /// Python's `repr()` escapes any character it considers non-printable (the
216
- /// Unicode `Cc`/`Cf`/`Cs`/`Co`/`Cn`/`Zl`/`Zp` categories, plus `Zs` other
217
- /// than a plain space) as `\xXX`/`\uXXXX`/`\UXXXXXXXX`. This port escapes
218
- /// exactly those below `U+0100` (every ASCII control, `U+007F`–`U+00A0`,
219
- /// and the soft hyphen `U+00AD` — the complete non-printable set in that
220
- /// range) and passes everything above through literally, which is what
221
- /// Python does for all *printable* text. Reproducing the rest would mean
222
- /// carrying a Unicode category table for characters (zero-width joiners,
223
- /// unassigned code points) that no realistic diff input holds; this is an
224
- /// accepted, narrow, documented limitation, in the same class as
225
- /// `crate::lcs`'s `2^53` numeric bound.
226
214
  #[must_use]
227
215
  pub fn python_repr(value: &Value) -> String {
228
216
  let mut out = String::new();
@@ -387,8 +375,8 @@ fn push_sequence<'a>(
387
375
 
388
376
  /// Python's `repr()` for a `str`: single quotes unless the string contains a
389
377
  /// single quote and no double quote (then double quotes), with `\`, the
390
- /// wrapping quote and the non-printable characters escaped — see
391
- /// [`python_repr`]'s doc for the one documented gap above `U+00FF`.
378
+ /// wrapping quote and every non-printable code point escaped as `\xXX`,
379
+ /// `\uXXXX` or `\UXXXXXXXX` per [`escape_non_printable`].
392
380
  fn python_repr_str(s: &str) -> String {
393
381
  let quote = if s.contains('\'') && !s.contains('"') {
394
382
  '"'
@@ -408,10 +396,7 @@ fn python_repr_str(s: &str) -> String {
408
396
  out.push('\\');
409
397
  out.push(c);
410
398
  }
411
- c if is_escaped_below_u100(c) => {
412
- // Writing into a `String` is infallible.
413
- let _ = write!(out, "\\x{:02x}", u32::from(c));
414
- }
399
+ c if is_non_printable(c) => escape_non_printable(&mut out, c),
415
400
  c => out.push(c),
416
401
  }
417
402
  }
@@ -437,10 +422,43 @@ fn number_repr(n: &Number) -> String {
437
422
  .to_string()
438
423
  }
439
424
 
440
- /// The complete set of code points below `U+0100` Python's `repr()` escapes
441
- /// as `\xXX`: the C0 controls, `DEL` through `NBSP`, and the soft hyphen.
442
- fn is_escaped_below_u100(c: char) -> bool {
443
- c < '\u{20}' || ('\u{7f}'..='\u{a0}').contains(&c) || c == '\u{ad}'
425
+ /// Whether `c` is one of the code points Python's `repr()` escapes: every
426
+ /// character in Unicode general categories `Cc`, `Cf`, `Cs`, `Co`, `Cn`,
427
+ /// `Zl`, `Zp` or `Zs`, except the plain space (`U+0020`), which is `Zs` but
428
+ /// stays printable. This is `CPython`'s own rule
429
+ /// (`Tools/unicode/makeunicodedata.py`'s `PRINTABLE_MASK`, read from the
430
+ /// `unicode-general-category` table pinned to Unicode 16.0.0 — the same
431
+ /// version Python 3.14's `unicodedata` module ships).
432
+ fn is_non_printable(c: char) -> bool {
433
+ if c == ' ' {
434
+ return false;
435
+ }
436
+ matches!(
437
+ get_general_category(c),
438
+ GeneralCategory::Control
439
+ | GeneralCategory::Format
440
+ | GeneralCategory::Surrogate
441
+ | GeneralCategory::PrivateUse
442
+ | GeneralCategory::Unassigned
443
+ | GeneralCategory::LineSeparator
444
+ | GeneralCategory::ParagraphSeparator
445
+ | GeneralCategory::SpaceSeparator
446
+ )
447
+ }
448
+
449
+ /// Appends `c`'s `repr()` escape to `out`: `\xXX` below `U+0100`, `\uXXXX`
450
+ /// up to `U+FFFF`, `\UXXXXXXXX` above — the same three widths
451
+ /// `Objects/unicodeobject.c`'s `unicode_repr` picks by.
452
+ fn escape_non_printable(out: &mut String, c: char) {
453
+ let code_point = u32::from(c);
454
+ // Writing into a `String` is infallible.
455
+ let _ = if code_point < 0x100 {
456
+ write!(out, "\\x{code_point:02x}")
457
+ } else if code_point < 0x1_0000 {
458
+ write!(out, "\\u{code_point:04x}")
459
+ } else {
460
+ write!(out, "\\U{code_point:08x}")
461
+ };
444
462
  }
445
463
 
446
464
  /// Python's `repr()` for a `float`, which is `float_repr_style="short"`: the
@@ -525,7 +543,9 @@ fn python_float_repr(value: f64) -> String {
525
543
 
526
544
  #[cfg(test)]
527
545
  mod tests {
528
- use super::{PathSegment, python_repr, quote_key, render_path, set_item_repr};
546
+ use super::{
547
+ PathSegment, escape_non_printable, python_repr, quote_key, render_path, set_item_repr,
548
+ };
529
549
  use crate::test_support::{cdate, cdt_at};
530
550
  use crate::value::{Builder, Number, SetItems, Value};
531
551
 
@@ -813,6 +833,86 @@ mod tests {
813
833
  }
814
834
  }
815
835
 
836
+ /// Every expectation here is real Python `repr()` output, verified
837
+ /// against `CPython` 3.14 (Unicode 16.0.0).
838
+ #[test]
839
+ fn python_repr_str_escapes_non_printable_code_points_above_u0100() {
840
+ let cases = [
841
+ // U+00FF: printable Latin-1, the code point right after the old
842
+ // below-U+0100 ceiling — left bare, not escaped.
843
+ ("a\u{ff}b", "'a\u{ff}b'"),
844
+ // U+200B: Cf (zero width space) — \uXXXX width.
845
+ ("a\u{200b}b", r"'a\u200bb'"),
846
+ // U+2028: Zl (line separator) — \uXXXX width.
847
+ ("a\u{2028}b", r"'a\u2028b'"),
848
+ // U+E000: Co (private use) — \uXXXX width.
849
+ ("a\u{e000}b", r"'a\ue000b'"),
850
+ // U+0378: Cn (unassigned in Unicode 16.0.0) — \uXXXX width.
851
+ ("a\u{378}b", r"'a\u0378b'"),
852
+ // U+FFFF: Cn (a BMP noncharacter) — the top of the \uXXXX width.
853
+ ("a\u{ffff}b", r"'a\uffffb'"),
854
+ // U+10000: Lo, printable astral text — left bare.
855
+ ("a\u{10000}b", "'a\u{10000}b'"),
856
+ // U+1F600: So, a printable astral emoji — left bare.
857
+ ("a\u{1f600}b", "'a\u{1f600}b'"),
858
+ // U+F0000: Co (a supplementary private-use plane) — \UXXXXXXXX
859
+ // width, the one the old port could not reach at all.
860
+ ("a\u{f0000}b", r"'a\U000f0000b'"),
861
+ // U+10FFFF: Cn, the last valid Unicode scalar value.
862
+ ("a\u{10ffff}b", r"'a\U0010ffffb'"),
863
+ ];
864
+ for (input, expected) in cases {
865
+ let item = Value::Tuple(Box::new([Value::Str(input.into())]));
866
+ assert_eq!(
867
+ python_repr(&item),
868
+ format!("({expected},)"),
869
+ "for {input:?}"
870
+ );
871
+ }
872
+ }
873
+
874
+ /// The plain space (`U+0020`) is `Zs` like every other escaped space
875
+ /// separator, but Python's own rule carves it out as printable — the
876
+ /// one exception `is_non_printable` must apply.
877
+ #[test]
878
+ fn python_repr_str_does_not_escape_plain_space() {
879
+ let item = Value::Tuple(Box::new([Value::Str("a b".into())]));
880
+ assert_eq!(python_repr(&item), "('a b',)");
881
+ }
882
+
883
+ /// Exercises `escape_non_printable` directly, at the two code points
884
+ /// each escape width's `<` comparison must reject vs. accept: `U+00FF`
885
+ /// stays `\xXX`, `U+0100` switches to `\uXXXX`; `U+FFFF` stays `\uXXXX`,
886
+ /// `U+10000` switches to `\UXXXXXXXX`.
887
+ #[test]
888
+ fn escape_non_printable_widths_switch_exactly_at_their_boundaries() {
889
+ let cases = [
890
+ (0xff, "\\xff"),
891
+ (0x100, "\\u0100"),
892
+ (0xffff, "\\uffff"),
893
+ (0x1_0000, "\\U00010000"),
894
+ ];
895
+ for (code_point, expected) in cases {
896
+ let mut out = String::new();
897
+ escape_non_printable(
898
+ &mut out,
899
+ char::from_u32(code_point).expect("valid code point"),
900
+ );
901
+ assert_eq!(out, expected, "for U+{code_point:04X}");
902
+ }
903
+ }
904
+
905
+ /// Fails `make check` if a `unicode-general-category` bump ever changes
906
+ /// the table `is_non_printable` reads, so a Unicode-version drift is
907
+ /// caught here rather than only by the Python bindings' own
908
+ /// interpreter-version-gated differential test (`test_sets.py`'s
909
+ /// `test_bmp_printability_table_matches_the_running_interpreter_on_3_14`),
910
+ /// which needs a Python 3.14 interpreter to run at all.
911
+ #[test]
912
+ fn unicode_general_category_stays_pinned_to_16_0_0() {
913
+ assert_eq!(unicode_general_category::UNICODE_VERSION, (16, 0, 0));
914
+ }
915
+
816
916
  /// Python's `repr()` for a calendar value — the form a container holding
817
917
  /// one shows, and the form issue #21 will need once a calendar value may
818
918
  /// be a set member. Every expectation here is real Python `repr()`
@@ -0,0 +1 @@
1
+ 3.14
@@ -18,6 +18,8 @@ import json
18
18
  import math
19
19
  import random
20
20
  import struct
21
+ import sys
22
+ import unicodedata
21
23
  from typing import Final
22
24
 
23
25
  import pytest
@@ -30,6 +32,26 @@ from deepdiff_rs import DeepDiff, diff_json
30
32
  FLOAT_CASE_COUNT: Final[int] = 1_000_000
31
33
  FLOAT_BATCH_SIZE: Final[int] = 5_000
32
34
 
35
+ # How many BMP code points are rendered per diff call in the str-repr
36
+ # differential below (one call per batch keeps the full sweep fast).
37
+ BMP_BATCH_SIZE: Final[int] = 2_000
38
+
39
+ # Beyond-BMP sample: one code point per category needing the `\UXXXXXXXX`
40
+ # escape width, plus three printable ones left bare.
41
+ SUPPLEMENTARY_SAMPLE: Final[list[int]] = [
42
+ 0x10000, # Lo, printable
43
+ 0x1F600, # So, printable (emoji)
44
+ 0x30000, # Lo, printable
45
+ 0xE01EF, # Mn, printable (combining, astral)
46
+ 0xE0001, # Cf, non-printable
47
+ 0xF0000, # Co, non-printable (private-use plane)
48
+ 0x10FFFD, # Co, non-printable (private-use plane)
49
+ 0x2FFFE, # Cn, non-printable (unassigned noncharacter)
50
+ 0x3FFFD, # Cn, non-printable (unassigned)
51
+ 0x40000, # Cn, non-printable (unassigned)
52
+ 0x10FFFF, # Cn, non-printable (last valid code point)
53
+ ]
54
+
33
55
 
34
56
  def test_set_items_are_reported_as_added_and_removed_paths() -> None:
35
57
  """The two set categories are lists of path strings, sorted by onix."""
@@ -204,6 +226,61 @@ def test_float_set_item_paths_match_python_repr_over_a_million_bit_patterns() ->
204
226
  )
205
227
 
206
228
 
229
+ def _rendered_tuple_str_path(s: str) -> str:
230
+ """
231
+ Render one `(s,)` tuple set item's path.
232
+
233
+ :param s: A single-character string.
234
+ :return: The rendered ``set_item_removed`` entry.
235
+ """
236
+ entries = DeepDiff({(s,)}, {("sentinel",)}).to_dict()["set_item_removed"]
237
+ assert len(entries) == 1, entries
238
+ return entries[0]
239
+
240
+
241
+ def _assert_batch_matches_python_repr(code_points: list[int]) -> None:
242
+ """
243
+ Diff one batch of one-element `(chr(cp),)` tuple set items against real Python `repr()`.
244
+
245
+ :param code_points: The batch to check, distinct code points.
246
+ """
247
+ strings = [chr(cp) for cp in code_points]
248
+ items = {(s,) for s in strings}
249
+ rendered = set(DeepDiff(items, {("sentinel",)}).to_dict()["set_item_removed"])
250
+ expected = {f"root[{(s,)!r}]" for s in strings}
251
+ if rendered == expected:
252
+ return
253
+
254
+ for cp in code_points:
255
+ s = chr(cp)
256
+ want = f"root[{(s,)!r}]"
257
+ actual = _rendered_tuple_str_path(s)
258
+ assert actual == want, f"U+{cp:04X} rendered differently: expected {want!r}, got {actual!r}"
259
+ pytest.fail("batch-level mismatch, but no single code point in it diverged")
260
+
261
+
262
+ def test_bmp_printability_table_matches_the_running_interpreter_on_3_14() -> None:
263
+ """Pins the assumption the sweep below relies on: 3.14 must ship Unicode 16.0.0."""
264
+ if sys.version_info[:2] != (3, 14):
265
+ pytest.skip("only meaningful on the crate's pinned reference interpreter, 3.14")
266
+ assert unicodedata.unidata_version == "16.0.0"
267
+
268
+
269
+ def test_str_inside_tuple_matches_python_repr_over_the_full_bmp() -> None:
270
+ """Every BMP code point escapes exactly like Python `repr()`, against Unicode 16.0.0."""
271
+ if unicodedata.unidata_version != "16.0.0":
272
+ pytest.skip("valid only against Unicode 16.0.0 (Python 3.14); see tests/golden/README.md")
273
+
274
+ code_points = [cp for cp in range(0x10000) if not 0xD800 <= cp <= 0xDFFF]
275
+ for start in range(0, len(code_points), BMP_BATCH_SIZE):
276
+ _assert_batch_matches_python_repr(code_points[start : start + BMP_BATCH_SIZE])
277
+
278
+
279
+ def test_str_inside_tuple_matches_python_repr_beyond_the_bmp() -> None:
280
+ """The `\\UXXXXXXXX` escape width, and printable astral text left bare."""
281
+ _assert_batch_matches_python_repr(SUPPLEMENTARY_SAMPLE)
282
+
283
+
207
284
  def test_a_set_holding_an_unhashable_subclass_is_refused() -> None:
208
285
  """A list subclass that defines __hash__ can be a set member; onix refuses it by path."""
209
286
 
@@ -39,11 +39,9 @@ Repository = "https://github.com/ksco92/onix"
39
39
  #
40
40
  # `deepdiff==9.1.0` itself requires Python >=3.10, tighter than this
41
41
  # package's own `requires-python` (>=3.9) — the marker below keeps `uv
42
- # sync`'s universal (all-supported-versions) resolution satisfiable. There
43
- # is no interpreter pin for the test venv itself (unlike the PEP-723
44
- # scripts under ../../scripts/ and ../../perf/, which do pin an exact
45
- # version): `uv sync` resolves whatever Python >=3.10 it finds/manages, and
46
- # the abi3 wheel this crate builds works identically across all of them.
42
+ # sync`'s universal (all-supported-versions) resolution satisfiable. The
43
+ # test venv itself IS pinned, via this directory's `.python-version`:
44
+ # Pinned to 3.14, Unicode 16.0.0; see tests/golden/README.md, Pinned versions.
47
45
  # polars and duckdb are here (test group) only, never as runtime deps: the
48
46
  # Arrow table-diff tests feed the same data through pyarrow, polars, and
49
47
  # DuckDB and assert identical results, which needs all three installed to run