jsonl-diff 0.1.3__tar.gz → 0.1.4__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.3
3
+ Version: 0.1.4
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
@@ -49,7 +49,8 @@ Classifier: Topic :: File Formats :: JSON
49
49
  Requires-Python: >=3.8
50
50
  Description-Content-Type: text/markdown
51
51
  License-File: LICENSE
52
- Requires-Dist: py-jsonl==1.5.2
52
+ Requires-Dist: ijson>=3.2
53
+ Requires-Dist: py-jsonl==1.5.3
53
54
  Requires-Dist: jmespath>=1.0.0
54
55
  Provides-Extra: speedups
55
56
  Requires-Dist: msgspec>=0.18; python_version >= "3.10" and extra == "speedups"
@@ -65,8 +66,9 @@ Dynamic: license-file
65
66
  <img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
66
67
  </p>
67
68
 
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
69
- position**, without loading the complete inputs into memory.
69
+ A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets and
70
+ top-level JSON arrays **by record identity instead of line position**, without
71
+ loading the complete inputs into memory.
70
72
 
71
73
  `jsonl-diff` matches records using one or more top-level fields and reports:
72
74
  `equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
@@ -143,14 +145,17 @@ Record order does not matter.
143
145
  * Original OLD/NEW physical line numbers.
144
146
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
145
147
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
148
+ * Incremental top-level JSON array parsing with `--format json`.
146
149
  * CLI and Python API using the same comparison engine.
147
150
 
148
151
  ## CLI
149
152
 
150
153
  ```text
151
154
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
152
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
155
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
156
+ [--details FILE] [--quiet]
153
157
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
158
+ [--format {jsonl,json}]
154
159
  old new
155
160
  ```
156
161
 
@@ -161,11 +166,13 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
161
166
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
162
167
  | `--where EXPRESSION` | JMESPath filter applied to each record |
163
168
  | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
169
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
164
170
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
165
171
  | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
166
172
  | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
167
173
  | `--quiet` | Suppress the normal summary |
168
174
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
175
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
169
176
 
170
177
  Examples:
171
178
 
@@ -184,6 +191,11 @@ jsonl-diff old.jsonl new.jsonl \
184
191
  --key id \
185
192
  --where 'deleted_at == `null`'
186
193
 
194
+ # Tolerate an identity field that is absent from some records
195
+ jsonl-diff old.jsonl new.jsonl \
196
+ --key country,customerId \
197
+ --missing-key null
198
+
187
199
  # Detect observed schema drift
188
200
  jsonl-diff old.jsonl new.jsonl \
189
201
  --key id \
@@ -192,6 +204,9 @@ jsonl-diff old.jsonl new.jsonl \
192
204
 
193
205
  # Compressed input
194
206
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
207
+
208
+ # Top-level JSON arrays (parsed incrementally)
209
+ jsonl-diff old.json new.json --key id --format json
195
210
  ```
196
211
 
197
212
  ### Exit codes
@@ -252,7 +267,10 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
252
267
 
253
268
  `jsonl-diff` is intentionally strict:
254
269
 
255
- * Every input line must contain exactly one valid top-level JSON object.
270
+ * In the default `jsonl` format, every input line must contain exactly one
271
+ valid top-level JSON object.
272
+ * With `--format json`, each input must be one top-level JSON array whose
273
+ elements are the records.
256
274
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
257
275
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
258
276
  * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
@@ -275,7 +293,9 @@ order, and canonical number formatting.
275
293
  ## Sources & compression
276
294
 
277
295
  Supported sources include local paths and HTTP/HTTPS URLs. The Python API
278
- also accepts file-like objects.
296
+ also accepts file-like objects. JSON arrays use `py-jsonl.open_stream()` for
297
+ the source and `ijson` for incremental parsing, so compression and remote
298
+ source handling are shared with JSONL input.
279
299
 
280
300
  Supported compression:
281
301
 
@@ -8,8 +8,9 @@
8
8
  <img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
9
9
  </p>
10
10
 
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
- position**, without loading the complete inputs into memory.
11
+ A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets and
12
+ top-level JSON arrays **by record identity instead of line position**, without
13
+ loading the complete inputs into memory.
13
14
 
14
15
  `jsonl-diff` matches records using one or more top-level fields and reports:
15
16
  `equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
@@ -86,14 +87,17 @@ Record order does not matter.
86
87
  * Original OLD/NEW physical line numbers.
87
88
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
88
89
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
90
+ * Incremental top-level JSON array parsing with `--format json`.
89
91
  * CLI and Python API using the same comparison engine.
90
92
 
91
93
  ## CLI
92
94
 
93
95
  ```text
94
96
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
95
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
97
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
98
+ [--details FILE] [--quiet]
96
99
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
100
+ [--format {jsonl,json}]
97
101
  old new
98
102
  ```
99
103
 
@@ -104,11 +108,13 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
104
108
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
105
109
  | `--where EXPRESSION` | JMESPath filter applied to each record |
106
110
  | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
111
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
107
112
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
108
113
  | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
109
114
  | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
110
115
  | `--quiet` | Suppress the normal summary |
111
116
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
117
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
112
118
 
113
119
  Examples:
114
120
 
@@ -127,6 +133,11 @@ jsonl-diff old.jsonl new.jsonl \
127
133
  --key id \
128
134
  --where 'deleted_at == `null`'
129
135
 
136
+ # Tolerate an identity field that is absent from some records
137
+ jsonl-diff old.jsonl new.jsonl \
138
+ --key country,customerId \
139
+ --missing-key null
140
+
130
141
  # Detect observed schema drift
131
142
  jsonl-diff old.jsonl new.jsonl \
132
143
  --key id \
@@ -135,6 +146,9 @@ jsonl-diff old.jsonl new.jsonl \
135
146
 
136
147
  # Compressed input
137
148
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
149
+
150
+ # Top-level JSON arrays (parsed incrementally)
151
+ jsonl-diff old.json new.json --key id --format json
138
152
  ```
139
153
 
140
154
  ### Exit codes
@@ -195,7 +209,10 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
195
209
 
196
210
  `jsonl-diff` is intentionally strict:
197
211
 
198
- * Every input line must contain exactly one valid top-level JSON object.
212
+ * In the default `jsonl` format, every input line must contain exactly one
213
+ valid top-level JSON object.
214
+ * With `--format json`, each input must be one top-level JSON array whose
215
+ elements are the records.
199
216
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
200
217
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
201
218
  * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
@@ -218,7 +235,9 @@ order, and canonical number formatting.
218
235
  ## Sources & compression
219
236
 
220
237
  Supported sources include local paths and HTTP/HTTPS URLs. The Python API
221
- also accepts file-like objects.
238
+ also accepts file-like objects. JSON arrays use `py-jsonl.open_stream()` for
239
+ the source and `ijson` for incremental parsing, so compression and remote
240
+ source handling are shared with JSONL input.
222
241
 
223
242
  Supported compression:
224
243
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonl-diff
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -49,7 +49,8 @@ Classifier: Topic :: File Formats :: JSON
49
49
  Requires-Python: >=3.8
50
50
  Description-Content-Type: text/markdown
51
51
  License-File: LICENSE
52
- Requires-Dist: py-jsonl==1.5.2
52
+ Requires-Dist: ijson>=3.2
53
+ Requires-Dist: py-jsonl==1.5.3
53
54
  Requires-Dist: jmespath>=1.0.0
54
55
  Provides-Extra: speedups
55
56
  Requires-Dist: msgspec>=0.18; python_version >= "3.10" and extra == "speedups"
@@ -65,8 +66,9 @@ Dynamic: license-file
65
66
  <img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
66
67
  </p>
67
68
 
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
69
- position**, without loading the complete inputs into memory.
69
+ A lightweight, pure-Python tool for comparing large JSONL/NDJSON datasets and
70
+ top-level JSON arrays **by record identity instead of line position**, without
71
+ loading the complete inputs into memory.
70
72
 
71
73
  `jsonl-diff` matches records using one or more top-level fields and reports:
72
74
  `equal`, `added`, `deleted`, `modified`, and tolerated duplicates in each source.
@@ -143,14 +145,17 @@ Record order does not matter.
143
145
  * Original OLD/NEW physical line numbers.
144
146
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
145
147
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
148
+ * Incremental top-level JSON array parsing with `--format json`.
146
149
  * CLI and Python API using the same comparison engine.
147
150
 
148
151
  ## CLI
149
152
 
150
153
  ```text
151
154
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
152
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
155
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
156
+ [--details FILE] [--quiet]
153
157
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
158
+ [--format {jsonl,json}]
154
159
  old new
155
160
  ```
156
161
 
@@ -161,11 +166,13 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
161
166
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
162
167
  | `--where EXPRESSION` | JMESPath filter applied to each record |
163
168
  | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
169
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
164
170
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
165
171
  | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
166
172
  | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
167
173
  | `--quiet` | Suppress the normal summary |
168
174
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
175
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
169
176
 
170
177
  Examples:
171
178
 
@@ -184,6 +191,11 @@ jsonl-diff old.jsonl new.jsonl \
184
191
  --key id \
185
192
  --where 'deleted_at == `null`'
186
193
 
194
+ # Tolerate an identity field that is absent from some records
195
+ jsonl-diff old.jsonl new.jsonl \
196
+ --key country,customerId \
197
+ --missing-key null
198
+
187
199
  # Detect observed schema drift
188
200
  jsonl-diff old.jsonl new.jsonl \
189
201
  --key id \
@@ -192,6 +204,9 @@ jsonl-diff old.jsonl new.jsonl \
192
204
 
193
205
  # Compressed input
194
206
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
207
+
208
+ # Top-level JSON arrays (parsed incrementally)
209
+ jsonl-diff old.json new.json --key id --format json
195
210
  ```
196
211
 
197
212
  ### Exit codes
@@ -252,7 +267,10 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
252
267
 
253
268
  `jsonl-diff` is intentionally strict:
254
269
 
255
- * Every input line must contain exactly one valid top-level JSON object.
270
+ * In the default `jsonl` format, every input line must contain exactly one
271
+ valid top-level JSON object.
272
+ * With `--format json`, each input must be one top-level JSON array whose
273
+ elements are the records.
256
274
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
257
275
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
258
276
  * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
@@ -275,7 +293,9 @@ order, and canonical number formatting.
275
293
  ## Sources & compression
276
294
 
277
295
  Supported sources include local paths and HTTP/HTTPS URLs. The Python API
278
- also accepts file-like objects.
296
+ also accepts file-like objects. JSON arrays use `py-jsonl.open_stream()` for
297
+ the source and `ijson` for incremental parsing, so compression and remote
298
+ source handling are shared with JSONL input.
279
299
 
280
300
  Supported compression:
281
301
 
@@ -1,4 +1,5 @@
1
- py-jsonl==1.5.2
1
+ ijson>=3.2
2
+ py-jsonl==1.5.3
2
3
  jmespath>=1.0.0
3
4
 
4
5
  [speedups]
@@ -17,6 +17,7 @@ from json.encoder import encode_basestring_ascii as _encode_basestring_ascii
17
17
  from pathlib import Path
18
18
  from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, Tuple, Union
19
19
 
20
+ import ijson
20
21
  import jmespath
21
22
  import jsonl
22
23
 
@@ -118,6 +119,13 @@ class DuplicatePolicy(str, Enum):
118
119
  LAST = "last"
119
120
 
120
121
 
122
+ class MissingKeyPolicy(str, Enum):
123
+ """How an absent identity field is handled."""
124
+
125
+ ERROR = "error"
126
+ NULL = "null"
127
+
128
+
121
129
  class SchemaChangeOperation(str, Enum):
122
130
  """An observed schema change classification."""
123
131
 
@@ -262,6 +270,8 @@ class DiffConfig:
262
270
  where_expression: Any = None
263
271
  schema_diff: bool = False
264
272
  schema_ignore: Tuple[str, ...] = ()
273
+ format: str = "jsonl"
274
+ missing_key: MissingKeyPolicy = MissingKeyPolicy.ERROR
265
275
 
266
276
 
267
277
  @dataclass(frozen=True)
@@ -574,13 +584,20 @@ def _remove_ignored(value: Any, tree: Dict[str, Any]) -> Any:
574
584
  return result
575
585
 
576
586
 
577
- def _identity(record: Dict[str, Any], keys: Sequence[str]) -> IdentityKey:
587
+ def _identity(
588
+ record: Dict[str, Any],
589
+ keys: Sequence[str],
590
+ missing_key: MissingKeyPolicy = MissingKeyPolicy.ERROR,
591
+ ) -> IdentityKey:
578
592
  composite = len(keys) > 1
579
593
  values = []
580
594
  for name in keys:
581
595
  if name not in record:
582
- raise ValueError("missing identity field {!r}".format(name))
583
- value = record[name]
596
+ if missing_key != MissingKeyPolicy.NULL:
597
+ raise ValueError("missing identity field {!r}".format(name))
598
+ value = None
599
+ else:
600
+ value = record[name]
584
601
  if value is None:
585
602
  # Composite identities may contain null components; uniqueness is
586
603
  # still enforced on the complete identity tuple. A single-field
@@ -603,10 +620,36 @@ def _compile_where(expression: str) -> Any:
603
620
  ) from error
604
621
 
605
622
 
623
+ def _normalize_array_numbers(value: Any) -> Any:
624
+ if isinstance(value, Decimal) and not isinstance(value, _Num):
625
+ return _Num(value)
626
+ if isinstance(value, list):
627
+ return [_normalize_array_numbers(item) for item in value]
628
+ if isinstance(value, dict):
629
+ return {name: _normalize_array_numbers(item) for name, item in value.items()}
630
+ return value
631
+
632
+
633
+ def _array_records(source: Any) -> Iterator[Any]:
634
+ with jsonl.open_stream(source) as stream:
635
+ events = ijson.parse(stream, use_float=False)
636
+ try:
637
+ prefix, event, _value = next(events)
638
+ except StopIteration:
639
+ raise ValueError("empty JSON input") from None
640
+ if (prefix, event) != ("", "start_array"):
641
+ raise ValueError("expected a top-level JSON array")
642
+ for record in ijson.items(events, "item"):
643
+ yield _normalize_array_numbers(record)
644
+
645
+
606
646
  if _msgspec is not None:
607
647
  _INPUT_DECODER = _msgspec.json.Decoder(float_hook=_Num)
608
648
 
609
- def _records(source: Any, on_error: Any) -> Iterator[Any]:
649
+ def _records(source: Any, on_error: Any, input_format: str) -> Iterator[Any]:
650
+ if input_format == "json":
651
+ yield from _array_records(source)
652
+ return
610
653
  # msgspec decodes input lines ~2.6x faster than the stdlib parser and
611
654
  # rejects NaN/Infinity natively. Floats become `_Num` (a Decimal subclass)
612
655
  # while integers stay native `int`; both are handled by the number
@@ -614,7 +657,10 @@ if _msgspec is not None:
614
657
  # matching the pure-Python fallback below.
615
658
  yield from jsonl.load(source, cls=_INPUT_DECODER.decode, _on_error=on_error)
616
659
  else:
617
- def _records(source: Any, on_error: Any) -> Iterator[Any]:
660
+ def _records(source: Any, on_error: Any, input_format: str) -> Iterator[Any]:
661
+ if input_format == "json":
662
+ yield from _array_records(source)
663
+ return
618
664
  # No object_pairs_hook is installed, so duplicate object keys follow the
619
665
  # stdlib parser's last-wins semantics, matching the msgspec path above.
620
666
  yield from jsonl.load(
@@ -1053,7 +1099,7 @@ class DiffResult:
1053
1099
  self._check_size()
1054
1100
  continue
1055
1101
  try:
1056
- key = _identity(record, self.config.key)
1102
+ key = _identity(record, self.config.key, self.config.missing_key)
1057
1103
  normalized = _remove_ignored(record, self._ignore_tree)
1058
1104
  canonical = _content_canonical(normalized)
1059
1105
  identity = _canonical(list(key))
@@ -1176,10 +1222,10 @@ class DiffResult:
1176
1222
 
1177
1223
  try:
1178
1224
  with self._connection:
1179
- self._insert_records(_records(source, on_error), side, name)
1225
+ self._insert_records(_records(source, on_error, self.config.format), side, name)
1180
1226
  except JsonlDiffError:
1181
1227
  raise
1182
- except (OSError, EOFError, ValueError, RuntimeError) as error:
1228
+ except (OSError, EOFError, ValueError, RuntimeError, ijson.JSONError) as error:
1183
1229
  line = error_line[0]
1184
1230
  message = "invalid input ({})".format(type(error).__name__)
1185
1231
  raise InputError(message, name, line) from error
@@ -1264,6 +1310,8 @@ def _configuration(
1264
1310
  max_temp: Optional[int],
1265
1311
  schema_diff: bool,
1266
1312
  schema_ignore: Sequence[str],
1313
+ input_format: str,
1314
+ missing_key: Union[str, MissingKeyPolicy] = MissingKeyPolicy.ERROR,
1267
1315
  ) -> DiffConfig:
1268
1316
  keys = (key,) if isinstance(key, str) else tuple(key)
1269
1317
  if not keys or any(not isinstance(name, str) or not name for name in keys):
@@ -1282,20 +1330,30 @@ def _configuration(
1282
1330
  duplicate_policy = DuplicatePolicy(duplicates)
1283
1331
  except ValueError as error:
1284
1332
  raise ConfigurationError("invalid duplicate policy {!r}".format(duplicates)) from error
1333
+ try:
1334
+ missing_key_policy = MissingKeyPolicy(missing_key)
1335
+ except ValueError as error:
1336
+ raise ConfigurationError(
1337
+ "invalid missing-key policy {!r}".format(missing_key),
1338
+ ) from error
1285
1339
  if (
1286
1340
  max_temp is not None
1287
1341
  and (not isinstance(max_temp, int) or isinstance(max_temp, bool) or max_temp <= 0)
1288
1342
  ):
1289
1343
  raise ConfigurationError("max_temp must be a positive integer")
1344
+ if input_format not in ("jsonl", "json"):
1345
+ raise ConfigurationError("invalid format {!r}".format(input_format))
1290
1346
  return DiffConfig(
1291
1347
  key=keys,
1292
1348
  ignore=ignores,
1293
1349
  where=where,
1294
1350
  duplicates=duplicate_policy,
1351
+ missing_key=missing_key_policy,
1295
1352
  max_temp=max_temp,
1296
1353
  where_expression=where_expression,
1297
1354
  schema_diff=schema_diff,
1298
1355
  schema_ignore=schema_ignores,
1356
+ format=input_format,
1299
1357
  )
1300
1358
 
1301
1359
 
@@ -1307,9 +1365,11 @@ def diff(
1307
1365
  ignore: Sequence[str] = (),
1308
1366
  where: Optional[str] = None,
1309
1367
  duplicates: Union[str, DuplicatePolicy] = DuplicatePolicy.ERROR,
1368
+ missing_key: Union[str, MissingKeyPolicy] = MissingKeyPolicy.ERROR,
1310
1369
  max_temp: Optional[int] = None,
1311
1370
  schema_diff: bool = False,
1312
1371
  schema_ignore: Sequence[str] = (),
1372
+ format: str = "jsonl", # noqa: A002
1313
1373
  ) -> DiffResult:
1314
1374
  """Create a context-managed, disk-backed comparison."""
1315
1375
  config = _configuration(
@@ -1320,6 +1380,8 @@ def diff(
1320
1380
  max_temp,
1321
1381
  schema_diff,
1322
1382
  schema_ignore,
1383
+ format,
1384
+ missing_key,
1323
1385
  )
1324
1386
  return DiffResult(old, new, config)
1325
1387
 
@@ -1408,6 +1470,7 @@ def _write_details(result: DiffResult, path: Union[str, os.PathLike]) -> None:
1408
1470
  "ignore": list(result.config.ignore),
1409
1471
  "where": result.config.where,
1410
1472
  "duplicates": result.config.duplicates.value,
1473
+ "missing_key": result.config.missing_key.value,
1411
1474
  }
1412
1475
  if result.config.schema_diff:
1413
1476
  metadata["schema_diff"] = True
@@ -1454,11 +1517,17 @@ def _parser() -> argparse.ArgumentParser:
1454
1517
  choices=tuple(policy.value for policy in DuplicatePolicy),
1455
1518
  default=DuplicatePolicy.ERROR.value,
1456
1519
  )
1520
+ parser.add_argument(
1521
+ "--missing-key",
1522
+ choices=tuple(policy.value for policy in MissingKeyPolicy),
1523
+ default=MissingKeyPolicy.ERROR.value,
1524
+ )
1457
1525
  parser.add_argument("--details", metavar="FILE")
1458
1526
  parser.add_argument("--schema-diff", action="store_true")
1459
1527
  parser.add_argument("--schema-ignore", action="append", default=[], metavar="POINTER")
1460
1528
  parser.add_argument("--quiet", action="store_true")
1461
1529
  parser.add_argument("--max-temp", type=int)
1530
+ parser.add_argument("--format", choices=("jsonl", "json"), default="jsonl")
1462
1531
  return parser
1463
1532
 
1464
1533
 
@@ -1470,9 +1539,11 @@ def _run_comparison(arguments: argparse.Namespace, old: Any, new: Any, keys: Tup
1470
1539
  ignore=arguments.ignore,
1471
1540
  where=arguments.where,
1472
1541
  duplicates=arguments.duplicates,
1542
+ missing_key=arguments.missing_key,
1473
1543
  max_temp=arguments.max_temp,
1474
1544
  schema_diff=arguments.schema_diff,
1475
1545
  schema_ignore=arguments.schema_ignore,
1546
+ format=arguments.format,
1476
1547
  ) as result:
1477
1548
  if arguments.details:
1478
1549
  _write_details(result, arguments.details)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jsonl-diff"
7
- version = "0.1.3"
7
+ version = "0.1.4"
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" }
@@ -29,7 +29,8 @@ classifiers = [
29
29
  "Topic :: File Formats :: JSON",
30
30
  ]
31
31
  dependencies = [
32
- "py-jsonl==1.5.2",
32
+ "ijson>=3.2",
33
+ "py-jsonl==1.5.3",
33
34
  "jmespath>=1.0.0",
34
35
  ]
35
36
 
@@ -1,5 +1,6 @@
1
1
  import io
2
2
  import json
3
+ import types
3
4
  from decimal import Decimal
4
5
 
5
6
  import pytest
@@ -26,6 +27,36 @@ def _different_sources(write_jsonl):
26
27
 
27
28
 
28
29
  class TestCliExitCodes:
30
+ def test_json_format_compares_array_inputs(self, tmp_path, capsys):
31
+ # Arrange
32
+ old = tmp_path / "old.json"
33
+ new = tmp_path / "new.json"
34
+ old.write_text('[{"id":1}]', encoding="utf-8")
35
+ new.write_text('[{"id":1},{"id":2}]', encoding="utf-8")
36
+
37
+ # Act
38
+ exit_code = main([str(old), str(new), "--key", "id", "--format", "json"])
39
+ captured = capsys.readouterr()
40
+
41
+ # Assert
42
+ assert exit_code == 1
43
+ assert "added: 1" in captured.out
44
+
45
+ def test_json_format_reads_stdin(self, tmp_path, monkeypatch, capsys):
46
+ # Arrange
47
+ new = tmp_path / "new.json"
48
+ new.write_text('[{"id":1}]', encoding="utf-8")
49
+ stdin = types.SimpleNamespace(buffer=io.BytesIO(b'[{"id":1},{"id":2}]'))
50
+ monkeypatch.setattr(jsonl_diff.sys, "stdin", stdin)
51
+
52
+ # Act
53
+ exit_code = main(["-", str(new), "--key", "id", "--format", "json"])
54
+ captured = capsys.readouterr()
55
+
56
+ # Assert
57
+ assert exit_code == 1
58
+ assert "deleted: 1" in captured.out
59
+
29
60
  def test_equal_inputs_return_zero(self, write_jsonl, capsys):
30
61
  # Arrange
31
62
  old = write_jsonl("old.jsonl", [{"id": 1}])
@@ -163,6 +194,44 @@ class TestCliExitCodes:
163
194
  assert exit_code == 1
164
195
  assert capsys.readouterr() == ("", "")
165
196
 
197
+ def test_missing_key_defaults_to_error(self, write_jsonl, capsys):
198
+ # Arrange
199
+ old = write_jsonl("old.jsonl", [{"value": "old"}])
200
+ new = write_jsonl("new.jsonl", [{"id": 1, "value": "new"}])
201
+
202
+ # Act
203
+ exit_code = main([str(old), str(new), "--key", "id", "--quiet"])
204
+
205
+ # Assert
206
+ assert exit_code == 2
207
+ assert "missing identity field" in capsys.readouterr().err
208
+
209
+ def test_missing_key_null_policy_tolerates_absent_composite_component(
210
+ self,
211
+ write_jsonl,
212
+ capsys,
213
+ ):
214
+ # Arrange
215
+ old = write_jsonl("old.jsonl", [{"a": 1, "value": "old"}])
216
+ new = write_jsonl("new.jsonl", [{"a": 1, "b": None, "value": "new"}])
217
+
218
+ # Act
219
+ exit_code = main(
220
+ [
221
+ str(old),
222
+ str(new),
223
+ "--key",
224
+ "a,b",
225
+ "--missing-key",
226
+ "null",
227
+ "--quiet",
228
+ ],
229
+ )
230
+
231
+ # Assert
232
+ assert exit_code == 1
233
+ assert capsys.readouterr() == ("", "")
234
+
166
235
  @pytest.mark.parametrize("stdin_side", ["old", "new"])
167
236
  def test_dash_reads_one_source_from_stdin(
168
237
  self,
@@ -459,6 +528,7 @@ class TestDetailsOutput:
459
528
  "ignore": ["/volatile"],
460
529
  "where": None,
461
530
  "duplicates": "error",
531
+ "missing_key": "error",
462
532
  }, {
463
533
  "type": "summary",
464
534
  "equal": 1,
@@ -11,6 +11,7 @@ from jsonl_diff import (
11
11
  DuplicateKeyError,
12
12
  DuplicatePolicy,
13
13
  InputError,
14
+ MissingKeyPolicy,
14
15
  SchemaChangeOperation,
15
16
  SchemaSummary,
16
17
  Summary,
@@ -19,6 +20,65 @@ from jsonl_diff import (
19
20
 
20
21
 
21
22
  class TestDiffSummary:
23
+ def test_json_array_format_compares_array_elements(self, tmp_path):
24
+ # Arrange
25
+ old = tmp_path / "old.json"
26
+ new = tmp_path / "new.json"
27
+ old.write_text('[{"id":1,"value":"before"}]', encoding="utf-8")
28
+ new.write_text('[{"id":1,"value":"after"}]', encoding="utf-8")
29
+
30
+ # Act
31
+ with diff(old, new, key="id", format="json") as result:
32
+ change = next(result.changes())
33
+
34
+ # Assert
35
+ assert change.operation == ChangeOperation.MODIFIED
36
+
37
+ def test_json_array_numbers_use_jsonl_numeric_semantics(self, tmp_path):
38
+ # Arrange
39
+ old = tmp_path / "old.json"
40
+ new = tmp_path / "new.json"
41
+ old.write_text('[{"id":1,"value":1}]', encoding="utf-8")
42
+ new.write_text('[{"id":1.0,"value":1e0}]', encoding="utf-8")
43
+
44
+ # Act
45
+ with diff(old, new, key="id", format="json") as result:
46
+ summary = result.summary
47
+
48
+ # Assert
49
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
50
+
51
+ def test_json_array_locations_are_one_based_element_ordinals(self, tmp_path):
52
+ # Arrange
53
+ old = tmp_path / "old.json"
54
+ new = tmp_path / "new.json"
55
+ old.write_text('[{"id":1},{"id":2}]', encoding="utf-8")
56
+ new.write_text('[{"id":1},{"id":3}]', encoding="utf-8")
57
+
58
+ # Act
59
+ with diff(old, new, key="id", format="json") as result:
60
+ changes = list(result.changes())
61
+
62
+ # Assert
63
+ assert [(change.operation, change.old_line, change.new_line) for change in changes] == [
64
+ (ChangeOperation.DELETED, 2, None),
65
+ (ChangeOperation.ADDED, None, 2),
66
+ ]
67
+
68
+ def test_json_array_duplicate_locations_are_element_ordinals(self, tmp_path):
69
+ # Arrange
70
+ old = tmp_path / "old.json"
71
+ new = tmp_path / "new.json"
72
+ old.write_text('[{"id":1},{"id":1}]', encoding="utf-8")
73
+ new.write_text('[{"id":1}]', encoding="utf-8")
74
+
75
+ # Act
76
+ with diff(old, new, key="id", format="json", duplicates="first") as result:
77
+ duplicate = next(result.duplicates())
78
+
79
+ # Assert
80
+ assert (duplicate.selected_line, duplicate.discarded_line) == (1, 2)
81
+
22
82
  def test_records_in_different_physical_order_are_classified_by_identity(self, write_jsonl):
23
83
  # Arrange
24
84
  old = write_jsonl(
@@ -566,6 +626,67 @@ class TestInputValidation:
566
626
  # Assert
567
627
  assert summary == Summary(added=0, deleted=0, equal=1, modified=1)
568
628
 
629
+ def test_missing_identity_field_defaults_to_error(self, write_jsonl):
630
+ # Arrange
631
+ old = write_jsonl("old.jsonl", [{"value": "old"}])
632
+ new = write_jsonl("new.jsonl", [{"id": 1, "value": "new"}])
633
+
634
+ # Act / Assert
635
+ with pytest.raises(InputError, match="missing identity field"):
636
+ with diff(old, new, key="id"):
637
+ pass
638
+
639
+ def test_missing_key_null_policy_treats_absent_field_as_null(self, write_jsonl):
640
+ # Arrange: "b" is absent from OLD and explicitly null in NEW; with the
641
+ # "null" policy both are treated as the same composite identity.
642
+ old = write_jsonl("old.jsonl", [{"a": 1, "value": "old"}])
643
+ new = write_jsonl("new.jsonl", [{"a": 1, "b": None, "value": "new"}])
644
+
645
+ # Act
646
+ with diff(old, new, key=("a", "b"), missing_key=MissingKeyPolicy.NULL) as result:
647
+ change = next(result.changes())
648
+
649
+ # Assert
650
+ assert change.key == (Decimal("1"), None)
651
+ assert change.operation == ChangeOperation.MODIFIED
652
+
653
+ def test_missing_key_null_policy_still_rejects_single_field_identity(self, write_jsonl):
654
+ # Arrange: a single-field identity may not collapse to null, so the
655
+ # "null" policy still raises for the missing field, same as an
656
+ # explicit null value would.
657
+ old = write_jsonl("old.jsonl", [{"value": "old"}])
658
+ new = write_jsonl("new.jsonl", [{"id": 1, "value": "new"}])
659
+
660
+ # Act / Assert
661
+ with pytest.raises(InputError, match="must be a non-null scalar"):
662
+ with diff(old, new, key="id", missing_key="null"):
663
+ pass
664
+
665
+ def test_missing_key_null_policy_can_collide_across_missing_records(self, write_jsonl):
666
+ # Arrange: two distinct OLD records both missing "b" collapse into one
667
+ # (1, None) identity under the "null" policy, matching the documented
668
+ # collision risk of explicit composite nulls.
669
+ old = write_jsonl(
670
+ "old.jsonl",
671
+ [{"a": 1, "value": "first"}, {"a": 1, "value": "second"}],
672
+ )
673
+ new = write_jsonl("new.jsonl", [])
674
+
675
+ # Act / Assert
676
+ with pytest.raises(DuplicateKeyError):
677
+ with diff(old, new, key=("a", "b"), missing_key="null"):
678
+ pass
679
+
680
+ def test_invalid_missing_key_policy_is_rejected(self, write_jsonl):
681
+ # Arrange
682
+ old = write_jsonl("old.jsonl", [{"id": 1}])
683
+ new = write_jsonl("new.jsonl", [{"id": 1}])
684
+
685
+ # Act / Assert
686
+ with pytest.raises(ConfigurationError, match="invalid missing-key policy"):
687
+ with diff(old, new, key="id", missing_key="bogus"):
688
+ pass
689
+
569
690
  def test_duplicate_identity_reports_source_and_physical_lines(self, write_jsonl):
570
691
  # Arrange
571
692
  old = write_jsonl("old.jsonl", [{"id": 1}, {"id": 2}, {"id": 1}])
@@ -82,6 +82,83 @@ class TestStreamSources:
82
82
  # Assert
83
83
  assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
84
84
 
85
+ def test_json_array_file_like_sources_are_supported(self):
86
+ # Arrange
87
+ old = io.BytesIO(b'[{"id":1,"value":"same"}]')
88
+ new = io.BytesIO(b'[{"value":"same","id":1}]')
89
+
90
+ # Act
91
+ with diff(old, new, key="id", format="json") as result:
92
+ summary = result.summary
93
+
94
+ # Assert
95
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
96
+ assert not old.closed
97
+ assert not new.closed
98
+
99
+
100
+ class TestJsonArraySources:
101
+ def test_json_arrays_are_streamed_incrementally(self, tmp_path):
102
+ # Arrange
103
+ old = tmp_path / "old.json"
104
+ new = tmp_path / "new.json"
105
+ old.write_text('[{"id":1,"value":"same"},{"id":2}]', encoding="utf-8")
106
+ new.write_text('[{"id":2},{"id":3}]', encoding="utf-8")
107
+
108
+ # Act
109
+ with diff(old, new, key="id", format="json") as result:
110
+ summary = result.summary
111
+
112
+ # Assert
113
+ assert summary == Summary(equal=1, added=1, deleted=1, modified=0)
114
+
115
+ @pytest.mark.parametrize(
116
+ "suffix,writer",
117
+ [
118
+ (".json.gz", _write_gzip),
119
+ (".json.bz2", _write_bzip2),
120
+ (".json.xz", _write_xz),
121
+ ],
122
+ )
123
+ def test_compressed_json_arrays_are_supported(self, tmp_path, suffix, writer):
124
+ # Arrange
125
+ old = tmp_path / ("old" + suffix)
126
+ new = tmp_path / ("new" + suffix)
127
+ writer(old, b'[{"id":1,"value":"same"}]')
128
+ writer(new, b'[{"value":"same","id":1}]')
129
+
130
+ # Act
131
+ with diff(old, new, key="id", format="json") as result:
132
+ summary = result.summary
133
+
134
+ # Assert
135
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
136
+
137
+ def test_json_format_rejects_non_array_documents(self, tmp_path):
138
+ # Arrange
139
+ old = tmp_path / "old.json"
140
+ new = tmp_path / "new.json"
141
+ old.write_text('{"id":1}', encoding="utf-8")
142
+ new.write_text("[]", encoding="utf-8")
143
+
144
+ # Act / Assert
145
+ with pytest.raises(InputError, match="invalid input"):
146
+ with diff(old, new, key="id", format="json"):
147
+ pass
148
+
149
+ @pytest.mark.parametrize("content", ['[{"id":1}', '[{"id":1}] trailing'])
150
+ def test_json_format_rejects_malformed_documents(self, tmp_path, content):
151
+ # Arrange
152
+ old = tmp_path / "old.json"
153
+ new = tmp_path / "new.json"
154
+ old.write_text(content, encoding="utf-8")
155
+ new.write_text("[]", encoding="utf-8")
156
+
157
+ # Act / Assert
158
+ with pytest.raises(InputError, match="invalid input"):
159
+ with diff(old, new, key="id", format="json"):
160
+ pass
161
+
85
162
 
86
163
  class _SilentHandler(http.server.SimpleHTTPRequestHandler):
87
164
  def log_message(self, message, *args):
@@ -117,6 +194,35 @@ class TestHttpSources:
117
194
  # Assert
118
195
  assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
119
196
 
197
+ def test_http_json_array_sources_are_supported(self, tmp_path):
198
+ # Arrange
199
+ old = tmp_path / "old.json"
200
+ old.write_text('[{"id":1,"value":"same"}]', encoding="utf-8")
201
+ new = tmp_path / "new.json"
202
+ new.write_text('[{"value":"same","id":1}]', encoding="utf-8")
203
+ handler = functools.partial(_SilentHandler, directory=str(tmp_path))
204
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
205
+ thread = threading.Thread(target=server.serve_forever)
206
+ thread.start()
207
+
208
+ # Act
209
+ try:
210
+ port = server.server_address[1]
211
+ with diff(
212
+ "http://127.0.0.1:{}/old.json".format(port),
213
+ "http://127.0.0.1:{}/new.json".format(port),
214
+ key="id",
215
+ format="json",
216
+ ) as result:
217
+ summary = result.summary
218
+ finally:
219
+ server.shutdown()
220
+ server.server_close()
221
+ thread.join()
222
+
223
+ # Assert
224
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
225
+
120
226
 
121
227
  class TestTemporaryStorage:
122
228
  @pytest.mark.parametrize("limit", [0, -1, "invalid"])
File without changes
File without changes