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.
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/PKG-INFO +31 -12
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/README.md +28 -10
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/PKG-INFO +31 -12
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/requires.txt +2 -1
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.py +83 -12
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/pyproject.toml +3 -2
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/tests/test_cli.py +90 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/tests/test_diff.py +165 -2
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/tests/test_sources.py +106 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/LICENSE +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/SOURCES.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/dependency_links.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/entry_points.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/jsonl_diff.egg-info/top_level.txt +0 -0
- {jsonl_diff-0.1.3 → jsonl_diff-0.1.5}/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.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:
|
|
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.
|
|
@@ -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}] [--
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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}] [--
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
+
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:
|
|
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.
|
|
@@ -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}] [--
|
|
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
|
-
*
|
|
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
|
|
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
|
|
307
|
+
support matrix and how sources are managed.
|
|
289
308
|
|
|
290
309
|
## Limitations
|
|
291
310
|
|
|
@@ -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
|
-
|
|
29
|
+
JsonIdentityValue = Union[str, Decimal, bool, int, None, list, dict]
|
|
29
30
|
Number = Union[Decimal, int, float]
|
|
30
|
-
IdentityKey = Tuple[
|
|
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(
|
|
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
|
|
@@ -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
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|