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.
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/Cargo.lock +3 -3
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/Cargo.toml +1 -1
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/PKG-INFO +14 -7
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/README.md +13 -6
- deepdiff_rs-0.4.0/crates/onix-core/src/datetime.rs +437 -0
- deepdiff_rs-0.4.0/crates/onix-core/src/datetime_tests.rs +334 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/array.rs +38 -13
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/dispatch.rs +30 -6
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/mod.rs +4 -1
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/object.rs +2 -2
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/options.rs +3 -3
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/scalar.rs +60 -1
- deepdiff_rs-0.4.0/crates/onix-core/src/diff/set.rs +77 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/diff/tests.rs +717 -2
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/error.rs +39 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/distance.rs +297 -24
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/fxhash.rs +38 -26
- deepdiff_rs-0.4.0/crates/onix-core/src/ignore_order/hash.rs +716 -0
- deepdiff_rs-0.4.0/crates/onix-core/src/ignore_order/memo.rs +292 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/mod.rs +7 -4
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/pairing.rs +3 -3
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/ignore_order/tests.rs +1285 -9
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lcs.rs +94 -15
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lcs_tests.rs +71 -1
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/lib.rs +2 -0
- deepdiff_rs-0.4.0/crates/onix-core/src/path.rs +928 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/report.rs +280 -29
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/report_tests.rs +227 -0
- deepdiff_rs-0.4.0/crates/onix-core/src/test_support.rs +87 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/value.rs +370 -12
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/src/value_tests.rs +428 -2
- deepdiff_rs-0.4.0/crates/onix-core/tests/golden.rs +424 -0
- deepdiff_rs-0.4.0/crates/onix-py/src/convert.rs +935 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/deepdiff.rs +49 -36
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/errors.rs +10 -4
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/fast_path.rs +4 -8
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/guard.rs +52 -92
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_conversions.py +232 -0
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_datetimes.py +222 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_depth_guard.py +244 -4
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_differential_fuzz.py +1294 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_golden_parity.py +29 -8
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_sets.py +640 -0
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_suite_hygiene.py +39 -0
- deepdiff_rs-0.4.0/crates/onix-py/tests/test_tuples.py +155 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/pyproject.toml +6 -0
- deepdiff_rs-0.3.0/crates/onix-core/src/ignore_order/hash.rs +0 -165
- deepdiff_rs-0.3.0/crates/onix-core/src/ignore_order/memo.rs +0 -105
- deepdiff_rs-0.3.0/crates/onix-core/src/path.rs +0 -241
- deepdiff_rs-0.3.0/crates/onix-core/src/test_support.rs +0 -40
- deepdiff_rs-0.3.0/crates/onix-core/tests/golden.rs +0 -209
- deepdiff_rs-0.3.0/crates/onix-py/src/convert.rs +0 -545
- deepdiff_rs-0.3.0/crates/onix-py/tests/test_conversions.py +0 -144
- deepdiff_rs-0.3.0/crates/onix-py/tests/test_differential_fuzz.py +0 -147
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/Cargo.toml +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/memory_footprint.rs +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/proptest_diff.rs +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/Cargo.toml +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/benchmarks/bench_bindings.py +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/src/lib.rs +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_bindings_memory.py +0 -0
- {deepdiff_rs-0.3.0 → deepdiff_rs-0.4.0}/crates/onix-py/tests/test_signed_zero.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
148
|
+
version = "0.4.0"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"onix-core",
|
|
151
151
|
"pyo3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepdiff-rs
|
|
3
|
-
Version: 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
|
[](https://github.com/ksco92/onix/actions/workflows/check.yml)
|
|
20
|
+
[](https://codecov.io/gh/ksco92/onix)
|
|
20
21
|
[](https://pypi.org/project/deepdiff-rs/)
|
|
22
|
+
[](https://pypi.org/project/deepdiff-rs/)
|
|
21
23
|
[](LICENSE)
|
|
22
24
|
[](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):
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
[](https://github.com/ksco92/onix/actions/workflows/check.yml)
|
|
4
|
+
[](https://codecov.io/gh/ksco92/onix)
|
|
4
5
|
[](https://pypi.org/project/deepdiff-rs/)
|
|
6
|
+
[](https://pypi.org/project/deepdiff-rs/)
|
|
5
7
|
[](LICENSE)
|
|
6
8
|
[](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):
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|