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.
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/PKG-INFO +27 -7
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/README.md +24 -5
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/PKG-INFO +27 -7
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/requires.txt +2 -1
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.py +79 -8
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/pyproject.toml +3 -2
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/tests/test_cli.py +70 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/tests/test_diff.py +121 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/tests/test_sources.py +106 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/LICENSE +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/SOURCES.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/dependency_links.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/entry_points.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/jsonl_diff.egg-info/top_level.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jsonl-diff
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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:
|
|
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
|
|
69
|
-
|
|
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}] [--
|
|
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
|
-
*
|
|
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
|
|
12
|
-
|
|
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}] [--
|
|
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
|
-
*
|
|
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
|
+
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:
|
|
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
|
|
69
|
-
|
|
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}] [--
|
|
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
|
-
*
|
|
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
|
|
|
@@ -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(
|
|
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
|
-
|
|
583
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|