jsonl-diff 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/PKG-INFO +23 -7
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/README.md +20 -6
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.egg-info/PKG-INFO +23 -7
- jsonl_diff-0.1.3/jsonl_diff.egg-info/requires.txt +7 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.py +224 -85
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/pyproject.toml +7 -1
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/tests/test_diff.py +35 -1
- jsonl_diff-0.1.2/jsonl_diff.egg-info/requires.txt +0 -2
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/LICENSE +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.egg-info/SOURCES.txt +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.egg-info/dependency_links.txt +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.egg-info/entry_points.txt +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/jsonl_diff.egg-info/top_level.txt +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/setup.cfg +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/tests/test_cli.py +0 -0
- {jsonl_diff-0.1.2 → jsonl_diff-0.1.3}/tests/test_sources.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jsonl-diff
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets without loading them entirely into memory.
|
|
5
5
|
Author-email: rmoralespp <rmoralespp@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -51,6 +51,8 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
License-File: LICENSE
|
|
52
52
|
Requires-Dist: py-jsonl==1.5.2
|
|
53
53
|
Requires-Dist: jmespath>=1.0.0
|
|
54
|
+
Provides-Extra: speedups
|
|
55
|
+
Requires-Dist: msgspec>=0.18; python_version >= "3.10" and extra == "speedups"
|
|
54
56
|
Dynamic: license-file
|
|
55
57
|
|
|
56
58
|
# jsonl-diff
|
|
@@ -63,12 +65,11 @@ Dynamic: license-file
|
|
|
63
65
|
<img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
|
|
64
66
|
</p>
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
A lightweight, pure-Python tool for comparing large [JSONL](https://jsonlines.org/)/[NDJSON](https://github.com/ndjson/ndjson-spec) datasets **by record identity instead of line
|
|
67
69
|
position**, without loading the complete inputs into memory.
|
|
68
70
|
|
|
69
71
|
`jsonl-diff` matches records using one or more top-level fields and reports:
|
|
70
|
-
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each
|
|
71
|
-
source.
|
|
72
|
+
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
|
|
72
73
|
|
|
73
74
|
It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
74
75
|
|
|
@@ -80,6 +81,19 @@ It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
|
80
81
|
pip install jsonl-diff
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
### Optional speedups
|
|
85
|
+
|
|
86
|
+
For large datasets, install the optional [`msgspec`](https://jcristharif.com/msgspec/)
|
|
87
|
+
accelerator (Python 3.10+), which uses a C parser and encoder for JSON decoding
|
|
88
|
+
and record canonicalization, speeding up large diffs by roughly **2x**:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install "jsonl-diff[speedups]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
It is used automatically when available — no configuration or code changes. When
|
|
95
|
+
`msgspec` is absent, the pure-Python path runs instead and produces identical results.
|
|
96
|
+
|
|
83
97
|
## Quick start
|
|
84
98
|
|
|
85
99
|
Given:
|
|
@@ -119,11 +133,12 @@ Record order does not matter.
|
|
|
119
133
|
|
|
120
134
|
* Single or composite top-level identities.
|
|
121
135
|
* Disk-backed comparison for large datasets ([architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md)).
|
|
122
|
-
* Optional **JMESPath** filtering with `--where`.
|
|
136
|
+
* Optional **[JMESPath](https://jmespath.org/)** filtering with `--where`.
|
|
123
137
|
* Configurable duplicate handling with counts and diagnostics: `error`, `first`, or `last`.
|
|
124
|
-
* Exact **RFC 6901** JSON Pointer ignores.
|
|
138
|
+
* Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
|
|
125
139
|
* Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
|
|
126
140
|
* Semantic number comparison using `Decimal`.
|
|
141
|
+
* Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
|
|
127
142
|
* Deterministic summaries and change iteration.
|
|
128
143
|
* Original OLD/NEW physical line numbers.
|
|
129
144
|
* Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
|
|
@@ -238,7 +253,8 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
|
|
|
238
253
|
`jsonl-diff` is intentionally strict:
|
|
239
254
|
|
|
240
255
|
* Every input line must contain exactly one valid top-level JSON object.
|
|
241
|
-
* Blank lines, malformed JSON,
|
|
256
|
+
* Blank lines, malformed JSON, `NaN`, and infinities are rejected.
|
|
257
|
+
* Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
|
|
242
258
|
* Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
|
|
243
259
|
* Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
|
|
244
260
|
* Duplicate identities fail by default; `--duplicates first`/`last` select one
|
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
<img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
A lightweight, pure-Python tool for comparing large [JSONL](https://jsonlines.org/)/[NDJSON](https://github.com/ndjson/ndjson-spec) datasets **by record identity instead of line
|
|
12
12
|
position**, without loading the complete inputs into memory.
|
|
13
13
|
|
|
14
14
|
`jsonl-diff` matches records using one or more top-level fields and reports:
|
|
15
|
-
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each
|
|
16
|
-
source.
|
|
15
|
+
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
|
|
17
16
|
|
|
18
17
|
It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
19
18
|
|
|
@@ -25,6 +24,19 @@ It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
|
25
24
|
pip install jsonl-diff
|
|
26
25
|
```
|
|
27
26
|
|
|
27
|
+
### Optional speedups
|
|
28
|
+
|
|
29
|
+
For large datasets, install the optional [`msgspec`](https://jcristharif.com/msgspec/)
|
|
30
|
+
accelerator (Python 3.10+), which uses a C parser and encoder for JSON decoding
|
|
31
|
+
and record canonicalization, speeding up large diffs by roughly **2x**:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install "jsonl-diff[speedups]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It is used automatically when available — no configuration or code changes. When
|
|
38
|
+
`msgspec` is absent, the pure-Python path runs instead and produces identical results.
|
|
39
|
+
|
|
28
40
|
## Quick start
|
|
29
41
|
|
|
30
42
|
Given:
|
|
@@ -64,11 +76,12 @@ Record order does not matter.
|
|
|
64
76
|
|
|
65
77
|
* Single or composite top-level identities.
|
|
66
78
|
* Disk-backed comparison for large datasets ([architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md)).
|
|
67
|
-
* Optional **JMESPath** filtering with `--where`.
|
|
79
|
+
* Optional **[JMESPath](https://jmespath.org/)** filtering with `--where`.
|
|
68
80
|
* Configurable duplicate handling with counts and diagnostics: `error`, `first`, or `last`.
|
|
69
|
-
* Exact **RFC 6901** JSON Pointer ignores.
|
|
81
|
+
* Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
|
|
70
82
|
* Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
|
|
71
83
|
* Semantic number comparison using `Decimal`.
|
|
84
|
+
* Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
|
|
72
85
|
* Deterministic summaries and change iteration.
|
|
73
86
|
* Original OLD/NEW physical line numbers.
|
|
74
87
|
* Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
|
|
@@ -183,7 +196,8 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
|
|
|
183
196
|
`jsonl-diff` is intentionally strict:
|
|
184
197
|
|
|
185
198
|
* Every input line must contain exactly one valid top-level JSON object.
|
|
186
|
-
* Blank lines, malformed JSON,
|
|
199
|
+
* Blank lines, malformed JSON, `NaN`, and infinities are rejected.
|
|
200
|
+
* Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
|
|
187
201
|
* Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
|
|
188
202
|
* Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
|
|
189
203
|
* Duplicate identities fail by default; `--duplicates first`/`last` select one
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jsonl-diff
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets without loading them entirely into memory.
|
|
5
5
|
Author-email: rmoralespp <rmoralespp@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -51,6 +51,8 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
License-File: LICENSE
|
|
52
52
|
Requires-Dist: py-jsonl==1.5.2
|
|
53
53
|
Requires-Dist: jmespath>=1.0.0
|
|
54
|
+
Provides-Extra: speedups
|
|
55
|
+
Requires-Dist: msgspec>=0.18; python_version >= "3.10" and extra == "speedups"
|
|
54
56
|
Dynamic: license-file
|
|
55
57
|
|
|
56
58
|
# jsonl-diff
|
|
@@ -63,12 +65,11 @@ Dynamic: license-file
|
|
|
63
65
|
<img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
|
|
64
66
|
</p>
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
A lightweight, pure-Python tool for comparing large [JSONL](https://jsonlines.org/)/[NDJSON](https://github.com/ndjson/ndjson-spec) datasets **by record identity instead of line
|
|
67
69
|
position**, without loading the complete inputs into memory.
|
|
68
70
|
|
|
69
71
|
`jsonl-diff` matches records using one or more top-level fields and reports:
|
|
70
|
-
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each
|
|
71
|
-
source.
|
|
72
|
+
`equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
|
|
72
73
|
|
|
73
74
|
It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
74
75
|
|
|
@@ -80,6 +81,19 @@ It is useful for snapshots, ETL validation, migrations, exports, and CI checks.
|
|
|
80
81
|
pip install jsonl-diff
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
### Optional speedups
|
|
85
|
+
|
|
86
|
+
For large datasets, install the optional [`msgspec`](https://jcristharif.com/msgspec/)
|
|
87
|
+
accelerator (Python 3.10+), which uses a C parser and encoder for JSON decoding
|
|
88
|
+
and record canonicalization, speeding up large diffs by roughly **2x**:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install "jsonl-diff[speedups]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
It is used automatically when available — no configuration or code changes. When
|
|
95
|
+
`msgspec` is absent, the pure-Python path runs instead and produces identical results.
|
|
96
|
+
|
|
83
97
|
## Quick start
|
|
84
98
|
|
|
85
99
|
Given:
|
|
@@ -119,11 +133,12 @@ Record order does not matter.
|
|
|
119
133
|
|
|
120
134
|
* Single or composite top-level identities.
|
|
121
135
|
* Disk-backed comparison for large datasets ([architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md)).
|
|
122
|
-
* Optional **JMESPath** filtering with `--where`.
|
|
136
|
+
* Optional **[JMESPath](https://jmespath.org/)** filtering with `--where`.
|
|
123
137
|
* Configurable duplicate handling with counts and diagnostics: `error`, `first`, or `last`.
|
|
124
|
-
* Exact **RFC 6901** JSON Pointer ignores.
|
|
138
|
+
* Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
|
|
125
139
|
* Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
|
|
126
140
|
* Semantic number comparison using `Decimal`.
|
|
141
|
+
* Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
|
|
127
142
|
* Deterministic summaries and change iteration.
|
|
128
143
|
* Original OLD/NEW physical line numbers.
|
|
129
144
|
* Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
|
|
@@ -238,7 +253,8 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
|
|
|
238
253
|
`jsonl-diff` is intentionally strict:
|
|
239
254
|
|
|
240
255
|
* Every input line must contain exactly one valid top-level JSON object.
|
|
241
|
-
* Blank lines, malformed JSON,
|
|
256
|
+
* Blank lines, malformed JSON, `NaN`, and infinities are rejected.
|
|
257
|
+
* Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
|
|
242
258
|
* Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
|
|
243
259
|
* Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
|
|
244
260
|
* Duplicate identities fail by default; `--duplicates first`/`last` select one
|
|
@@ -12,21 +12,48 @@ import warnings
|
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
from decimal import Decimal
|
|
14
14
|
from enum import Enum
|
|
15
|
+
from json.encoder import encode_basestring as _encode_basestring
|
|
16
|
+
from json.encoder import encode_basestring_ascii as _encode_basestring_ascii
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, Tuple, Union
|
|
17
19
|
|
|
18
20
|
import jmespath
|
|
19
21
|
import jsonl
|
|
20
22
|
|
|
23
|
+
try: # Optional C-accelerated content canonicalization (Python 3.10+).
|
|
24
|
+
import msgspec as _msgspec
|
|
25
|
+
except ImportError:
|
|
26
|
+
_msgspec = None
|
|
27
|
+
|
|
21
28
|
JsonScalar = Union[str, Decimal, bool, None]
|
|
22
29
|
Number = Union[Decimal, int, float]
|
|
23
30
|
IdentityKey = Tuple[JsonScalar, ...]
|
|
24
31
|
|
|
32
|
+
|
|
33
|
+
class _Num(Decimal):
|
|
34
|
+
"""Decimal subclass parsed numbers use so msgspec routes them through enc_hook."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ()
|
|
37
|
+
|
|
38
|
+
|
|
25
39
|
_MISSING = object()
|
|
26
40
|
|
|
41
|
+
_DIGITS = ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")
|
|
42
|
+
|
|
43
|
+
# Integral digit limit for the msgspec content encoder only. Because msgspec's
|
|
44
|
+
# parser emits integer literals as native `int` and its encoder always writes
|
|
45
|
+
# them out in full, `_Num` float integrals must use the fast plain-integer form
|
|
46
|
+
# over the same range to stay byte-consistent. The bound stays within CPython's
|
|
47
|
+
# default integer-to-string limit so huge float exponents (e.g. 1e50000000) still
|
|
48
|
+
# fall back to scientific form and never materialise as gigantic integers. The
|
|
49
|
+
# pure-Python fallback keeps the lower default limit in `_digest_number`.
|
|
50
|
+
_DIGEST_INTEGRAL_LIMIT = 4300
|
|
51
|
+
|
|
27
52
|
# Avoid per-record filesystem scans: SQLite already enforces the main size limit.
|
|
28
53
|
# Check periodically and once after each side finishes to catch extra temp/journal growth.
|
|
29
54
|
_SIZE_CHECK_INTERVAL = 1024
|
|
55
|
+
# Records are inserted in batches to amortize per-statement overhead.
|
|
56
|
+
_INSERT_BATCH = 2048
|
|
30
57
|
_SCHEMA_TYPE_INDEXES = {
|
|
31
58
|
"boolean": 3,
|
|
32
59
|
"integer": 4,
|
|
@@ -286,22 +313,12 @@ def _reject_constant(value: str) -> None:
|
|
|
286
313
|
raise ValueError("invalid JSON number {!r}".format(value))
|
|
287
314
|
|
|
288
315
|
|
|
289
|
-
def _object(pairs: Iterable[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
290
|
-
result = {}
|
|
291
|
-
for name, value in pairs:
|
|
292
|
-
if name in result:
|
|
293
|
-
raise ValueError("duplicate property {!r}".format(name))
|
|
294
|
-
result[name] = value
|
|
295
|
-
return result
|
|
296
|
-
|
|
297
|
-
|
|
298
316
|
def _decode_json(value: str) -> Any:
|
|
299
317
|
return json.loads(
|
|
300
318
|
value,
|
|
301
319
|
parse_int=Decimal,
|
|
302
320
|
parse_float=Decimal,
|
|
303
321
|
parse_constant=_reject_constant,
|
|
304
|
-
object_pairs_hook=_object,
|
|
305
322
|
)
|
|
306
323
|
|
|
307
324
|
|
|
@@ -318,17 +335,22 @@ def _number(value: Number) -> str:
|
|
|
318
335
|
return "0"
|
|
319
336
|
|
|
320
337
|
sign, digits, exponent = value.as_tuple()
|
|
338
|
+
count = len(digits)
|
|
321
339
|
trailing_zeros = 0
|
|
322
|
-
while
|
|
340
|
+
while count - trailing_zeros > 1 and digits[count - trailing_zeros - 1] == 0:
|
|
323
341
|
trailing_zeros += 1
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
342
|
+
if trailing_zeros:
|
|
343
|
+
digits = digits[:count - trailing_zeros]
|
|
344
|
+
exponent += trailing_zeros
|
|
345
|
+
count -= trailing_zeros
|
|
346
|
+
|
|
347
|
+
if count == 1:
|
|
348
|
+
mantissa = _DIGITS[digits[0]]
|
|
349
|
+
else:
|
|
350
|
+
text = "".join([_DIGITS[digit] for digit in digits])
|
|
351
|
+
mantissa = text[0] + "." + text[1:]
|
|
352
|
+
|
|
353
|
+
scientific_exponent = exponent + count - 1
|
|
332
354
|
return "{}{}e{:+d}".format("-" if sign else "", mantissa, scientific_exponent)
|
|
333
355
|
|
|
334
356
|
|
|
@@ -340,11 +362,50 @@ def _details_number(value: Number) -> str:
|
|
|
340
362
|
return str(value).lower()
|
|
341
363
|
|
|
342
364
|
|
|
365
|
+
def _digest_number(value: Number, integral_limit: int = 18) -> str:
|
|
366
|
+
# Formats numbers for the content fingerprint only (hashed, never decoded or
|
|
367
|
+
# displayed), so it just needs value-consistent bytes. Integrals with fewer
|
|
368
|
+
# than `integral_limit` digits use the fast plain-integer form; classifying by
|
|
369
|
+
# value keeps `1`, `1.0` and `1e0` identical. Fractions and larger integrals
|
|
370
|
+
# fall back to the exact scientific form and are never materialised in full.
|
|
371
|
+
# The default limit matches the pure-Python fallback; the msgspec encoder
|
|
372
|
+
# raises it (see `_digest_raw`) so `_Num` integrals agree with the full-digit
|
|
373
|
+
# bytes msgspec writes for native ints.
|
|
374
|
+
if (
|
|
375
|
+
isinstance(value, Decimal)
|
|
376
|
+
and value.adjusted() < integral_limit
|
|
377
|
+
and value == value.to_integral_value()
|
|
378
|
+
):
|
|
379
|
+
return str(int(value))
|
|
380
|
+
return _number(value)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _require_str_key(name: Any) -> bool:
|
|
384
|
+
if isinstance(name, str):
|
|
385
|
+
return True
|
|
386
|
+
raise ValueError("JSON object property names must be strings")
|
|
387
|
+
|
|
388
|
+
|
|
343
389
|
def _json_text(
|
|
344
390
|
value: Any,
|
|
345
|
-
|
|
391
|
+
escape: Callable[[str], str],
|
|
346
392
|
number: Callable[[Number], str],
|
|
347
393
|
) -> str:
|
|
394
|
+
# Checks are ordered by frequency for object-heavy payloads. String escaping
|
|
395
|
+
# is delegated to CPython's C-accelerated encoder (`escape`) rather than a
|
|
396
|
+
# per-value `json.dumps`.
|
|
397
|
+
kind = type(value)
|
|
398
|
+
if kind is str:
|
|
399
|
+
return escape(value)
|
|
400
|
+
if kind is dict:
|
|
401
|
+
members = [
|
|
402
|
+
escape(name) + ":" + _json_text(value[name], escape, number)
|
|
403
|
+
for name in sorted(value)
|
|
404
|
+
if type(name) is str or _require_str_key(name)
|
|
405
|
+
]
|
|
406
|
+
return "{" + ",".join(members) + "}"
|
|
407
|
+
if kind is list or kind is tuple:
|
|
408
|
+
return "[" + ",".join([_json_text(item, escape, number) for item in value]) + "]"
|
|
348
409
|
if value is None:
|
|
349
410
|
return "null"
|
|
350
411
|
if value is True:
|
|
@@ -354,35 +415,56 @@ def _json_text(
|
|
|
354
415
|
if isinstance(value, (Decimal, int, float)):
|
|
355
416
|
return number(value)
|
|
356
417
|
if isinstance(value, str):
|
|
357
|
-
return
|
|
418
|
+
return escape(value)
|
|
358
419
|
if isinstance(value, (list, tuple)):
|
|
359
|
-
return "[
|
|
420
|
+
return "[" + ",".join([_json_text(item, escape, number) for item in value]) + "]"
|
|
360
421
|
if isinstance(value, dict):
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
members = (
|
|
364
|
-
"{}:{}".format(
|
|
365
|
-
json.dumps(name, ensure_ascii=ensure_ascii),
|
|
366
|
-
_json_text(value[name], ensure_ascii, number),
|
|
367
|
-
)
|
|
422
|
+
members = [
|
|
423
|
+
escape(name) + ":" + _json_text(value[name], escape, number)
|
|
368
424
|
for name in sorted(value)
|
|
369
|
-
|
|
370
|
-
|
|
425
|
+
if type(name) is str or _require_str_key(name)
|
|
426
|
+
]
|
|
427
|
+
return "{" + ",".join(members) + "}"
|
|
371
428
|
raise ValueError("unsupported JSON value type: {}".format(type(value).__name__))
|
|
372
429
|
|
|
373
430
|
|
|
374
431
|
def _canonical_text(value: Any, ensure_ascii: bool = False) -> str:
|
|
375
|
-
|
|
432
|
+
escape = _encode_basestring_ascii if ensure_ascii else _encode_basestring
|
|
433
|
+
return _json_text(value, escape, _number)
|
|
376
434
|
|
|
377
435
|
|
|
378
436
|
def _details_text(value: Any, ensure_ascii: bool = False) -> str:
|
|
379
|
-
|
|
437
|
+
escape = _encode_basestring_ascii if ensure_ascii else _encode_basestring
|
|
438
|
+
return _json_text(value, escape, _details_number)
|
|
380
439
|
|
|
381
440
|
|
|
382
441
|
def _canonical(value: Any) -> bytes:
|
|
383
442
|
return _canonical_text(value).encode("utf-8")
|
|
384
443
|
|
|
385
444
|
|
|
445
|
+
def _content_canonical(value: Any) -> bytes:
|
|
446
|
+
# Bytes hashed for the content fingerprint. Uses the fast digest-only number
|
|
447
|
+
# formatter; identity encoding keeps `_canonical` so key notation round-trips.
|
|
448
|
+
return _json_text(value, _encode_basestring, _digest_number).encode("utf-8")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if _msgspec is not None:
|
|
452
|
+
def _digest_raw(value: Number) -> Any:
|
|
453
|
+
# enc_hook only fires for `_Num`, msgspec's parser writes integer literals
|
|
454
|
+
# as native ints and encodes them in full, so `_Num` integrals use the
|
|
455
|
+
# raised digit limit to produce the same bytes. Tokens are emitted bare
|
|
456
|
+
# (no quotes) so they never collide with real strings.
|
|
457
|
+
return _msgspec.Raw(_digest_number(value, _DIGEST_INTEGRAL_LIMIT).encode("utf-8"))
|
|
458
|
+
|
|
459
|
+
_CONTENT_ENCODER = _msgspec.json.Encoder(order="sorted", enc_hook=_digest_raw)
|
|
460
|
+
|
|
461
|
+
def _content_canonical(value: Any) -> bytes:
|
|
462
|
+
# C-accelerated equivalent of the pure-Python serializer above. Every
|
|
463
|
+
# number is a _Num subclass, so enc_hook fires and yields identical
|
|
464
|
+
# value-consistent bytes; the digest is hashed only, never decoded.
|
|
465
|
+
return _CONTENT_ENCODER.encode(value)
|
|
466
|
+
|
|
467
|
+
|
|
386
468
|
def _fingerprint(canonical: bytes) -> Tuple[int, bytes]:
|
|
387
469
|
return len(canonical), hashlib.sha256(canonical).digest()
|
|
388
470
|
|
|
@@ -506,7 +588,7 @@ def _identity(record: Dict[str, Any], keys: Sequence[str]) -> IdentityKey:
|
|
|
506
588
|
# record into one indistinguishable identity.
|
|
507
589
|
if not composite:
|
|
508
590
|
raise ValueError("identity field {!r} must be a non-null scalar".format(name))
|
|
509
|
-
elif not isinstance(value, (str, Decimal, bool)):
|
|
591
|
+
elif not isinstance(value, (str, Decimal, bool, int)):
|
|
510
592
|
raise ValueError("identity field {!r} must be a non-null scalar".format(name))
|
|
511
593
|
values.append(value)
|
|
512
594
|
return tuple(values)
|
|
@@ -521,15 +603,27 @@ def _compile_where(expression: str) -> Any:
|
|
|
521
603
|
) from error
|
|
522
604
|
|
|
523
605
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
606
|
+
if _msgspec is not None:
|
|
607
|
+
_INPUT_DECODER = _msgspec.json.Decoder(float_hook=_Num)
|
|
608
|
+
|
|
609
|
+
def _records(source: Any, on_error: Any) -> Iterator[Any]:
|
|
610
|
+
# msgspec decodes input lines ~2.6x faster than the stdlib parser and
|
|
611
|
+
# rejects NaN/Infinity natively. Floats become `_Num` (a Decimal subclass)
|
|
612
|
+
# while integers stay native `int`; both are handled by the number
|
|
613
|
+
# canonicalizers. Duplicate object keys follow JSON's last-wins semantics,
|
|
614
|
+
# matching the pure-Python fallback below.
|
|
615
|
+
yield from jsonl.load(source, cls=_INPUT_DECODER.decode, _on_error=on_error)
|
|
616
|
+
else:
|
|
617
|
+
def _records(source: Any, on_error: Any) -> Iterator[Any]:
|
|
618
|
+
# No object_pairs_hook is installed, so duplicate object keys follow the
|
|
619
|
+
# stdlib parser's last-wins semantics, matching the msgspec path above.
|
|
620
|
+
yield from jsonl.load(
|
|
621
|
+
source,
|
|
622
|
+
parse_int=Decimal,
|
|
623
|
+
parse_float=Decimal,
|
|
624
|
+
parse_constant=_reject_constant,
|
|
625
|
+
_on_error=on_error,
|
|
626
|
+
)
|
|
533
627
|
|
|
534
628
|
|
|
535
629
|
class DiffResult:
|
|
@@ -561,11 +655,17 @@ class DiffResult:
|
|
|
561
655
|
|
|
562
656
|
def _configure_database(self) -> None:
|
|
563
657
|
self._connection.execute("PRAGMA journal_mode = OFF")
|
|
658
|
+
self._connection.execute("PRAGMA synchronous = OFF")
|
|
564
659
|
self._connection.execute("PRAGMA temp_store = FILE")
|
|
565
660
|
if self.config.max_temp is not None:
|
|
566
661
|
pages = max(1, self.config.max_temp // 4096)
|
|
567
662
|
self._connection.execute("PRAGMA page_size = 4096")
|
|
568
663
|
self._connection.execute("PRAGMA max_page_count = {}".format(pages))
|
|
664
|
+
else:
|
|
665
|
+
# A larger page cache speeds bulk index writes and the summary read.
|
|
666
|
+
# Skipped under max_temp so on-disk growth stays observable and the
|
|
667
|
+
# workspace memory footprint remains bounded.
|
|
668
|
+
self._connection.execute("PRAGMA cache_size = -65536")
|
|
569
669
|
|
|
570
670
|
def _create_schema(self) -> None:
|
|
571
671
|
self._connection.execute(
|
|
@@ -927,6 +1027,7 @@ class DiffResult:
|
|
|
927
1027
|
cursor = self._connection.cursor()
|
|
928
1028
|
schema_fields = {}
|
|
929
1029
|
schema_objects = {}
|
|
1030
|
+
pending = []
|
|
930
1031
|
for line, record in enumerate(records, start=1):
|
|
931
1032
|
if not isinstance(record, dict):
|
|
932
1033
|
raise InputError("each record must be a JSON object", source, line)
|
|
@@ -939,6 +1040,8 @@ class DiffResult:
|
|
|
939
1040
|
if (
|
|
940
1041
|
self.config.schema_diff or self.config.max_temp is not None
|
|
941
1042
|
) and line % _SIZE_CHECK_INTERVAL == 0:
|
|
1043
|
+
if pending:
|
|
1044
|
+
self._flush_record_batch(cursor, pending, source)
|
|
942
1045
|
if self.config.schema_diff:
|
|
943
1046
|
self._flush_schema_profile(
|
|
944
1047
|
cursor,
|
|
@@ -952,7 +1055,7 @@ class DiffResult:
|
|
|
952
1055
|
try:
|
|
953
1056
|
key = _identity(record, self.config.key)
|
|
954
1057
|
normalized = _remove_ignored(record, self._ignore_tree)
|
|
955
|
-
canonical =
|
|
1058
|
+
canonical = _content_canonical(normalized)
|
|
956
1059
|
identity = _canonical(list(key))
|
|
957
1060
|
length, digest = _fingerprint(canonical)
|
|
958
1061
|
except (TypeError, ValueError) as error:
|
|
@@ -969,16 +1072,9 @@ class DiffResult:
|
|
|
969
1072
|
raise InputError(str(error), source, line) from error
|
|
970
1073
|
values = (side, identity, line, length, digest)
|
|
971
1074
|
if self.config.duplicates == DuplicatePolicy.ERROR:
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
first = cursor.execute(
|
|
976
|
-
"SELECT line FROM records WHERE side = ? AND identity = ?",
|
|
977
|
-
(side, identity),
|
|
978
|
-
).fetchone()[0]
|
|
979
|
-
raise DuplicateKeyError(key, source, (first, line)) from error
|
|
980
|
-
except sqlite3.DatabaseError as error:
|
|
981
|
-
raise ResourceError("could not write the temporary index") from error
|
|
1075
|
+
pending.append(values)
|
|
1076
|
+
if len(pending) >= _INSERT_BATCH:
|
|
1077
|
+
self._flush_record_batch(cursor, pending, source)
|
|
982
1078
|
else:
|
|
983
1079
|
try:
|
|
984
1080
|
self._insert_tolerated_record(cursor, values)
|
|
@@ -987,14 +1083,52 @@ class DiffResult:
|
|
|
987
1083
|
if (
|
|
988
1084
|
self.config.schema_diff or self.config.max_temp is not None
|
|
989
1085
|
) and line % _SIZE_CHECK_INTERVAL == 0:
|
|
1086
|
+
if pending:
|
|
1087
|
+
self._flush_record_batch(cursor, pending, source)
|
|
990
1088
|
if self.config.schema_diff:
|
|
991
1089
|
self._flush_schema_profile(cursor, side, schema_fields, schema_objects)
|
|
992
1090
|
if self.config.max_temp is not None:
|
|
993
1091
|
self._check_size()
|
|
1092
|
+
if pending:
|
|
1093
|
+
self._flush_record_batch(cursor, pending, source)
|
|
994
1094
|
if self.config.schema_diff:
|
|
995
1095
|
self._flush_schema_profile(cursor, side, schema_fields, schema_objects)
|
|
996
1096
|
self._check_size()
|
|
997
1097
|
|
|
1098
|
+
@staticmethod
|
|
1099
|
+
def _flush_record_batch(
|
|
1100
|
+
cursor: sqlite3.Cursor,
|
|
1101
|
+
pending: list,
|
|
1102
|
+
source: str,
|
|
1103
|
+
) -> None:
|
|
1104
|
+
try:
|
|
1105
|
+
cursor.executemany("INSERT INTO records VALUES (?, ?, ?, ?, ?)", pending)
|
|
1106
|
+
except sqlite3.IntegrityError:
|
|
1107
|
+
# A duplicate identity exists within this batch or against a
|
|
1108
|
+
# previously inserted row. Undo this batch's partial inserts (each
|
|
1109
|
+
# row has a unique physical line, so this can never remove an earlier
|
|
1110
|
+
# row) and replay row by row to report the exact colliding identity
|
|
1111
|
+
# and its first physical line, matching the per-record behavior.
|
|
1112
|
+
cursor.executemany(
|
|
1113
|
+
"DELETE FROM records WHERE side = ? AND identity = ? AND line = ?",
|
|
1114
|
+
[(row[0], row[1], row[2]) for row in pending],
|
|
1115
|
+
)
|
|
1116
|
+
for row in pending:
|
|
1117
|
+
try:
|
|
1118
|
+
cursor.execute("INSERT INTO records VALUES (?, ?, ?, ?, ?)", row)
|
|
1119
|
+
except sqlite3.IntegrityError as error:
|
|
1120
|
+
first = cursor.execute(
|
|
1121
|
+
"SELECT line FROM records WHERE side = ? AND identity = ?",
|
|
1122
|
+
(row[0], row[1]),
|
|
1123
|
+
).fetchone()[0]
|
|
1124
|
+
key = tuple(_decode_json(row[1].decode("utf-8")))
|
|
1125
|
+
raise DuplicateKeyError(key, source, (first, row[2])) from error
|
|
1126
|
+
pending.clear()
|
|
1127
|
+
except sqlite3.DatabaseError as error:
|
|
1128
|
+
raise ResourceError("could not write the temporary index") from error
|
|
1129
|
+
else:
|
|
1130
|
+
pending.clear()
|
|
1131
|
+
|
|
998
1132
|
@staticmethod
|
|
999
1133
|
def _flush_schema_profile(
|
|
1000
1134
|
cursor: sqlite3.Cursor,
|
|
@@ -1066,43 +1200,48 @@ class DiffResult:
|
|
|
1066
1200
|
)
|
|
1067
1201
|
|
|
1068
1202
|
def _calculate_summary(self) -> Summary:
|
|
1203
|
+
# Derive the four record totals from a single identity join (matched
|
|
1204
|
+
# pairs, plus how many of them are content-equal) and two per-side range
|
|
1205
|
+
# counts, instead of running the join twice and two anti-joins. Every
|
|
1206
|
+
# access uses the (side, identity) primary key, and the arithmetic below
|
|
1207
|
+
# recovers added/deleted/modified without extra scans.
|
|
1069
1208
|
row = self._connection.execute(
|
|
1070
1209
|
"""
|
|
1071
1210
|
SELECT
|
|
1072
|
-
(SELECT COUNT(*)
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
(SELECT COUNT(*)
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
)),
|
|
1089
|
-
(SELECT COUNT(*)
|
|
1090
|
-
FROM records AS o JOIN records AS n USING (identity)
|
|
1091
|
-
WHERE o.side = 0 AND n.side = 1
|
|
1092
|
-
AND (
|
|
1093
|
-
o.length != n.length
|
|
1094
|
-
OR o.digest != n.digest
|
|
1095
|
-
)),
|
|
1096
|
-
(SELECT COUNT(*)
|
|
1097
|
-
FROM duplicate_records
|
|
1098
|
-
WHERE side = 0),
|
|
1099
|
-
(SELECT COUNT(*)
|
|
1100
|
-
FROM duplicate_records
|
|
1101
|
-
WHERE side = 1)
|
|
1211
|
+
(SELECT COUNT(*) FROM records WHERE side = 0),
|
|
1212
|
+
(SELECT COUNT(*) FROM records WHERE side = 1),
|
|
1213
|
+
matches.matched,
|
|
1214
|
+
matches.equal,
|
|
1215
|
+
(SELECT COUNT(*) FROM duplicate_records WHERE side = 0),
|
|
1216
|
+
(SELECT COUNT(*) FROM duplicate_records WHERE side = 1)
|
|
1217
|
+
FROM (
|
|
1218
|
+
SELECT
|
|
1219
|
+
COUNT(*) AS matched,
|
|
1220
|
+
COALESCE(
|
|
1221
|
+
SUM(o.length = n.length AND o.digest = n.digest), 0
|
|
1222
|
+
) AS equal
|
|
1223
|
+
FROM records AS o
|
|
1224
|
+
JOIN records AS n
|
|
1225
|
+
ON o.side = 0 AND n.side = 1 AND o.identity = n.identity
|
|
1226
|
+
) AS matches
|
|
1102
1227
|
""",
|
|
1103
1228
|
).fetchone()
|
|
1229
|
+
total_old, total_new, matched, equal, old_dups, new_dups = (
|
|
1230
|
+
int(value or 0) for value in row
|
|
1231
|
+
)
|
|
1232
|
+
added = total_new - matched
|
|
1233
|
+
deleted = total_old - matched
|
|
1234
|
+
modified = matched - equal
|
|
1104
1235
|
schema = self._calculate_schema_summary() if self.config.schema_diff else None
|
|
1105
|
-
return Summary(
|
|
1236
|
+
return Summary(
|
|
1237
|
+
equal,
|
|
1238
|
+
added,
|
|
1239
|
+
deleted,
|
|
1240
|
+
modified,
|
|
1241
|
+
old_dups,
|
|
1242
|
+
new_dups,
|
|
1243
|
+
schema=schema,
|
|
1244
|
+
)
|
|
1106
1245
|
|
|
1107
1246
|
def _calculate_schema_summary(self) -> SchemaSummary:
|
|
1108
1247
|
counts = dict.fromkeys(SchemaChangeOperation, 0)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jsonl-diff"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets without loading them entirely into memory."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -33,6 +33,11 @@ dependencies = [
|
|
|
33
33
|
"jmespath>=1.0.0",
|
|
34
34
|
]
|
|
35
35
|
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
speedups = [
|
|
38
|
+
"msgspec>=0.18; python_version >= '3.10'",
|
|
39
|
+
]
|
|
40
|
+
|
|
36
41
|
[project.scripts]
|
|
37
42
|
jsonl-diff = "jsonl_diff:main"
|
|
38
43
|
|
|
@@ -48,6 +53,7 @@ test = [
|
|
|
48
53
|
"coverage",
|
|
49
54
|
"pytest-cov",
|
|
50
55
|
"setuptools; python_version >= '3.12'",
|
|
56
|
+
"msgspec>=0.18; python_version >= '3.10'",
|
|
51
57
|
]
|
|
52
58
|
lint = [
|
|
53
59
|
"ruff",
|
|
@@ -480,7 +480,6 @@ class TestInputValidation:
|
|
|
480
480
|
'{"id":NaN}',
|
|
481
481
|
'{"id":Infinity}',
|
|
482
482
|
'{"id":-Infinity}',
|
|
483
|
-
'{"id":1,"id":2}',
|
|
484
483
|
],
|
|
485
484
|
)
|
|
486
485
|
def test_invalid_json_is_rejected(self, write_jsonl, record):
|
|
@@ -493,6 +492,19 @@ class TestInputValidation:
|
|
|
493
492
|
with diff(old, new, key="id"):
|
|
494
493
|
pass
|
|
495
494
|
|
|
495
|
+
def test_duplicate_object_keys_follow_last_wins(self, write_jsonl):
|
|
496
|
+
# Arrange: a repeated property keeps the last occurrence, so both records
|
|
497
|
+
# collapse to {"id": 2} and compare equal.
|
|
498
|
+
old = write_jsonl("old.jsonl", ['{"id":1,"id":2}'])
|
|
499
|
+
new = write_jsonl("new.jsonl", ['{"id":2}'])
|
|
500
|
+
|
|
501
|
+
# Act
|
|
502
|
+
with diff(old, new, key="id") as result:
|
|
503
|
+
summary = result.summary
|
|
504
|
+
|
|
505
|
+
# Assert
|
|
506
|
+
assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
|
|
507
|
+
|
|
496
508
|
@pytest.mark.parametrize("record", ["[]", '"record"', "1", "true", "null"])
|
|
497
509
|
def test_non_object_record_is_rejected(self, write_jsonl, record):
|
|
498
510
|
# Arrange
|
|
@@ -568,6 +580,28 @@ class TestInputValidation:
|
|
|
568
580
|
assert captured.value.lines == (1, 3)
|
|
569
581
|
assert captured.value.source == "OLD"
|
|
570
582
|
|
|
583
|
+
def test_duplicate_identity_is_detected_across_insert_batches(self, write_jsonl):
|
|
584
|
+
# A duplicate whose two occurrences are separated by more than one
|
|
585
|
+
# insert batch must still report the exact first and duplicate physical
|
|
586
|
+
# lines, exercising the batched-insert cleanup-and-replay path.
|
|
587
|
+
import jsonl_diff
|
|
588
|
+
|
|
589
|
+
span = jsonl_diff._INSERT_BATCH + 500
|
|
590
|
+
records = [{"id": index + 100_000} for index in range(span)]
|
|
591
|
+
records[-1] = {"id": 100_000}
|
|
592
|
+
old = write_jsonl("old.jsonl", records)
|
|
593
|
+
new = write_jsonl("new.jsonl", [])
|
|
594
|
+
|
|
595
|
+
# Act
|
|
596
|
+
with pytest.raises(DuplicateKeyError) as captured:
|
|
597
|
+
with diff(old, new, key="id"):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
# Assert
|
|
601
|
+
assert captured.value.key == (Decimal(100_000),)
|
|
602
|
+
assert captured.value.lines == (1, span)
|
|
603
|
+
assert captured.value.source == "OLD"
|
|
604
|
+
|
|
571
605
|
@pytest.mark.parametrize(
|
|
572
606
|
"policy,expected",
|
|
573
607
|
[
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|