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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonl-diff
3
- Version: 0.1.2
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
- Compare large JSONL/NDJSON datasets **by record identity instead of line
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, duplicate properties, `NaN`, and infinities are rejected.
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
- Compare large JSONL/NDJSON datasets **by record identity instead of line
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, duplicate properties, `NaN`, and infinities are rejected.
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.2
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
- Compare large JSONL/NDJSON datasets **by record identity instead of line
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, duplicate properties, `NaN`, and infinities are rejected.
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
@@ -0,0 +1,7 @@
1
+ py-jsonl==1.5.2
2
+ jmespath>=1.0.0
3
+
4
+ [speedups]
5
+
6
+ [speedups:python_version >= "3.10"]
7
+ msgspec>=0.18
@@ -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 len(digits) - trailing_zeros > 1 and digits[-trailing_zeros - 1] == 0:
340
+ while count - trailing_zeros > 1 and digits[count - trailing_zeros - 1] == 0:
323
341
  trailing_zeros += 1
324
-
325
- digits = digits[:-trailing_zeros] if trailing_zeros else digits
326
- exponent += trailing_zeros
327
- mantissa = str(digits[0])
328
- if len(digits) > 1:
329
- mantissa += "." + "".join(str(digit) for digit in digits[1:])
330
-
331
- scientific_exponent = exponent + len(digits) - 1
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
- ensure_ascii: bool,
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 json.dumps(value, ensure_ascii=ensure_ascii)
418
+ return escape(value)
358
419
  if isinstance(value, (list, tuple)):
359
- return "[{}]".format(",".join(_json_text(item, ensure_ascii, number) for item in value))
420
+ return "[" + ",".join([_json_text(item, escape, number) for item in value]) + "]"
360
421
  if isinstance(value, dict):
361
- if any(not isinstance(name, str) for name in value):
362
- raise ValueError("JSON object property names must be strings")
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
- return "{{{}}}".format(",".join(members))
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
- return _json_text(value, ensure_ascii, _number)
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
- return _json_text(value, ensure_ascii, _details_number)
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
- def _records(source: Any, on_error: Any) -> Iterator[Any]:
525
- yield from jsonl.load(
526
- source,
527
- parse_int=Decimal,
528
- parse_float=Decimal,
529
- parse_constant=_reject_constant,
530
- object_pairs_hook=_object,
531
- _on_error=on_error,
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 = _canonical(normalized)
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
- try:
973
- cursor.execute("INSERT INTO records VALUES (?, ?, ?, ?, ?)", values)
974
- except sqlite3.IntegrityError as error:
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
- FROM records AS o JOIN records AS n USING (identity)
1074
- WHERE o.side = 0 AND n.side = 1
1075
- AND o.length = n.length
1076
- AND o.digest = n.digest),
1077
- (SELECT COUNT(*)
1078
- FROM records AS n
1079
- WHERE n.side = 1 AND NOT EXISTS (
1080
- SELECT 1 FROM records AS o
1081
- WHERE o.side = 0 AND o.identity = n.identity
1082
- )),
1083
- (SELECT COUNT(*)
1084
- FROM records AS o
1085
- WHERE o.side = 0 AND NOT EXISTS (
1086
- SELECT 1 FROM records AS n
1087
- WHERE n.side = 1 AND n.identity = o.identity
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(*(int(value or 0) for value in row), schema=schema)
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.2"
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
  [
@@ -1,2 +0,0 @@
1
- py-jsonl==1.5.2
2
- jmespath>=1.0.0
File without changes
File without changes
File without changes