jsonl-diff 0.1.3__tar.gz → 0.1.5__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.5
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.
@@ -138,19 +140,21 @@ Record order does not matter.
138
140
  * Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
139
141
  * Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
140
142
  * Semantic number comparison using `Decimal`.
141
- * Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
142
143
  * Deterministic summaries and change iteration.
143
144
  * Original OLD/NEW physical line numbers.
144
145
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
145
146
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
147
+ * Incremental parsing for JSONL/NDJSON by default, with support for top-level JSON arrays via `--format json`.
146
148
  * CLI and Python API using the same comparison engine.
147
149
 
148
150
  ## CLI
149
151
 
150
152
  ```text
151
153
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
152
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
154
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
155
+ [--details FILE] [--quiet]
153
156
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
157
+ [--format {jsonl,json}]
154
158
  old new
155
159
  ```
156
160
 
@@ -160,12 +164,14 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
160
164
  | `--key KEY` | Required top-level identity field; repeat or comma-separate for composite keys |
161
165
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
162
166
  | `--where EXPRESSION` | JMESPath filter applied to each record |
163
- | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
167
+ | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
168
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
164
169
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
165
- | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
166
- | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
170
+ | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
171
+ | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
167
172
  | `--quiet` | Suppress the normal summary |
168
173
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
174
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
169
175
 
170
176
  Examples:
171
177
 
@@ -184,6 +190,11 @@ jsonl-diff old.jsonl new.jsonl \
184
190
  --key id \
185
191
  --where 'deleted_at == `null`'
186
192
 
193
+ # Tolerate an identity field that is absent from some records
194
+ jsonl-diff old.jsonl new.jsonl \
195
+ --key country,customerId \
196
+ --missing-key null
197
+
187
198
  # Detect observed schema drift
188
199
  jsonl-diff old.jsonl new.jsonl \
189
200
  --key id \
@@ -192,6 +203,9 @@ jsonl-diff old.jsonl new.jsonl \
192
203
 
193
204
  # Compressed input
194
205
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
206
+
207
+ # Top-level JSON arrays (parsed incrementally)
208
+ jsonl-diff old.json new.json --key id --format json
195
209
  ```
196
210
 
197
211
  ### Exit codes
@@ -252,10 +266,15 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
252
266
 
253
267
  `jsonl-diff` is intentionally strict:
254
268
 
255
- * Every input line must contain exactly one valid top-level JSON object.
269
+ * In the default `jsonl` format, every input line must contain exactly one
270
+ valid top-level JSON object.
271
+ * With `--format json`, each input must be one top-level JSON array whose
272
+ elements are the records.
256
273
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
257
274
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
258
- * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
275
+ * Identity fields must be top-level JSON values. Object member order is ignored
276
+ and array order is significant (`null` allowed only as one component of a
277
+ composite key).
259
278
  * Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
260
279
  * Duplicate identities fail by default; `--duplicates first`/`last` select one
261
280
  occurrence, report every discarded occurrence, and return exit code `1`.
@@ -285,7 +304,7 @@ Supported compression:
285
304
  * Zstandard on Python 3.14
286
305
 
287
306
  See [architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md) for the full source/compression
288
- support matrix and how sources are delegated to `py-jsonl`.
307
+ support matrix and how sources are managed.
289
308
 
290
309
  ## Limitations
291
310
 
@@ -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.
@@ -81,19 +82,21 @@ Record order does not matter.
81
82
  * Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
82
83
  * Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
83
84
  * Semantic number comparison using `Decimal`.
84
- * Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
85
85
  * Deterministic summaries and change iteration.
86
86
  * Original OLD/NEW physical line numbers.
87
87
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
88
88
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
89
+ * Incremental parsing for JSONL/NDJSON by default, with support for top-level JSON arrays via `--format json`.
89
90
  * CLI and Python API using the same comparison engine.
90
91
 
91
92
  ## CLI
92
93
 
93
94
  ```text
94
95
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
95
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
96
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
97
+ [--details FILE] [--quiet]
96
98
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
99
+ [--format {jsonl,json}]
97
100
  old new
98
101
  ```
99
102
 
@@ -103,12 +106,14 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
103
106
  | `--key KEY` | Required top-level identity field; repeat or comma-separate for composite keys |
104
107
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
105
108
  | `--where EXPRESSION` | JMESPath filter applied to each record |
106
- | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
109
+ | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
110
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
107
111
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
108
- | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
109
- | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
112
+ | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
113
+ | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
110
114
  | `--quiet` | Suppress the normal summary |
111
115
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
116
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
112
117
 
113
118
  Examples:
114
119
 
@@ -127,6 +132,11 @@ jsonl-diff old.jsonl new.jsonl \
127
132
  --key id \
128
133
  --where 'deleted_at == `null`'
129
134
 
135
+ # Tolerate an identity field that is absent from some records
136
+ jsonl-diff old.jsonl new.jsonl \
137
+ --key country,customerId \
138
+ --missing-key null
139
+
130
140
  # Detect observed schema drift
131
141
  jsonl-diff old.jsonl new.jsonl \
132
142
  --key id \
@@ -135,6 +145,9 @@ jsonl-diff old.jsonl new.jsonl \
135
145
 
136
146
  # Compressed input
137
147
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
148
+
149
+ # Top-level JSON arrays (parsed incrementally)
150
+ jsonl-diff old.json new.json --key id --format json
138
151
  ```
139
152
 
140
153
  ### Exit codes
@@ -195,10 +208,15 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
195
208
 
196
209
  `jsonl-diff` is intentionally strict:
197
210
 
198
- * Every input line must contain exactly one valid top-level JSON object.
211
+ * In the default `jsonl` format, every input line must contain exactly one
212
+ valid top-level JSON object.
213
+ * With `--format json`, each input must be one top-level JSON array whose
214
+ elements are the records.
199
215
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
200
216
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
201
- * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
217
+ * Identity fields must be top-level JSON values. Object member order is ignored
218
+ and array order is significant (`null` allowed only as one component of a
219
+ composite key).
202
220
  * Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
203
221
  * Duplicate identities fail by default; `--duplicates first`/`last` select one
204
222
  occurrence, report every discarded occurrence, and return exit code `1`.
@@ -228,7 +246,7 @@ Supported compression:
228
246
  * Zstandard on Python 3.14
229
247
 
230
248
  See [architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md) for the full source/compression
231
- support matrix and how sources are delegated to `py-jsonl`.
249
+ support matrix and how sources are managed.
232
250
 
233
251
  ## Limitations
234
252
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonl-diff
3
- Version: 0.1.3
3
+ Version: 0.1.5
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.
@@ -138,19 +140,21 @@ Record order does not matter.
138
140
  * Exact **[RFC 6901](https://www.rfc-editor.org/info/rfc6901/)** JSON Pointer ignores.
139
141
  * Optional disk-backed observed-schema diff for fields, types, nullability, and requiredness.
140
142
  * Semantic number comparison using `Decimal`.
141
- * Optional `msgspec` accelerator for ~2x faster large diffs (`pip install "jsonl-diff[speedups]"`).
142
143
  * Deterministic summaries and change iteration.
143
144
  * Original OLD/NEW physical line numbers.
144
145
  * Machine-readable JSONL change log with `--details` ([format](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/details-format.md)).
145
146
  * Local, HTTP/HTTPS, file-like, and supported compressed sources.
147
+ * Incremental parsing for JSONL/NDJSON by default, with support for top-level JSON arrays via `--format json`.
146
148
  * CLI and Python API using the same comparison engine.
147
149
 
148
150
  ## CLI
149
151
 
150
152
  ```text
151
153
  jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
152
- [--duplicates {error,first,last}] [--details FILE] [--quiet]
154
+ [--duplicates {error,first,last}] [--missing-key {error,null}]
155
+ [--details FILE] [--quiet]
153
156
  [--schema-diff] [--schema-ignore POINTER] [--max-temp MAX_TEMP]
157
+ [--format {jsonl,json}]
154
158
  old new
155
159
  ```
156
160
 
@@ -160,12 +164,14 @@ jsonl-diff [-h] --key KEY [--ignore IGNORE] [--where EXPRESSION]
160
164
  | `--key KEY` | Required top-level identity field; repeat or comma-separate for composite keys |
161
165
  | `--ignore POINTER` | RFC 6901 pointer to exclude from content comparison |
162
166
  | `--where EXPRESSION` | JMESPath filter applied to each record |
163
- | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
167
+ | `--duplicates POLICY` | `error` (default), or select and report duplicates with `first`/`last` |
168
+ | `--missing-key POLICY`| `error` (default), or `null` to treat an absent identity field as `null` |
164
169
  | `--details FILE` | Write deterministic machine-readable JSONL changes |
165
- | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
166
- | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
170
+ | `--schema-diff` | Compare observed fields, types, nullability, and requiredness |
171
+ | `--schema-ignore` | RFC 6901 pointer to exclude from observed-schema profiling |
167
172
  | `--quiet` | Suppress the normal summary |
168
173
  | `--max-temp BYTES` | Best-effort budget for `jsonl-diff` workspace temporary storage |
174
+ | `--format FORMAT` | Input format: `jsonl` (default) or a top-level JSON array with `json` |
169
175
 
170
176
  Examples:
171
177
 
@@ -184,6 +190,11 @@ jsonl-diff old.jsonl new.jsonl \
184
190
  --key id \
185
191
  --where 'deleted_at == `null`'
186
192
 
193
+ # Tolerate an identity field that is absent from some records
194
+ jsonl-diff old.jsonl new.jsonl \
195
+ --key country,customerId \
196
+ --missing-key null
197
+
187
198
  # Detect observed schema drift
188
199
  jsonl-diff old.jsonl new.jsonl \
189
200
  --key id \
@@ -192,6 +203,9 @@ jsonl-diff old.jsonl new.jsonl \
192
203
 
193
204
  # Compressed input
194
205
  jsonl-diff old.jsonl.xz new.jsonl.gz --key id
206
+
207
+ # Top-level JSON arrays (parsed incrementally)
208
+ jsonl-diff old.json new.json --key id --format json
195
209
  ```
196
210
 
197
211
  ### Exit codes
@@ -252,10 +266,15 @@ signature, result models, `Decimal` key semantics, and error hierarchy.
252
266
 
253
267
  `jsonl-diff` is intentionally strict:
254
268
 
255
- * Every input line must contain exactly one valid top-level JSON object.
269
+ * In the default `jsonl` format, every input line must contain exactly one
270
+ valid top-level JSON object.
271
+ * With `--format json`, each input must be one top-level JSON array whose
272
+ elements are the records.
256
273
  * Blank lines, malformed JSON, `NaN`, and infinities are rejected.
257
274
  * Duplicate object property names within a record follow JSON's last-wins semantics: the last occurrence is kept.
258
- * Identity fields must be top-level scalar values (`null` allowed only as one component of a composite key).
275
+ * Identity fields must be top-level JSON values. Object member order is ignored
276
+ and array order is significant (`null` allowed only as one component of a
277
+ composite key).
259
278
  * Identity types remain significant: `"1"` ≠ `1`, `true` ≠ `1`.
260
279
  * Duplicate identities fail by default; `--duplicates first`/`last` select one
261
280
  occurrence, report every discarded occurrence, and return exit code `1`.
@@ -285,7 +304,7 @@ Supported compression:
285
304
  * Zstandard on Python 3.14
286
305
 
287
306
  See [architecture](https://github.com/rmoralespp/jsonl-diff/blob/main/docs/architecture.md) for the full source/compression
288
- support matrix and how sources are delegated to `py-jsonl`.
307
+ support matrix and how sources are managed.
289
308
 
290
309
  ## Limitations
291
310
 
@@ -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
 
@@ -25,9 +26,9 @@ try: # Optional C-accelerated content canonicalization (Python 3.10+).
25
26
  except ImportError:
26
27
  _msgspec = None
27
28
 
28
- JsonScalar = Union[str, Decimal, bool, None]
29
+ JsonIdentityValue = Union[str, Decimal, bool, int, None, list, dict]
29
30
  Number = Union[Decimal, int, float]
30
- IdentityKey = Tuple[JsonScalar, ...]
31
+ IdentityKey = Tuple[JsonIdentityValue, ...]
31
32
 
32
33
 
33
34
  class _Num(Decimal):
@@ -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
@@ -588,8 +605,8 @@ def _identity(record: Dict[str, Any], keys: Sequence[str]) -> IdentityKey:
588
605
  # record into one indistinguishable identity.
589
606
  if not composite:
590
607
  raise ValueError("identity field {!r} must be a non-null scalar".format(name))
591
- elif not isinstance(value, (str, Decimal, bool, int)):
592
- raise ValueError("identity field {!r} must be a non-null scalar".format(name))
608
+ elif not isinstance(value, (str, Decimal, bool, int, list, dict)):
609
+ raise ValueError("identity field {!r} must be a JSON value".format(name))
593
610
  values.append(value)
594
611
  return tuple(values)
595
612
 
@@ -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.5"
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,
@@ -527,6 +597,26 @@ class TestDetailsOutput:
527
597
  # Assert
528
598
  assert records[1]["key"] == [12345678901234567890]
529
599
 
600
+ def test_structured_key_is_written_to_details_as_json(
601
+ self,
602
+ write_jsonl,
603
+ tmp_path,
604
+ ):
605
+ old = write_jsonl(
606
+ "old.jsonl",
607
+ [{"id": {"region": "ES", "customer": 7}, "value": "before"}],
608
+ )
609
+ new = write_jsonl(
610
+ "new.jsonl",
611
+ [{"id": {"customer": 7, "region": "ES"}, "value": "after"}],
612
+ )
613
+ details = tmp_path / "changes.jsonl"
614
+
615
+ main([str(old), str(new), "--key", "id", "--details", str(details), "--quiet"])
616
+ records = [json.loads(line) for line in details.read_text(encoding="utf-8").splitlines()]
617
+
618
+ assert records[1]["key"] == [{"customer": 7, "region": "ES"}]
619
+
530
620
  def test_duplicate_events_follow_meta_and_include_policy_outcome(
531
621
  self,
532
622
  write_jsonl,
@@ -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(
@@ -71,6 +131,50 @@ class TestDiffSummary:
71
131
 
72
132
 
73
133
  class TestIdentityKeys:
134
+ @pytest.mark.parametrize(
135
+ "identity",
136
+ [
137
+ {"region": "ES", "customer": 7},
138
+ ["ES", 7, {"active": True}],
139
+ ],
140
+ )
141
+ def test_structured_identity_values_are_supported(self, write_jsonl, identity):
142
+ old = write_jsonl("old.jsonl", [{"id": identity, "value": "before"}])
143
+ new = write_jsonl("new.jsonl", [{"id": identity, "value": "after"}])
144
+
145
+ with diff(old, new, key="id") as result:
146
+ change = next(result.changes())
147
+
148
+ assert change.key == (identity,)
149
+ assert change.operation == ChangeOperation.MODIFIED
150
+
151
+ def test_identity_object_member_order_is_ignored(self, write_jsonl):
152
+ old = write_jsonl("old.jsonl", ['{"id":{"a":1,"b":{"x":2,"y":3}}}'])
153
+ new = write_jsonl("new.jsonl", ['{"id":{"b":{"y":3,"x":2},"a":1}}'])
154
+
155
+ with diff(old, new, key="id") as result:
156
+ summary = result.summary
157
+
158
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
159
+
160
+ def test_identity_array_order_is_significant(self, write_jsonl):
161
+ old = write_jsonl("old.jsonl", ['{"id":[1,2]}'])
162
+ new = write_jsonl("new.jsonl", ['{"id":[2,1]}'])
163
+
164
+ with diff(old, new, key="id") as result:
165
+ summary = result.summary
166
+
167
+ assert summary == Summary(equal=0, added=1, deleted=1, modified=0)
168
+
169
+ def test_identity_nested_numbers_use_json_numeric_semantics(self, write_jsonl):
170
+ old = write_jsonl("old.jsonl", ['{"id":{"version":1,"values":[2.0]}}'])
171
+ new = write_jsonl("new.jsonl", ['{"id":{"version":1.0,"values":[2e0]}}'])
172
+
173
+ with diff(old, new, key="id") as result:
174
+ summary = result.summary
175
+
176
+ assert summary == Summary(equal=1, added=0, deleted=0, modified=0)
177
+
74
178
  def test_composite_key_is_returned_as_typed_tuple(self, write_jsonl):
75
179
  # Arrange
76
180
  old = write_jsonl("old.jsonl", [{"country": "ES", "customer": 7, "value": "before"}])
@@ -521,8 +625,6 @@ class TestInputValidation:
521
625
  [
522
626
  ({}, "missing identity field"),
523
627
  ({"id": None}, "must be a non-null scalar"),
524
- ({"id": []}, "must be a non-null scalar"),
525
- ({"id": {}}, "must be a non-null scalar"),
526
628
  ],
527
629
  )
528
630
  def test_invalid_identity_component_is_rejected(self, write_jsonl, record, message):
@@ -566,6 +668,67 @@ class TestInputValidation:
566
668
  # Assert
567
669
  assert summary == Summary(added=0, deleted=0, equal=1, modified=1)
568
670
 
671
+ def test_missing_identity_field_defaults_to_error(self, write_jsonl):
672
+ # Arrange
673
+ old = write_jsonl("old.jsonl", [{"value": "old"}])
674
+ new = write_jsonl("new.jsonl", [{"id": 1, "value": "new"}])
675
+
676
+ # Act / Assert
677
+ with pytest.raises(InputError, match="missing identity field"):
678
+ with diff(old, new, key="id"):
679
+ pass
680
+
681
+ def test_missing_key_null_policy_treats_absent_field_as_null(self, write_jsonl):
682
+ # Arrange: "b" is absent from OLD and explicitly null in NEW; with the
683
+ # "null" policy both are treated as the same composite identity.
684
+ old = write_jsonl("old.jsonl", [{"a": 1, "value": "old"}])
685
+ new = write_jsonl("new.jsonl", [{"a": 1, "b": None, "value": "new"}])
686
+
687
+ # Act
688
+ with diff(old, new, key=("a", "b"), missing_key=MissingKeyPolicy.NULL) as result:
689
+ change = next(result.changes())
690
+
691
+ # Assert
692
+ assert change.key == (Decimal("1"), None)
693
+ assert change.operation == ChangeOperation.MODIFIED
694
+
695
+ def test_missing_key_null_policy_still_rejects_single_field_identity(self, write_jsonl):
696
+ # Arrange: a single-field identity may not collapse to null, so the
697
+ # "null" policy still raises for the missing field, same as an
698
+ # explicit null value would.
699
+ old = write_jsonl("old.jsonl", [{"value": "old"}])
700
+ new = write_jsonl("new.jsonl", [{"id": 1, "value": "new"}])
701
+
702
+ # Act / Assert
703
+ with pytest.raises(InputError, match="must be a non-null scalar"):
704
+ with diff(old, new, key="id", missing_key="null"):
705
+ pass
706
+
707
+ def test_missing_key_null_policy_can_collide_across_missing_records(self, write_jsonl):
708
+ # Arrange: two distinct OLD records both missing "b" collapse into one
709
+ # (1, None) identity under the "null" policy, matching the documented
710
+ # collision risk of explicit composite nulls.
711
+ old = write_jsonl(
712
+ "old.jsonl",
713
+ [{"a": 1, "value": "first"}, {"a": 1, "value": "second"}],
714
+ )
715
+ new = write_jsonl("new.jsonl", [])
716
+
717
+ # Act / Assert
718
+ with pytest.raises(DuplicateKeyError):
719
+ with diff(old, new, key=("a", "b"), missing_key="null"):
720
+ pass
721
+
722
+ def test_invalid_missing_key_policy_is_rejected(self, write_jsonl):
723
+ # Arrange
724
+ old = write_jsonl("old.jsonl", [{"id": 1}])
725
+ new = write_jsonl("new.jsonl", [{"id": 1}])
726
+
727
+ # Act / Assert
728
+ with pytest.raises(ConfigurationError, match="invalid missing-key policy"):
729
+ with diff(old, new, key="id", missing_key="bogus"):
730
+ pass
731
+
569
732
  def test_duplicate_identity_reports_source_and_physical_lines(self, write_jsonl):
570
733
  # Arrange
571
734
  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