deepdiff-rs 0.3.0__tar.gz → 0.4.0__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 (65) hide show
  1. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/Cargo.lock +3 -3
  2. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/Cargo.toml +1 -1
  3. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/PKG-INFO +14 -7
  4. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/README.md +13 -6
  5. deepdiff_rs-0.4.0/crates/onix-core/src/datetime.rs +437 -0
  6. deepdiff_rs-0.4.0/crates/onix-core/src/datetime_tests.rs +334 -0
  7. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/array.rs +38 -13
  8. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/dispatch.rs +30 -6
  9. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/mod.rs +4 -1
  10. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/object.rs +2 -2
  11. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/options.rs +3 -3
  12. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/scalar.rs +60 -1
  13. deepdiff_rs-0.4.0/crates/onix-core/src/diff/set.rs +77 -0
  14. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/tests.rs +717 -2
  15. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/error.rs +39 -0
  16. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/distance.rs +297 -24
  17. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/fxhash.rs +38 -26
  18. deepdiff_rs-0.4.0/crates/onix-core/src/ignore_order/hash.rs +716 -0
  19. deepdiff_rs-0.4.0/crates/onix-core/src/ignore_order/memo.rs +292 -0
  20. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/mod.rs +7 -4
  21. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/pairing.rs +3 -3
  22. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/tests.rs +1285 -9
  23. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lcs.rs +94 -15
  24. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lcs_tests.rs +71 -1
  25. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lib.rs +2 -0
  26. deepdiff_rs-0.4.0/crates/onix-core/src/path.rs +928 -0
  27. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/report.rs +280 -29
  28. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/report_tests.rs +227 -0
  29. deepdiff_rs-0.4.0/crates/onix-core/src/test_support.rs +87 -0
  30. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/value.rs +370 -12
  31. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/value_tests.rs +428 -2
  32. deepdiff_rs-0.4.0/crates/onix-core/tests/golden.rs +424 -0
  33. deepdiff_rs-0.4.0/crates/onix-py/src/convert.rs +935 -0
  34. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/deepdiff.rs +49 -36
  35. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/errors.rs +10 -4
  36. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/fast_path.rs +4 -8
  37. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/guard.rs +52 -92
  38. deepdiff_rs-0.4.0/crates/onix-py/tests/test_conversions.py +232 -0
  39. deepdiff_rs-0.4.0/crates/onix-py/tests/test_datetimes.py +222 -0
  40. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_depth_guard.py +244 -4
  41. deepdiff_rs-0.4.0/crates/onix-py/tests/test_differential_fuzz.py +1294 -0
  42. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_golden_parity.py +29 -8
  43. deepdiff_rs-0.4.0/crates/onix-py/tests/test_sets.py +640 -0
  44. deepdiff_rs-0.4.0/crates/onix-py/tests/test_suite_hygiene.py +39 -0
  45. deepdiff_rs-0.4.0/crates/onix-py/tests/test_tuples.py +155 -0
  46. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/pyproject.toml +6 -0
  47. deepdiff_rs-0.3.0/crates/onix-core/src/ignore_order/hash.rs +0 -165
  48. deepdiff_rs-0.3.0/crates/onix-core/src/ignore_order/memo.rs +0 -105
  49. deepdiff_rs-0.3.0/crates/onix-core/src/path.rs +0 -241
  50. deepdiff_rs-0.3.0/crates/onix-core/src/test_support.rs +0 -40
  51. deepdiff_rs-0.3.0/crates/onix-core/tests/golden.rs +0 -209
  52. deepdiff_rs-0.3.0/crates/onix-py/src/convert.rs +0 -545
  53. deepdiff_rs-0.3.0/crates/onix-py/tests/test_conversions.py +0 -144
  54. deepdiff_rs-0.3.0/crates/onix-py/tests/test_differential_fuzz.py +0 -147
  55. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/Cargo.toml +0 -0
  56. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
  57. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
  58. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/proptest_diff.rs +0 -0
  59. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
  60. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/Cargo.toml +0 -0
  61. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
  62. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/lib.rs +0 -0
  63. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
  64. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
  65. {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_smoke.py +0 -0
@@ -127,7 +127,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
127
127
 
128
128
  [[package]]
129
129
  name = "onix-cli"
130
- version = "0.3.0"
130
+ version = "0.4.0"
131
131
  dependencies = [
132
132
  "onix-core",
133
133
  "serde_json",
@@ -135,7 +135,7 @@ dependencies = [
135
135
 
136
136
  [[package]]
137
137
  name = "onix-core"
138
- version = "0.3.0"
138
+ version = "0.4.0"
139
139
  dependencies = [
140
140
  "proptest",
141
141
  "serde",
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "onix-py"
148
- version = "0.3.0"
148
+ version = "0.4.0"
149
149
  dependencies = [
150
150
  "onix-core",
151
151
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/onix-core", "crates/onix-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.0"
6
+ version = "0.4.0"
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.3.0
3
+ Version: 0.4.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -17,7 +17,9 @@ Project-URL: Repository, https://github.com/ksco92/onix
17
17
  # deepdiff-rs
18
18
 
19
19
  [![CI](https://img.shields.io/github/actions/workflow/status/ksco92/onix/check.yml?branch=main&label=CI)](https://github.com/ksco92/onix/actions/workflows/check.yml)
20
+ [![coverage](https://codecov.io/gh/ksco92/onix/branch/main/graph/badge.svg)](https://codecov.io/gh/ksco92/onix)
20
21
  [![PyPI](https://img.shields.io/pypi/v/deepdiff-rs.svg)](https://pypi.org/project/deepdiff-rs/)
22
+ [![downloads](https://img.shields.io/pypi/dm/deepdiff-rs.svg)](https://pypi.org/project/deepdiff-rs/)
21
23
  [![license](https://img.shields.io/github/license/ksco92/onix.svg)](LICENSE)
22
24
  [![last commit](https://img.shields.io/github/last-commit/ksco92/onix.svg)](https://github.com/ksco92/onix/commits/main)
23
25
 
@@ -25,7 +27,7 @@ Project-URL: Repository, https://github.com/ksco92/onix
25
27
 
26
28
  `deepdiff-rs` reads live Python objects (or JSON) and produces the exact same report [DeepDiff](https://github.com/seperman/deepdiff) does at `verbose_level=2`, so it slots into code that already parses DeepDiff output while running dramatically faster on large or deeply nested inputs.
27
29
 
28
- Status (September 2026): pre-alpha proof of concept. Ordered and `ignore_order` diffing are complete, differentially tested against real DeepDiff 9.1.0, and [benchmarked](perf/RESULTS.md); `deepdiff-rs` is published to PyPI, the `onix` CLI builds and runs from source, and nothing is on crates.io yet.
30
+ Status (September 2026): `deepdiff-rs` 0.x is live on PyPI (Python 3.9+, wheels for Linux x86_64/aarch64, macOS arm64/x86_64, and Windows x64, plus an sdist); the `onix` CLI builds from source, and nothing is on crates.io yet. Ordered and `ignore_order` diffing are complete, differentially tested against real DeepDiff 9.1.0, and [benchmarked](perf/RESULTS.md). It is 0.x, not stable or 1.0: the API may still change before 1.0.
29
31
 
30
32
  ## Table of contents
31
33
 
@@ -112,10 +114,15 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
112
114
  ## Known limitations
113
115
 
114
116
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
115
- - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), and `list`; `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else (tuple, set, date, custom object, non-`str` dict key) raises `TypeError` or `ValueError` naming the exact path it was found at. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs).
117
+ - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, and `datetime.date`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests. `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else `time`, `timedelta`, a non-`str` dict key, a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. The **Datetimes** and **Sets** bullets below cover the deliberate divergences for those two types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
118
+ - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
119
+ - **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).
120
+ - **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.
121
+ - Two distinct `str` values that differ only in lone surrogates (`'\ud800'` and `'\udc00'`) convert to the same string, so a set holding both loses a member and with it a finding. Tracked as [issue #27](https://github.com/ksco92/onix/issues/27).
122
+ - 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).
116
123
  - 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).
117
124
  - `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).
118
- - Output is byte-identical to DeepDiff except for one boundary case: integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists. See [`tests/golden/README.md`](tests/golden/README.md).
125
+ - 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.
119
126
 
120
127
  ## Performance
121
128
 
@@ -162,14 +169,14 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
162
169
 
163
170
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `MaxDepthError`, and `MAX_DEPTH_CEILING`.
164
171
 
165
- - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects; `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict, and the instance is falsy when there is no difference.
172
+ - `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)]`).
166
173
  - `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.
167
174
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
168
175
 
169
176
  **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).
170
177
 
171
178
  - `--max-depth N` overrides the recursion-depth bound (default: the `ONIX_MAX_DEPTH` environment variable if set, else 512).
172
- - `--ignore-order` compares every list/tuple by hash-based matching instead of by position, mirroring `DeepDiff(..., ignore_order=True)`.
179
+ - `--ignore-order` compares every list by hash-based matching instead of by position, mirroring `DeepDiff(..., ignore_order=True)`.
173
180
  - `--timing` prints one line of JSON (`{"parse_ns": N, "diff_ns": N}`) to stderr.
174
181
 
175
182
  Exit codes:
@@ -194,7 +201,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
194
201
 
195
202
  ## Contributing
196
203
 
197
- This is a pre-alpha proof of concept and issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff divergence (include both inputs and the report each engine produces), or a question. Building from source, the quality gates, the golden corpus, benchmarking, mutation testing, and publishing are all in [CONTRIBUTING.md](CONTRIBUTING.md).
204
+ Issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff divergence (include both inputs and the report each engine produces), or a question. Building from source, the quality gates, the golden corpus, benchmarking, mutation testing, and publishing are all in [CONTRIBUTING.md](CONTRIBUTING.md).
198
205
 
199
206
  ## License
200
207
 
@@ -1,7 +1,9 @@
1
1
  # deepdiff-rs
2
2
 
3
3
  [![CI](https://img.shields.io/github/actions/workflow/status/ksco92/onix/check.yml?branch=main&label=CI)](https://github.com/ksco92/onix/actions/workflows/check.yml)
4
+ [![coverage](https://codecov.io/gh/ksco92/onix/branch/main/graph/badge.svg)](https://codecov.io/gh/ksco92/onix)
4
5
  [![PyPI](https://img.shields.io/pypi/v/deepdiff-rs.svg)](https://pypi.org/project/deepdiff-rs/)
6
+ [![downloads](https://img.shields.io/pypi/dm/deepdiff-rs.svg)](https://pypi.org/project/deepdiff-rs/)
5
7
  [![license](https://img.shields.io/github/license/ksco92/onix.svg)](LICENSE)
6
8
  [![last commit](https://img.shields.io/github/last-commit/ksco92/onix.svg)](https://github.com/ksco92/onix/commits/main)
7
9
 
@@ -9,7 +11,7 @@
9
11
 
10
12
  `deepdiff-rs` reads live Python objects (or JSON) and produces the exact same report [DeepDiff](https://github.com/seperman/deepdiff) does at `verbose_level=2`, so it slots into code that already parses DeepDiff output while running dramatically faster on large or deeply nested inputs.
11
13
 
12
- Status (September 2026): pre-alpha proof of concept. Ordered and `ignore_order` diffing are complete, differentially tested against real DeepDiff 9.1.0, and [benchmarked](perf/RESULTS.md); `deepdiff-rs` is published to PyPI, the `onix` CLI builds and runs from source, and nothing is on crates.io yet.
14
+ Status (September 2026): `deepdiff-rs` 0.x is live on PyPI (Python 3.9+, wheels for Linux x86_64/aarch64, macOS arm64/x86_64, and Windows x64, plus an sdist); the `onix` CLI builds from source, and nothing is on crates.io yet. Ordered and `ignore_order` diffing are complete, differentially tested against real DeepDiff 9.1.0, and [benchmarked](perf/RESULTS.md). It is 0.x, not stable or 1.0: the API may still change before 1.0.
13
15
 
14
16
  ## Table of contents
15
17
 
@@ -96,10 +98,15 @@ Pass `--ignore-order` to compare every list by value instead of by position, mir
96
98
  ## Known limitations
97
99
 
98
100
  - Only the core diff is implemented: `exclude_paths`, `significant_digits`, custom operators, `verbose_level != 2`, and delta/patch are not (yet) supported.
99
- - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), and `list`; `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else (tuple, set, date, custom object, non-`str` dict key) raises `TypeError` or `ValueError` naming the exact path it was found at. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs).
101
+ - Supported value types are `None`, `bool`, `int`, `float`, `str`, `dict` (with `str` keys), `list`, `tuple`, `set`, `frozenset`, `datetime.datetime`, and `datetime.date`; a `set`/`frozenset` member may be any of these except a `list`, `dict` or `set`, matching Python's own hashability rule, transitively through whatever the member nests. `int`s must fit in `i64`/`u64`, `float`s must be finite, and anything else `time`, `timedelta`, a non-`str` dict key, a custom object, an arbitrary-precision `int`, or a non-finite `float` — raises `TypeError`/`ValueError` naming the exact path it was found at. The **Datetimes** and **Sets** bullets below cover the deliberate divergences for those two types. See [`crates/onix-py/src/convert.rs`](crates/onix-py/src/convert.rs) and [`tests/golden/README.md`](tests/golden/README.md).
102
+ - A subclass of a supported type (a `tuple`, `set` or `frozenset` subclass including `namedtuple`, a `datetime`/`date` subclass such as pandas' `Timestamp`) raises `TypeError` rather than being diffed as its base type, because DeepDiff reports each value's own type name. A `type_changes` entry's `old_type`/`new_type` are type *names* in `to_dict()`, where DeepDiff returns the type objects. Both are described in [`tests/golden/README.md`](tests/golden/README.md).
103
+ - **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).
104
+ - **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.
105
+ - Two distinct `str` values that differ only in lone surrogates (`'\ud800'` and `'\udc00'`) convert to the same string, so a set holding both loses a member and with it a finding. Tracked as [issue #27](https://github.com/ksco92/onix/issues/27).
106
+ - 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).
100
107
  - 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).
101
108
  - `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).
102
- - Output is byte-identical to DeepDiff except for one boundary case: integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists. See [`tests/golden/README.md`](tests/golden/README.md).
109
+ - 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.
103
110
 
104
111
  ## Performance
105
112
 
@@ -146,14 +153,14 @@ Both reports carry their full methodology, fairness rules, and the reproduce com
146
153
 
147
154
  **Python API.** The public surface is `DeepDiff`, `diff_json`, `MaxDepthError`, and `MAX_DEPTH_CEILING`.
148
155
 
149
- - `DeepDiff(t1, t2, ignore_order=False, max_depth=None)`: diffs two live Python objects; `.to_json()` returns the DeepDiff-compatible JSON string, `.to_dict()` the same report as a dict, and the instance is falsy when there is no difference.
156
+ - `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)]`).
150
157
  - `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.
151
158
  - `MaxDepthError` (a `ValueError` subclass) is raised when input exceeds `max_depth`; `MAX_DEPTH_CEILING` (20,000) is the hard upper bound on `max_depth`.
152
159
 
153
160
  **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).
154
161
 
155
162
  - `--max-depth N` overrides the recursion-depth bound (default: the `ONIX_MAX_DEPTH` environment variable if set, else 512).
156
- - `--ignore-order` compares every list/tuple by hash-based matching instead of by position, mirroring `DeepDiff(..., ignore_order=True)`.
163
+ - `--ignore-order` compares every list by hash-based matching instead of by position, mirroring `DeepDiff(..., ignore_order=True)`.
157
164
  - `--timing` prints one line of JSON (`{"parse_ns": N, "diff_ns": N}`) to stderr.
158
165
 
159
166
  Exit codes:
@@ -178,7 +185,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
178
185
 
179
186
  ## Contributing
180
187
 
181
- This is a pre-alpha proof of concept and issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff divergence (include both inputs and the report each engine produces), or a question. Building from source, the quality gates, the golden corpus, benchmarking, mutation testing, and publishing are all in [CONTRIBUTING.md](CONTRIBUTING.md).
188
+ Issues and pull requests are welcome. Open an issue to report a bug, a DeepDiff divergence (include both inputs and the report each engine produces), or a question. Building from source, the quality gates, the golden corpus, benchmarking, mutation testing, and publishing are all in [CONTRIBUTING.md](CONTRIBUTING.md).
182
189
 
183
190
  ## License
184
191
 
@@ -0,0 +1,437 @@
1
+ //! The two calendar values [`crate::Value`] carries beyond JSON's own
2
+ //! shapes: [`Date`] (a Python `datetime.date`) and [`DateTime`] (a Python
3
+ //! `datetime.datetime`, naive or with a fixed UTC offset).
4
+ //!
5
+ //! # Representation
6
+ //!
7
+ //! Both hold plain wall-clock fields — a [`Date`] is a year/month/day, a
8
+ //! [`DateTime`] adds hour/minute/second/microsecond plus an optional UTC
9
+ //! offset in whole seconds (`None` is Python's *naive* datetime). Nothing
10
+ //! here needs a calendar crate: the only arithmetic the engine performs is
11
+ //! civil-date to day-number conversion in both directions
12
+ //! ([`Date::ordinal`] and [`Date::from_ordinal`], Howard Hinnant's
13
+ //! `days_from_civil`/`civil_from_days`), which is a few lines of integer
14
+ //! math and no lookup tables.
15
+ //!
16
+ //! # Comparison: by instant, naive as UTC
17
+ //!
18
+ //! `DeepDiff` compares two datetimes by *instant*, after normalizing each
19
+ //! through `helper.py::datetime_normalize`: an aware value is converted with
20
+ //! `astimezone(timezone.utc)`, a naive one is *stamped* with UTC
21
+ //! (`replace(tzinfo=utc)`) rather than interpreted in local time. So
22
+ //! `datetime(2024, 1, 1, 10)` and `datetime(2024, 1, 1, 10, tzinfo=utc)` are
23
+ //! one instant, and `10:00+00:00` equals `12:00+02:00`. [`DateTime::instant`]
24
+ //! is that rule as one integer, and [`DateTime::to_utc`] is
25
+ //! `datetime_normalize` itself.
26
+ //!
27
+ //! Two [`Date`]s compare by value, and a [`Date`] never equals a
28
+ //! [`DateTime`] — matching Python, where `date(2024, 1, 1) ==
29
+ //! datetime(2024, 1, 1)` is `False` in both directions
30
+ //! (`datetime.__eq__` returns `False`, not `NotImplemented`, for a plain
31
+ //! `date`, and its subclass position gives it first refusal).
32
+ //!
33
+ //! # Rendering
34
+ //!
35
+ //! [`Date::isoformat`] and [`DateTime::isoformat`] reproduce Python's own
36
+ //! `isoformat()` byte for byte: microseconds only when non-zero, an offset
37
+ //! suffix only when the value is aware, and that suffix widening from
38
+ //! `+HH:MM` to `+HH:MM:SS` when the offset is not a whole number of minutes.
39
+ //! `DeepDiff`'s `to_json()` renders a datetime through exactly this method
40
+ //! (`serialization.py`'s `JSON_CONVERTOR` maps `datetime.datetime` to
41
+ //! `lambda x: x.isoformat()`); it has no entry for `date` at all and raises
42
+ //! `TypeError` on one, which this crate renders as `YYYY-MM-DD` instead — a
43
+ //! documented superset, see `tests/golden/README.md`.
44
+
45
+ use std::fmt::Write as _;
46
+
47
+ /// Microseconds in one second.
48
+ const MICROS_PER_SECOND: i64 = 1_000_000;
49
+ /// Seconds in one day.
50
+ pub(crate) const SECONDS_PER_DAY: i64 = 86_400;
51
+ /// Days from `0001-01-01` to the Unix epoch — the shift between Python's
52
+ /// `date.toordinal()` origin and this module's civil-date arithmetic.
53
+ const DAYS_FROM_YEAR_ONE_TO_EPOCH: i64 = 719_162;
54
+ /// The first year Python's `date`/`datetime` can represent.
55
+ const MIN_YEAR: i32 = 1;
56
+ /// The last year Python's `date`/`datetime` can represent.
57
+ const MAX_YEAR: i32 = 9999;
58
+ /// `Date::new(MIN_YEAR, 1, 1).ordinal()`, i.e. Python's `date.min.toordinal()`.
59
+ const MIN_ORDINAL: i64 = 1;
60
+ /// `Date::new(MAX_YEAR, 12, 31).ordinal()`, i.e. Python's `date.max.toordinal()`.
61
+ const MAX_ORDINAL: i64 = 3_652_059;
62
+
63
+ /// A Python `datetime.date`: a proleptic-Gregorian year, month and day.
64
+ ///
65
+ /// # Examples
66
+ ///
67
+ /// ```
68
+ /// use onix_core::datetime::Date;
69
+ ///
70
+ /// let date = Date::new(2024, 2, 29).expect("2024 is a leap year");
71
+ /// assert_eq!(date.isoformat(), "2024-02-29");
72
+ /// assert!(Date::new(2023, 2, 29).is_none());
73
+ /// ```
74
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
75
+ pub struct Date {
76
+ year: i32,
77
+ month: u8,
78
+ day: u8,
79
+ }
80
+
81
+ impl Date {
82
+ /// Builds a date, returning `None` unless `year`/`month`/`day` are a real
83
+ /// calendar date (leap years included) inside Python's own year range,
84
+ /// `1..=9999`. An out-of-range month has no days at all
85
+ /// (`days_in_month` returns `0` for one), so the `day` bound rejects it
86
+ /// without a separate month check.
87
+ ///
88
+ /// Enforcing the year range here is what lets every other method on this
89
+ /// type be total: the ordinal arithmetic stays far inside [`i64`], and
90
+ /// the field widths in [`Date::from_ordinal`] are guaranteed.
91
+ #[must_use]
92
+ pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
93
+ ((MIN_YEAR..=MAX_YEAR).contains(&year) && day >= 1 && day <= days_in_month(year, month))
94
+ .then_some(Self { year, month, day })
95
+ }
96
+
97
+ /// The year.
98
+ #[must_use]
99
+ pub fn year(self) -> i32 {
100
+ self.year
101
+ }
102
+
103
+ /// The month, `1..=12`.
104
+ #[must_use]
105
+ pub fn month(self) -> u8 {
106
+ self.month
107
+ }
108
+
109
+ /// The day of the month, `1..=31`.
110
+ #[must_use]
111
+ pub fn day(self) -> u8 {
112
+ self.day
113
+ }
114
+
115
+ /// Days since `0001-01-01`, counting that day as `1` — Python's
116
+ /// `date.toordinal()`, which is what `DeepDiff` measures a date-pair
117
+ /// distance with (`distance.py::_get_date_distance`).
118
+ #[must_use]
119
+ pub fn ordinal(self) -> i64 {
120
+ days_from_civil(self.year, self.month, self.day) + DAYS_FROM_YEAR_ONE_TO_EPOCH + 1
121
+ }
122
+
123
+ /// The inverse of [`Date::ordinal`], or `None` for an ordinal outside
124
+ /// the representable range (`1..=3_652_059`, Python's
125
+ /// `date.min`/`date.max`).
126
+ #[must_use]
127
+ pub fn from_ordinal(ordinal: i64) -> Option<Self> {
128
+ if !(MIN_ORDINAL..=MAX_ORDINAL).contains(&ordinal) {
129
+ return None;
130
+ }
131
+ let (year, month, day) = civil_from_days(ordinal - DAYS_FROM_YEAR_ONE_TO_EPOCH - 1);
132
+
133
+ Some(Self { year, month, day })
134
+ }
135
+
136
+ /// Python's `date.isoformat()`: `YYYY-MM-DD`.
137
+ #[must_use]
138
+ pub fn isoformat(self) -> String {
139
+ format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
140
+ }
141
+
142
+ /// Python's `str(date)`, which for a date is exactly its
143
+ /// [`isoformat`](Date::isoformat) — the two differ only for a datetime.
144
+ /// See [`DateTime::python_str`] for why `str()` is worth a method of its
145
+ /// own at all.
146
+ #[must_use]
147
+ pub fn python_str(self) -> String {
148
+ self.isoformat()
149
+ }
150
+ }
151
+
152
+ /// A Python `datetime.datetime`: a [`Date`] plus a wall-clock time to
153
+ /// microsecond precision, and an optional fixed UTC offset in whole seconds
154
+ /// (`None` is a *naive* datetime).
155
+ ///
156
+ /// See the [module documentation](self) for the instant-comparison rule and
157
+ /// the exact `isoformat()` reproduction.
158
+ ///
159
+ /// # Examples
160
+ ///
161
+ /// ```
162
+ /// use onix_core::datetime::{Date, DateTime};
163
+ ///
164
+ /// let date = Date::new(2024, 1, 1).expect("a real date");
165
+ /// let naive = DateTime::new(date, 10, 0, 0, 0, None).expect("in range");
166
+ /// let aware = DateTime::new(date, 12, 0, 0, 0, Some(2 * 3600)).expect("in range");
167
+ ///
168
+ /// assert_eq!(naive.isoformat(), "2024-01-01T10:00:00");
169
+ /// assert_eq!(aware.isoformat(), "2024-01-01T12:00:00+02:00");
170
+ /// // Naive counts as UTC, so these are the same instant.
171
+ /// assert_eq!(naive.instant(), aware.instant());
172
+ /// ```
173
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
174
+ pub struct DateTime {
175
+ date: Date,
176
+ hour: u8,
177
+ minute: u8,
178
+ second: u8,
179
+ microsecond: u32,
180
+ utc_offset_seconds: Option<i32>,
181
+ }
182
+
183
+ /// The exclusive bound Python puts on a `timezone` offset: strictly less
184
+ /// than one day, in either direction.
185
+ const SECONDS_PER_DAY_U32: u32 = 86_400;
186
+
187
+ impl DateTime {
188
+ /// Builds a datetime, returning `None` if the time fields are out of
189
+ /// range (`hour <= 23`, `minute`/`second <= 59`, `microsecond <=
190
+ /// 999_999`) or the offset is not strictly within ±1 day — the same
191
+ /// bounds Python's own `datetime`/`timezone` constructors enforce.
192
+ #[must_use]
193
+ pub fn new(
194
+ date: Date,
195
+ hour: u8,
196
+ minute: u8,
197
+ second: u8,
198
+ microsecond: u32,
199
+ utc_offset_seconds: Option<i32>,
200
+ ) -> Option<Self> {
201
+ let in_range = hour <= 23
202
+ && minute <= 59
203
+ && second <= 59
204
+ && microsecond <= 999_999
205
+ && utc_offset_seconds.is_none_or(|offset| offset.unsigned_abs() < SECONDS_PER_DAY_U32);
206
+
207
+ in_range.then_some(Self {
208
+ date,
209
+ hour,
210
+ minute,
211
+ second,
212
+ microsecond,
213
+ utc_offset_seconds,
214
+ })
215
+ }
216
+
217
+ /// The calendar date part.
218
+ #[must_use]
219
+ pub fn date(self) -> Date {
220
+ self.date
221
+ }
222
+
223
+ /// The hour, `0..=23`.
224
+ #[must_use]
225
+ pub fn hour(self) -> u8 {
226
+ self.hour
227
+ }
228
+
229
+ /// The minute, `0..=59`.
230
+ #[must_use]
231
+ pub fn minute(self) -> u8 {
232
+ self.minute
233
+ }
234
+
235
+ /// The second, `0..=59`.
236
+ #[must_use]
237
+ pub fn second(self) -> u8 {
238
+ self.second
239
+ }
240
+
241
+ /// The microsecond, `0..=999_999`.
242
+ #[must_use]
243
+ pub fn microsecond(self) -> u32 {
244
+ self.microsecond
245
+ }
246
+
247
+ /// The fixed UTC offset in whole seconds, or `None` for a naive value.
248
+ #[must_use]
249
+ pub fn utc_offset_seconds(self) -> Option<i32> {
250
+ self.utc_offset_seconds
251
+ }
252
+
253
+ /// This value's instant, as microseconds from `1970-01-01T00:00:00Z`,
254
+ /// with a naive value counted as UTC — `DeepDiff`'s comparison key (see
255
+ /// the [module documentation](self)).
256
+ ///
257
+ /// Exact across the whole Python-representable range: year `9999`'s
258
+ /// microsecond count is under `2.6e17`, three orders of magnitude inside
259
+ /// [`i64`].
260
+ #[must_use]
261
+ pub fn instant(self) -> i64 {
262
+ let seconds_of_day =
263
+ i64::from(self.hour) * 3600 + i64::from(self.minute) * 60 + i64::from(self.second)
264
+ - i64::from(self.utc_offset_seconds.unwrap_or(0));
265
+
266
+ ((self.date.ordinal() - 1) * SECONDS_PER_DAY + seconds_of_day) * MICROS_PER_SECOND
267
+ + i64::from(self.microsecond)
268
+ - DAYS_FROM_YEAR_ONE_TO_EPOCH * SECONDS_PER_DAY * MICROS_PER_SECOND
269
+ }
270
+
271
+ /// This value normalized to UTC — `helper.py::datetime_normalize` with
272
+ /// the default `default_timezone=timezone.utc`, i.e. the values
273
+ /// `DeepDiff` puts in a `values_changed` entry for a datetime pair.
274
+ ///
275
+ /// The result is always aware with offset `0`, so two normalized values
276
+ /// are equal exactly when the originals are the same instant.
277
+ ///
278
+ /// Returns `None` for the one case that has no answer: an extreme aware
279
+ /// value whose UTC wall clock falls outside Python's own `1..=9999` year
280
+ /// range, by at most one day (`9999-12-31T23:00-01:00`, say). Real
281
+ /// `astimezone(timezone.utc)` raises `OverflowError: date value out of
282
+ /// range` there, and so `DeepDiff` raises rather than reporting anything.
283
+ ///
284
+ /// *When* each tool reaches that point differs, verified live. On the
285
+ /// ordered path only `_diff_datetime` normalizes, so both raise only when
286
+ /// two datetimes are actually compared. Under `ignore_order`,
287
+ /// `deephash.py::_prep_datetime` normalizes every datetime it hashes, so
288
+ /// real `DeepDiff` raises for such a value even when it is merely added,
289
+ /// removed, or shuffled, where onix hashes by instant (see
290
+ /// `crate::ignore_order`) and reports it raw.
291
+ #[must_use]
292
+ pub fn to_utc(self) -> Option<Self> {
293
+ let instant =
294
+ self.instant() + DAYS_FROM_YEAR_ONE_TO_EPOCH * SECONDS_PER_DAY * MICROS_PER_SECOND;
295
+ let (days, micros_of_day) = div_rem_euclid(instant, SECONDS_PER_DAY * MICROS_PER_SECOND);
296
+ let seconds_of_day = micros_of_day / MICROS_PER_SECOND;
297
+
298
+ #[allow(
299
+ clippy::cast_possible_truncation,
300
+ clippy::cast_sign_loss,
301
+ reason = "`div_rem_euclid` makes `micros_of_day` non-negative and strictly under one \
302
+ day, so every component below is non-negative and inside its own field"
303
+ )]
304
+ Some(Self {
305
+ date: Date::from_ordinal(days + 1)?,
306
+ hour: (seconds_of_day / 3600) as u8,
307
+ minute: (seconds_of_day / 60 % 60) as u8,
308
+ second: (seconds_of_day % 60) as u8,
309
+ microsecond: (micros_of_day % MICROS_PER_SECOND) as u32,
310
+ utc_offset_seconds: Some(0),
311
+ })
312
+ }
313
+
314
+ /// Python's `datetime.isoformat()`: `YYYY-MM-DDTHH:MM:SS`, plus
315
+ /// `.ffffff` when the microsecond is non-zero and an offset suffix when
316
+ /// the value is aware — see the [module documentation](self).
317
+ #[must_use]
318
+ pub fn isoformat(self) -> String {
319
+ self.rendered('T')
320
+ }
321
+
322
+ /// Python's `str(datetime)`, which is `isoformat(sep=" ")` — the same
323
+ /// rendering with a space where the `T` goes.
324
+ ///
325
+ /// This is the one place the `str()`-versus-`isoformat()` distinction is
326
+ /// explained, for both calendar types. They are kept apart because they
327
+ /// have different jobs: `isoformat()` is what `to_json()` prints, while
328
+ /// `str()` is what `DeepDiff` reproduces when it tests whether a
329
+ /// `type_changes` pair's new value is reachable by coercion
330
+ /// (`model.py`'s `new_t1 = new_type(change.t1)`), and what `DeepHash`
331
+ /// embeds in a `frozenset` member's digest.
332
+ #[must_use]
333
+ pub fn python_str(self) -> String {
334
+ self.rendered(' ')
335
+ }
336
+
337
+ /// The shared rendering behind [`isoformat`](DateTime::isoformat) and
338
+ /// [`python_str`](DateTime::python_str), which differ only in the
339
+ /// separator between the date and the time.
340
+ fn rendered(self, separator: char) -> String {
341
+ let mut rendered = format!(
342
+ "{}{separator}{:02}:{:02}:{:02}",
343
+ self.date.isoformat(),
344
+ self.hour,
345
+ self.minute,
346
+ self.second
347
+ );
348
+
349
+ if self.microsecond != 0 {
350
+ let _ = write!(rendered, ".{:06}", self.microsecond);
351
+ }
352
+
353
+ if let Some(offset) = self.utc_offset_seconds {
354
+ let sign = if offset < 0 { '-' } else { '+' };
355
+ let magnitude = i64::from(offset.abs());
356
+ let _ = write!(
357
+ rendered,
358
+ "{sign}{:02}:{:02}",
359
+ magnitude / 3600,
360
+ magnitude / 60 % 60
361
+ );
362
+ if magnitude % 60 != 0 {
363
+ let _ = write!(rendered, ":{:02}", magnitude % 60);
364
+ }
365
+ }
366
+
367
+ rendered
368
+ }
369
+ }
370
+
371
+ /// Floored division and its remainder, both taken toward negative infinity —
372
+ /// the split [`DateTime::to_utc`] needs to turn a possibly-negative
373
+ /// microsecond count into a whole day plus a non-negative offset into it.
374
+ pub(crate) fn div_rem_euclid(value: i64, divisor: i64) -> (i64, i64) {
375
+ (value.div_euclid(divisor), value.rem_euclid(divisor))
376
+ }
377
+
378
+ /// Days from `1970-01-01` to `year-month-day`, negative before the epoch —
379
+ /// Howard Hinnant's `days_from_civil`, valid for any proleptic-Gregorian
380
+ /// date.
381
+ fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
382
+ let year = i64::from(year) - i64::from(month <= 2);
383
+ let era = year.div_euclid(400);
384
+ let year_of_era = year - era * 400;
385
+ let month = i64::from(month);
386
+ let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
387
+ let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
388
+
389
+ era * 146_097 + day_of_era - 719_468
390
+ }
391
+
392
+ /// The inverse of [`days_from_civil`] — Hinnant's `civil_from_days`.
393
+ #[allow(
394
+ clippy::cast_possible_truncation,
395
+ clippy::cast_sign_loss,
396
+ reason = "the day count this crate reaches spans years 1..=9999, so the year fits an i32 \
397
+ and the month and day are always positive and inside a u8"
398
+ )]
399
+ fn civil_from_days(days: i64) -> (i32, u8, u8) {
400
+ let days = days + 719_468;
401
+ let era = days.div_euclid(146_097);
402
+ let day_of_era = days - era * 146_097;
403
+ let year_of_era =
404
+ (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
405
+ let year = year_of_era + era * 400;
406
+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
407
+ let month_position = (5 * day_of_year + 2) / 153;
408
+ let day = day_of_year - (153 * month_position + 2) / 5 + 1;
409
+ let month = month_position + if month_position < 10 { 3 } else { -9 };
410
+
411
+ (
412
+ (year + i64::from(month <= 2)) as i32,
413
+ month as u8,
414
+ day as u8,
415
+ )
416
+ }
417
+
418
+ /// The number of days in `month` of `year`, or `0` if `month` is not a real
419
+ /// month — which is what makes it [`Date::new`]'s only bound.
420
+ fn days_in_month(year: i32, month: u8) -> u8 {
421
+ match month {
422
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
423
+ 4 | 6 | 9 | 11 => 30,
424
+ 2 if is_leap_year(year) => 29,
425
+ 2 => 28,
426
+ _ => 0,
427
+ }
428
+ }
429
+
430
+ /// Whether `year` is a proleptic-Gregorian leap year.
431
+ fn is_leap_year(year: i32) -> bool {
432
+ year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
433
+ }
434
+
435
+ #[cfg(test)]
436
+ #[path = "datetime_tests.rs"]
437
+ mod tests;