jsonl-diff 0.1.0__py3-none-any.whl
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.
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jsonl-diff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict, disk-backed reconciliation of large JSONL/NDJSON datasets for Python.
|
|
5
|
+
Author-email: rmoralespp <rmoralespp@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Rolando Morales Perez
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Source, https://github.com/rmoralespp/jsonl-diff
|
|
29
|
+
Project-URL: Homepage, https://github.com/rmoralespp/jsonl-diff
|
|
30
|
+
Project-URL: Changelog, https://github.com/rmoralespp/jsonl-diff/blob/main/CHANGELOG.md
|
|
31
|
+
Project-URL: Issues, https://github.com/rmoralespp/jsonl-diff/issues
|
|
32
|
+
Project-URL: Documentation, https://rmoralespp.github.io/jsonl-diff/
|
|
33
|
+
Keywords: jsonlines,ndjson,jsonl,json,gzip,bzip2,xz,zst,utilities,serialization,deserialization,diff,json-diff,cli
|
|
34
|
+
Classifier: Programming Language :: Python
|
|
35
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
43
|
+
Classifier: Intended Audience :: Developers
|
|
44
|
+
Classifier: Intended Audience :: Information Technology
|
|
45
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
46
|
+
Classifier: Topic :: Internet
|
|
47
|
+
Classifier: Topic :: Utilities
|
|
48
|
+
Classifier: Topic :: File Formats :: JSON
|
|
49
|
+
Requires-Python: >=3.8
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
License-File: LICENSE
|
|
52
|
+
Requires-Dist: py-jsonl==1.5.2
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
# jsonl-diff
|
|
56
|
+
|
|
57
|
+
----
|
|
58
|
+
|
|
59
|
+
<p>
|
|
60
|
+
<img src="https://img.shields.io/pypi/v/jsonl-diff.svg" alt="PyPI">
|
|
61
|
+
<img src="https://img.shields.io/pypi/pyversions/jsonl-diff.svg" alt="Python">
|
|
62
|
+
<img src="https://github.com/rmoralespp/jsonl-diff/workflows/CI/badge.svg" alt="CI">
|
|
63
|
+
<img src="https://codecov.io/gh/rmoralespp/jsonl-diff/branch/main/graph/badge.svg" alt="Coverage">
|
|
64
|
+
<img src="https://img.shields.io/github/license/rmoralespp/jsonl-diff.svg" alt="License">
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
`jsonl-diff` performs strict, identity-based reconciliation of JSONL/NDJSON
|
|
68
|
+
datasets. It matches records by one or more top-level fields, then classifies
|
|
69
|
+
each identity as `equal`, `added`, `deleted`, or `modified`.
|
|
70
|
+
|
|
71
|
+
## Why jsonl-diff?
|
|
72
|
+
|
|
73
|
+
A text diff compares lines. That is usually the wrong model for datasets:
|
|
74
|
+
reordering unchanged records creates noise, and inserting one record can make
|
|
75
|
+
every later line appear different.
|
|
76
|
+
|
|
77
|
+
`jsonl-diff` fills the gap between line-oriented tools and in-memory dataframe comparisons:
|
|
78
|
+
|
|
79
|
+
- match records by identity, not physical position;
|
|
80
|
+
- compare datasets larger than available RAM using temporary SQLite storage;
|
|
81
|
+
- preserve original OLD and NEW line numbers for auditability;
|
|
82
|
+
- expose the same behavior through a CLI and a small Python API.
|
|
83
|
+
|
|
84
|
+
Use it for snapshots, ETL validation, migrations, exports, and CI checks. Use
|
|
85
|
+
`diff`, `jq`, or a visual JSON diff instead when you need line-level,
|
|
86
|
+
field-level, or JSON Patch output.
|
|
87
|
+
|
|
88
|
+
The comparison is backed by a temporary SQLite index. Complete inputs and
|
|
89
|
+
complete result sets do not need to fit in memory. Process memory is bounded by
|
|
90
|
+
the largest record plus parser and SQLite buffers, while indexed data lives on
|
|
91
|
+
temporary disk.
|
|
92
|
+
|
|
93
|
+
## Features
|
|
94
|
+
|
|
95
|
+
- Single or composite top-level identities, independent of input order.
|
|
96
|
+
- Configurable duplicate handling with strict failure by default.
|
|
97
|
+
- Exact RFC 6901 object-member ignores.
|
|
98
|
+
- Semantic number comparison without binary floating-point rounding.
|
|
99
|
+
- Deterministic summaries and changed-identity iteration.
|
|
100
|
+
- Original OLD and NEW physical line numbers for every change.
|
|
101
|
+
- Local, HTTP/HTTPS, file-like, and supported compressed sources through
|
|
102
|
+
`py-jsonl`.
|
|
103
|
+
- The same comparison engine through the CLI and Python API.
|
|
104
|
+
- Disk-backed comparison with configurable `jsonl-diff` temporary storage.
|
|
105
|
+
|
|
106
|
+
## Requirements and installation
|
|
107
|
+
|
|
108
|
+
`jsonl-diff` supports Python 3.8 through 3.14.
|
|
109
|
+
|
|
110
|
+
Install it in a project managed by `uv`:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv add jsonl-diff
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Install the CLI as a standalone tool:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv tool install jsonl-diff
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`pip` is also supported:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python -m pip install jsonl-diff
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## CLI quick start
|
|
129
|
+
|
|
130
|
+
Given:
|
|
131
|
+
|
|
132
|
+
```jsonl
|
|
133
|
+
{"id":1,"name":"Ada"}
|
|
134
|
+
{"id":2,"name":"Grace"}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
and a reordered, changed dataset:
|
|
138
|
+
|
|
139
|
+
```jsonl
|
|
140
|
+
{"id":2,"name":"Grace Hopper"}
|
|
141
|
+
{"name":"Ada","id":1}
|
|
142
|
+
{"id":3,"name":"Linus"}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
compare them by `id`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
jsonl-diff old.jsonl new.jsonl --key id
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Standard output contains only the text summary:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
Records:
|
|
155
|
+
equal: 1
|
|
156
|
+
added: 1
|
|
157
|
+
deleted: 0
|
|
158
|
+
modified: 1
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The result means one record is unchanged, one was added, and one existing
|
|
162
|
+
record changed. Physical order does not affect these counts.
|
|
163
|
+
|
|
164
|
+
Diagnostics are written to standard error. Use `--quiet` when only the exit
|
|
165
|
+
status or details file is needed.
|
|
166
|
+
|
|
167
|
+
### Common pipelines
|
|
168
|
+
|
|
169
|
+
Filter JSONL with `jq` before comparing it. `-c` keeps one compact JSON object
|
|
170
|
+
per line:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
jq -c 'select(has("deleted_at") and .deleted_at == null)' old.jsonl |
|
|
174
|
+
jsonl-diff - new.jsonl --key id
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Read compressed input directly when no preprocessing is required:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
jsonl-diff old.jsonl.xz new.jsonl.gz --key id
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
For two independent pipelines on Bash-compatible systems, use process
|
|
184
|
+
substitution:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
jsonl-diff \
|
|
188
|
+
<(head -n 1000 old.jsonl | jq -c 'select(.active)') \
|
|
189
|
+
<(head -n 1000 new.jsonl | jq -c 'select(.active)') \
|
|
190
|
+
--key id
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`-` represents stdin. Both inputs cannot use the same stdin stream.
|
|
194
|
+
|
|
195
|
+
### CLI syntax and options
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
jsonl-diff [-h] --key KEY [--ignore IGNORE]
|
|
199
|
+
[--duplicates {error,first,last}] [--details FILE] [--quiet]
|
|
200
|
+
[--max-temp MAX_TEMP]
|
|
201
|
+
old new
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| Argument | Meaning |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `old` | OLD local path, HTTP/HTTPS source, or `-` for stdin. |
|
|
207
|
+
| `new` | NEW local path, HTTP/HTTPS source, or `-` for stdin. |
|
|
208
|
+
| `--key KEY` | Required top-level identity field. Repeat it or use a comma-separated value for a composite identity. |
|
|
209
|
+
| `--ignore JSON_POINTER` | Exact RFC 6901 pointer to an object member to remove before content comparison. Repeatable. |
|
|
210
|
+
| `--duplicates POLICY` | Handle repeated identities with `error` (default), `first`, or `last`. |
|
|
211
|
+
| `--details FILE` | Write machine-readable JSONL details to `FILE`; details are never written to stdout. |
|
|
212
|
+
| `--quiet` | Suppress the normal stdout summary. Errors still go to stderr. |
|
|
213
|
+
| `--max-temp MAX_TEMP` | Limit storage owned by `jsonl-diff` to a positive integer number of bytes. |
|
|
214
|
+
| `-h`, `--help` | Show command help and exit. |
|
|
215
|
+
|
|
216
|
+
These composite-key forms are equivalent:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
jsonl-diff old.jsonl new.jsonl --key country,customerId,type
|
|
220
|
+
|
|
221
|
+
jsonl-diff old.jsonl new.jsonl \
|
|
222
|
+
--key country \
|
|
223
|
+
--key customerId \
|
|
224
|
+
--key type
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Ignore volatile fields by exact path:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
jsonl-diff old.jsonl new.jsonl \
|
|
231
|
+
--key id \
|
|
232
|
+
--ignore /updated_at \
|
|
233
|
+
--ignore /metadata/request_id
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Exit codes
|
|
237
|
+
|
|
238
|
+
| Code | Meaning |
|
|
239
|
+
|---:|---|
|
|
240
|
+
| `0` | The inputs are equal under the configured rules. |
|
|
241
|
+
| `1` | At least one added, deleted, or modified identity was found. |
|
|
242
|
+
| `2` | A handled input, source, resource, output, or interruption error occurred. |
|
|
243
|
+
| `3` | CLI usage or comparison configuration is invalid. |
|
|
244
|
+
|
|
245
|
+
Invalid argparse usage exits directly with code `3`. The Python API uses
|
|
246
|
+
exceptions instead of these process exit codes.
|
|
247
|
+
|
|
248
|
+
## Details JSONL
|
|
249
|
+
|
|
250
|
+
`--details FILE` streams a deterministic JSONL report through `py-jsonl` after
|
|
251
|
+
both inputs have been completely indexed and validated:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
jsonl-diff old.jsonl new.jsonl --key id --details changes.jsonl
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
For an OLD source containing identities `1` and `2`, and a NEW source
|
|
258
|
+
containing changed identity `2` and added identity `3`, the file is:
|
|
259
|
+
|
|
260
|
+
```jsonl
|
|
261
|
+
{"duplicates":"error","ignore":[],"key":["id"],"type":"meta"}
|
|
262
|
+
{"key":[1],"old_line":1,"op":"deleted","type":"change"}
|
|
263
|
+
{"key":[2],"new_line":1,"old_line":2,"op":"modified","type":"change"}
|
|
264
|
+
{"key":[3],"new_line":2,"op":"added","type":"change"}
|
|
265
|
+
{"added":1,"deleted":1,"equal":0,"modified":1,"type":"summary"}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Record types are:
|
|
269
|
+
|
|
270
|
+
- **`meta`**: always first. `key` contains normalized identity-field names;
|
|
271
|
+
`ignore` contains the configured ignore pointers.
|
|
272
|
+
- **`change`**: one per changed identity. `op` is `added`, `deleted`, or
|
|
273
|
+
`modified`. `old_line` is present for deletions and modifications;
|
|
274
|
+
`new_line` is present for additions and modifications. Equal records are not
|
|
275
|
+
emitted.
|
|
276
|
+
- **`summary`**: always last after a successful write, with all four totals.
|
|
277
|
+
|
|
278
|
+
The normal stdout summary is unchanged when `--details` is used, unless
|
|
279
|
+
`--quiet` suppresses it. A write failure can leave a partial details file and
|
|
280
|
+
returns exit code `2`.
|
|
281
|
+
|
|
282
|
+
## Python API
|
|
283
|
+
|
|
284
|
+
### Comparing sources
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from jsonl_diff import ChangeOperation, diff
|
|
288
|
+
|
|
289
|
+
with diff(
|
|
290
|
+
"old.jsonl.gz",
|
|
291
|
+
"new.jsonl.gz",
|
|
292
|
+
key=("country", "customerId"),
|
|
293
|
+
ignore=("/updated_at",),
|
|
294
|
+
duplicates="error",
|
|
295
|
+
max_temp=2_000_000_000,
|
|
296
|
+
) as result:
|
|
297
|
+
print(result.summary)
|
|
298
|
+
|
|
299
|
+
for change in result.changes(ChangeOperation.MODIFIED):
|
|
300
|
+
print(change.key, change.old_line, change.new_line)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The callable signature is:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from typing import Any, Optional, Sequence, Union
|
|
307
|
+
|
|
308
|
+
from jsonl_diff import DiffResult, DuplicatePolicy
|
|
309
|
+
|
|
310
|
+
def diff(
|
|
311
|
+
old: Any,
|
|
312
|
+
new: Any,
|
|
313
|
+
*,
|
|
314
|
+
key: Union[str, Sequence[str]],
|
|
315
|
+
ignore: Sequence[str] = (),
|
|
316
|
+
duplicates: Union[str, DuplicatePolicy] = DuplicatePolicy.ERROR,
|
|
317
|
+
max_temp: Optional[int] = None,
|
|
318
|
+
) -> DiffResult:
|
|
319
|
+
...
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`diff()` creates a comparison session without opening either source. Entering
|
|
323
|
+
its context fully reads, indexes, and validates both sources before exposing
|
|
324
|
+
the result. `DiffResult.changes()` is then a lazy iterator over the disk-backed
|
|
325
|
+
result rather than a list held in memory.
|
|
326
|
+
|
|
327
|
+
Using `DiffResult` as a context manager is required. It owns the temporary
|
|
328
|
+
resources and removes its private workspace on exit. A result cannot be entered
|
|
329
|
+
more than once. Its summary becomes available after entering and remains
|
|
330
|
+
available after closing; iterating changes requires the context to remain open.
|
|
331
|
+
|
|
332
|
+
### Result models
|
|
333
|
+
|
|
334
|
+
`result.summary` is an immutable `Summary`:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
Summary(equal=10, added=2, deleted=1, modified=3)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
It exposes integer fields `equal`, `added`, `deleted`, and `modified`, plus the
|
|
341
|
+
boolean property `different`. The same values are available directly as
|
|
342
|
+
read-only `DiffResult` properties.
|
|
343
|
+
|
|
344
|
+
Each item from `result.changes()` is an immutable `Change` with:
|
|
345
|
+
|
|
346
|
+
- `operation`: a `ChangeOperation` enum value (`ADDED`, `DELETED`, or
|
|
347
|
+
`MODIFIED`);
|
|
348
|
+
- `key`: a typed tuple in normalized identity-field order;
|
|
349
|
+
- `old` and `new`: optional `SourceLocation` values;
|
|
350
|
+
- `old_line` and `new_line`: convenience properties returning a one-based
|
|
351
|
+
physical line or `None`.
|
|
352
|
+
|
|
353
|
+
For additions, `old`/`old_line` are `None`; for deletions,
|
|
354
|
+
`new`/`new_line` are `None`; modifications have both locations. Location
|
|
355
|
+
sources are labeled `OLD` and `NEW`.
|
|
356
|
+
|
|
357
|
+
Pass a `ChangeOperation` to filter without materializing all changes:
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
with diff("old.jsonl", "new.jsonl", key="id") as result:
|
|
361
|
+
added = result.changes(ChangeOperation.ADDED)
|
|
362
|
+
for change in added:
|
|
363
|
+
print(change.key, change.new_line)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
With numeric keys, API key components are `decimal.Decimal` values. For
|
|
367
|
+
example, JSON identity `7` is returned as `Decimal("7")`; JSON strings and
|
|
368
|
+
booleans retain their types. Details JSONL writes numeric keys as JSON numbers,
|
|
369
|
+
including arbitrary-precision integers.
|
|
370
|
+
|
|
371
|
+
### API errors
|
|
372
|
+
|
|
373
|
+
The public error hierarchy starts with `JsonlDiffError`:
|
|
374
|
+
|
|
375
|
+
- `ConfigurationError`: invalid keys, ignore pointers, or `max_temp`;
|
|
376
|
+
- `InputError`: an invalid source or record; exposes `source` and optional
|
|
377
|
+
`line`;
|
|
378
|
+
- `DuplicateKeyError`: an `InputError` with `key` and the first/repeated
|
|
379
|
+
physical lines in `lines`;
|
|
380
|
+
- `ResourceError`: the temporary index cannot be created, written, or kept
|
|
381
|
+
within its configured limit.
|
|
382
|
+
|
|
383
|
+
Failures during `diff()` clean up the workspace before the exception is
|
|
384
|
+
raised.
|
|
385
|
+
|
|
386
|
+
## Sources and compression
|
|
387
|
+
|
|
388
|
+
Source opening and decompression are delegated to `py-jsonl`:
|
|
389
|
+
|
|
390
|
+
| Source | CLI | Python API |
|
|
391
|
+
|---|:---:|:---:|
|
|
392
|
+
| Local path | Yes | Yes, including path-like values |
|
|
393
|
+
| HTTP/HTTPS URL | Yes | Yes |
|
|
394
|
+
| File-like object | No | Yes |
|
|
395
|
+
| gzip (`.gz`) | Yes | Yes |
|
|
396
|
+
| bzip2 (`.bz2`) | Yes | Yes |
|
|
397
|
+
| xz (`.xz`) | Yes | Yes |
|
|
398
|
+
| Zstandard (`.zst`) | Python 3.14 only | Python 3.14 only |
|
|
399
|
+
| ZIP archive | No | No |
|
|
400
|
+
|
|
401
|
+
Zstandard availability follows `py-jsonl` and its use of Python 3.14's
|
|
402
|
+
standard-library zstd support; it is not supported by this project on earlier
|
|
403
|
+
Python versions. Compression and HTTP transfer boundaries do not affect the
|
|
404
|
+
reported decoded JSONL line numbers.
|
|
405
|
+
|
|
406
|
+
## Comparison semantics
|
|
407
|
+
|
|
408
|
+
### Strict JSONL records
|
|
409
|
+
|
|
410
|
+
- Every physical line must be non-blank, valid JSON containing exactly one
|
|
411
|
+
top-level object.
|
|
412
|
+
- Local paths and binary streams are decoded as UTF-8. Text streams are
|
|
413
|
+
already decoded, while URL response charset handling follows `py-jsonl`
|
|
414
|
+
(defaulting to UTF-8 when no charset is declared).
|
|
415
|
+
- Blank lines, malformed JSON, duplicate object property names, `NaN`,
|
|
416
|
+
`Infinity`, and `-Infinity` are errors.
|
|
417
|
+
- Top-level arrays, strings, numbers, booleans, and `null` are rejected.
|
|
418
|
+
- Input errors identify `OLD` or `NEW` and include the physical line when
|
|
419
|
+
available.
|
|
420
|
+
|
|
421
|
+
No normal summary or details output begins until both inputs pass complete
|
|
422
|
+
input and duplicate validation.
|
|
423
|
+
|
|
424
|
+
### Identity and duplicates
|
|
425
|
+
|
|
426
|
+
Identity fields are top-level object-member names. Each component must exist
|
|
427
|
+
and be a non-null string, number, or boolean; objects and arrays are invalid.
|
|
428
|
+
Nested identity paths and automatic key detection are not supported.
|
|
429
|
+
|
|
430
|
+
Composite identity field names are sorted lexically before their values are
|
|
431
|
+
extracted. Consequently, `key=("b", "a")` and `key=("a", "b")` both produce
|
|
432
|
+
keys in `(a, b)` order and match identically. Repeated or empty identity-field
|
|
433
|
+
names are configuration errors.
|
|
434
|
+
|
|
435
|
+
JSON types remain significant: `"1"` is different from `1`, and `true` is
|
|
436
|
+
different from `1`.
|
|
437
|
+
|
|
438
|
+
Every normalized identity must be unique within OLD and within NEW by default.
|
|
439
|
+
The first duplicate aborts the comparison; `DuplicateKeyError` reports the
|
|
440
|
+
first occurrence and repeated physical line. Set `duplicates="first"` to keep
|
|
441
|
+
the first occurrence or `duplicates="last"` to keep the last occurrence and
|
|
442
|
+
its physical line. These tolerant policies depend on physical input order.
|
|
443
|
+
|
|
444
|
+
### Ignored object members
|
|
445
|
+
|
|
446
|
+
Ignore expressions are exact
|
|
447
|
+
[RFC 6901 JSON Pointers](https://www.rfc-editor.org/rfc/rfc6901). Escapes such
|
|
448
|
+
as `~1` for `/` and `~0` for `~` are supported. An absent path is harmless,
|
|
449
|
+
and `/updated_at` does not match `/metadata/updated_at`.
|
|
450
|
+
|
|
451
|
+
Ignores are applied symmetrically before content canonicalization. A pointer
|
|
452
|
+
may remove an entire array-valued object member, but it may not traverse an
|
|
453
|
+
array or address an array element. Ignoring a top-level identity field is also
|
|
454
|
+
invalid. Wildcards, JSONPath, and recursive name matching are not supported.
|
|
455
|
+
|
|
456
|
+
### Canonical content and numbers
|
|
457
|
+
|
|
458
|
+
Records with the same identity are compared after ignored members are removed:
|
|
459
|
+
|
|
460
|
+
- JSON object property order is insignificant at every depth.
|
|
461
|
+
- Array order is significant.
|
|
462
|
+
- Unicode strings are compared by code-point sequence; no Unicode
|
|
463
|
+
normalization is applied.
|
|
464
|
+
- Numbers are parsed as `Decimal` and compared by mathematical value.
|
|
465
|
+
`1`, `1.0`, and `1e0` are equal, and negative zero equals zero.
|
|
466
|
+
- Arbitrary-precision integers and decimal values are not rounded through
|
|
467
|
+
binary floating point.
|
|
468
|
+
|
|
469
|
+
Canonical content is stored with its length and SHA-256 digest. Matching
|
|
470
|
+
canonical bytes are still compared, so a digest match alone is not treated as
|
|
471
|
+
proof of equality. Numbers are written using compact scientific notation; a
|
|
472
|
+
large exponent does not expand into a large string of zeroes.
|
|
473
|
+
|
|
474
|
+
### Determinism
|
|
475
|
+
|
|
476
|
+
Summary counts do not depend on either source's physical record order.
|
|
477
|
+
Changes, including details records, use the stable lexical byte order of each
|
|
478
|
+
canonical typed identity. This is a reproducibility guarantee, not numeric,
|
|
479
|
+
locale-aware, or human-oriented sorting.
|
|
480
|
+
|
|
481
|
+
## Disk-backed architecture and temporary files
|
|
482
|
+
|
|
483
|
+
Each record is parsed and validated incrementally, normalized, and inserted
|
|
484
|
+
into a private SQLite database under an operating-system temporary directory.
|
|
485
|
+
The database stores the typed canonical identity, original line, canonical
|
|
486
|
+
content, content length, and digest. A uniqueness constraint detects duplicate
|
|
487
|
+
identities. SQL joins calculate the summary, and ordered SQLite cursors drive
|
|
488
|
+
lazy change iteration.
|
|
489
|
+
|
|
490
|
+
This architecture bounds memory by the records currently being processed and
|
|
491
|
+
database buffers; it does not keep the complete decoded inputs or all changes
|
|
492
|
+
in RAM. It does require temporary disk space. Canonical records and SQLite
|
|
493
|
+
indexes remain until the `DiffResult` is closed, so temporary usage can exceed
|
|
494
|
+
twice the combined decompressed input size.
|
|
495
|
+
|
|
496
|
+
`max_temp` / `--max-temp` accepts a positive byte count. The implementation
|
|
497
|
+
limits and checks files in the workspace owned by `jsonl-diff`, raising
|
|
498
|
+
`ResourceError` (CLI exit `2`) when the index cannot stay within that budget.
|
|
499
|
+
Choose a limit with room for SQLite pages and index overhead.
|
|
500
|
+
|
|
501
|
+
`py-jsonl` may create its own temporary staging files for remote or compressed
|
|
502
|
+
sources. Those files follow `py-jsonl`'s resource policy and are not counted by
|
|
503
|
+
`jsonl-diff`'s `max_temp` limit. Total system temporary usage can therefore
|
|
504
|
+
exceed the configured value.
|
|
505
|
+
|
|
506
|
+
The private workspace is removed on context-manager exit, explicit `close()`,
|
|
507
|
+
or a handled failure during construction. Cleanup failures are emitted as
|
|
508
|
+
warnings rather than replacing the primary error.
|
|
509
|
+
|
|
510
|
+
## Limitations and non-goals
|
|
511
|
+
|
|
512
|
+
`jsonl-diff` intentionally does not provide:
|
|
513
|
+
|
|
514
|
+
- line- or physical-order comparison;
|
|
515
|
+
- equal-record events or complete record payloads in details output;
|
|
516
|
+
- field-level diffs, JSON Patch, move detection, or rename detection;
|
|
517
|
+
- nested identities, fuzzy matching, or key autodetection;
|
|
518
|
+
- numeric tolerances, unordered-array comparison, or Unicode normalization;
|
|
519
|
+
- wildcard/JSONPath ignores or ignores that traverse arrays;
|
|
520
|
+
- schema validation or repair of malformed input;
|
|
521
|
+
- ZIP input, database-table input, cloud-provider SDK integrations, GUI, or
|
|
522
|
+
HTML reports.
|
|
523
|
+
|
|
524
|
+
It is a reconciliation tool for strict JSONL datasets, not a general-purpose
|
|
525
|
+
visual JSON diff.
|
|
526
|
+
|
|
527
|
+
## Development
|
|
528
|
+
|
|
529
|
+
Clone the repository and sync the test and lint dependency groups with `uv`:
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
uv sync --group test --group lint
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Run the test suite and Ruff:
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
uv run pytest
|
|
539
|
+
uv run ruff check --quiet --output-format=concise .
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
The tests cover CLI behavior, identity and canonicalization rules, strict
|
|
543
|
+
validation, details output, source types, compression, temporary limits, and
|
|
544
|
+
resource cleanup.
|
|
545
|
+
|
|
546
|
+
## License
|
|
547
|
+
|
|
548
|
+
See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
jsonl_diff.py,sha256=iupZ9h0SIG8gBXy3zjabQznv6HsVaE1q2FDKs00Zb0s,24649
|
|
2
|
+
jsonl_diff-0.1.0.dist-info/licenses/LICENSE,sha256=awYAatdjruzs9zNPe2GuP-Ozwb4gCm6FKt0BIY4DkmQ,1078
|
|
3
|
+
jsonl_diff-0.1.0.dist-info/METADATA,sha256=dwyZrMBhSJQW8xvkZ46vUrOT6taXKKXmcwobsK7rx7U,19991
|
|
4
|
+
jsonl_diff-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
jsonl_diff-0.1.0.dist-info/entry_points.txt,sha256=cRg5gUGYOAC64jrkvuo9GCEx66fwmYTtogwa73PT9Bw,47
|
|
6
|
+
jsonl_diff-0.1.0.dist-info/top_level.txt,sha256=xdbZTzW96-R8MmntMHqF1hSYJjsxSHU3xNdvVPlJiKo,11
|
|
7
|
+
jsonl_diff-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rolando Morales Perez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jsonl_diff
|
jsonl_diff.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""Strict, disk-backed reconciliation of JSON Lines datasets."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Iterable, Iterator, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
import jsonl
|
|
19
|
+
|
|
20
|
+
Scalar = Union[str, Decimal, int, float, bool]
|
|
21
|
+
IdentityKey = Tuple[Union[str, Decimal, bool], ...]
|
|
22
|
+
|
|
23
|
+
_MISSING = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JsonlDiffError(Exception):
|
|
27
|
+
"""Base exception for jsonl-diff failures."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigurationError(JsonlDiffError, ValueError):
|
|
31
|
+
"""Raised when comparison options are invalid."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InputError(JsonlDiffError):
|
|
35
|
+
"""Raised when an input source or record is invalid."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, source: str, line: Optional[int] = None):
|
|
38
|
+
self.source = source
|
|
39
|
+
self.line = line
|
|
40
|
+
location = " in {}".format(source)
|
|
41
|
+
if line is not None:
|
|
42
|
+
location += " at line {}".format(line)
|
|
43
|
+
super().__init__("{}{}".format(message, location))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DuplicateKeyError(InputError):
|
|
47
|
+
"""Raised when an identity occurs more than once in one source."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, key: IdentityKey, source: str, lines: Sequence[int]):
|
|
50
|
+
self.key = key
|
|
51
|
+
self.lines = tuple(lines)
|
|
52
|
+
|
|
53
|
+
message = "duplicate key {} on lines {}".format(
|
|
54
|
+
_canonical_text(list(key), ensure_ascii=True),
|
|
55
|
+
", ".join(str(line) for line in lines),
|
|
56
|
+
)
|
|
57
|
+
super().__init__(message, source)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ResourceError(JsonlDiffError):
|
|
61
|
+
"""Raised when a resource limit prevents comparison."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ChangeOperation(str, Enum):
|
|
65
|
+
"""A changed record classification."""
|
|
66
|
+
|
|
67
|
+
ADDED = "added"
|
|
68
|
+
DELETED = "deleted"
|
|
69
|
+
MODIFIED = "modified"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DuplicatePolicy(str, Enum):
|
|
73
|
+
"""How duplicate identities are handled."""
|
|
74
|
+
|
|
75
|
+
ERROR = "error"
|
|
76
|
+
FIRST = "first"
|
|
77
|
+
LAST = "last"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SourceLocation:
|
|
82
|
+
"""A physical source location."""
|
|
83
|
+
|
|
84
|
+
source: str
|
|
85
|
+
line: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Summary:
|
|
90
|
+
"""Comparison totals."""
|
|
91
|
+
|
|
92
|
+
equal: int
|
|
93
|
+
added: int
|
|
94
|
+
deleted: int
|
|
95
|
+
modified: int
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def different(self) -> bool:
|
|
99
|
+
"""Return whether any changed records were found."""
|
|
100
|
+
return bool(self.added or self.deleted or self.modified)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class DiffConfig:
|
|
105
|
+
"""Normalized comparison configuration."""
|
|
106
|
+
|
|
107
|
+
key: Tuple[str, ...]
|
|
108
|
+
ignore: Tuple[str, ...] = ()
|
|
109
|
+
duplicates: DuplicatePolicy = DuplicatePolicy.ERROR
|
|
110
|
+
max_temp: Optional[int] = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Change:
|
|
115
|
+
"""One added, deleted, or modified identity."""
|
|
116
|
+
|
|
117
|
+
operation: ChangeOperation
|
|
118
|
+
key: IdentityKey
|
|
119
|
+
old: Optional[SourceLocation]
|
|
120
|
+
new: Optional[SourceLocation]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def old_line(self) -> Optional[int]:
|
|
124
|
+
"""Return the OLD physical line, when present."""
|
|
125
|
+
return None if self.old is None else self.old.line
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def new_line(self) -> Optional[int]:
|
|
129
|
+
"""Return the NEW physical line, when present."""
|
|
130
|
+
return None if self.new is None else self.new.line
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _reject_constant(value: str) -> None:
|
|
134
|
+
raise ValueError("invalid JSON number {!r}".format(value))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _object(pairs: Iterable[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
138
|
+
result = {}
|
|
139
|
+
for name, value in pairs:
|
|
140
|
+
if name in result:
|
|
141
|
+
raise ValueError("duplicate property {!r}".format(name))
|
|
142
|
+
result[name] = value
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _decode_json(value: str) -> Any:
|
|
147
|
+
return json.loads(
|
|
148
|
+
value,
|
|
149
|
+
parse_int=Decimal,
|
|
150
|
+
parse_float=Decimal,
|
|
151
|
+
parse_constant=_reject_constant,
|
|
152
|
+
object_pairs_hook=_object,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _number(value: Scalar) -> str:
|
|
157
|
+
if isinstance(value, float):
|
|
158
|
+
if not math.isfinite(value):
|
|
159
|
+
raise ValueError("non-finite numbers are not valid JSON")
|
|
160
|
+
value = Decimal(str(value))
|
|
161
|
+
elif isinstance(value, int):
|
|
162
|
+
value = Decimal(value)
|
|
163
|
+
if not isinstance(value, Decimal) or not value.is_finite():
|
|
164
|
+
raise ValueError("non-finite numbers are not valid JSON")
|
|
165
|
+
if value.is_zero():
|
|
166
|
+
return "0"
|
|
167
|
+
|
|
168
|
+
sign, digits, exponent = value.as_tuple()
|
|
169
|
+
trailing_zeros = 0
|
|
170
|
+
while len(digits) - trailing_zeros > 1 and digits[-trailing_zeros - 1] == 0:
|
|
171
|
+
trailing_zeros += 1
|
|
172
|
+
|
|
173
|
+
digits = digits[:-trailing_zeros] if trailing_zeros else digits
|
|
174
|
+
exponent += trailing_zeros
|
|
175
|
+
mantissa = str(digits[0])
|
|
176
|
+
if len(digits) > 1:
|
|
177
|
+
mantissa += "." + "".join(str(digit) for digit in digits[1:])
|
|
178
|
+
|
|
179
|
+
scientific_exponent = exponent + len(digits) - 1
|
|
180
|
+
return "{}{}e{:+d}".format("-" if sign else "", mantissa, scientific_exponent)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _canonical_text(value: Any, ensure_ascii: bool = False) -> str:
|
|
184
|
+
if value is None:
|
|
185
|
+
return "null"
|
|
186
|
+
if value is True:
|
|
187
|
+
return "true"
|
|
188
|
+
if value is False:
|
|
189
|
+
return "false"
|
|
190
|
+
if isinstance(value, (Decimal, int, float)):
|
|
191
|
+
return _number(value)
|
|
192
|
+
if isinstance(value, str):
|
|
193
|
+
return json.dumps(value, ensure_ascii=ensure_ascii)
|
|
194
|
+
if isinstance(value, (list, tuple)):
|
|
195
|
+
return "[{}]".format(",".join(_canonical_text(item, ensure_ascii) for item in value))
|
|
196
|
+
if isinstance(value, dict):
|
|
197
|
+
if any(not isinstance(name, str) for name in value):
|
|
198
|
+
raise ValueError("JSON object property names must be strings")
|
|
199
|
+
members = (
|
|
200
|
+
"{}:{}".format(
|
|
201
|
+
json.dumps(name, ensure_ascii=ensure_ascii),
|
|
202
|
+
_canonical_text(value[name], ensure_ascii),
|
|
203
|
+
)
|
|
204
|
+
for name in sorted(value)
|
|
205
|
+
)
|
|
206
|
+
return "{{{}}}".format(",".join(members))
|
|
207
|
+
raise ValueError("unsupported JSON value type: {}".format(type(value).__name__))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _canonical(value: Any) -> bytes:
|
|
211
|
+
return _canonical_text(value).encode("utf-8")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _fingerprint(canonical: bytes) -> Tuple[int, bytes]:
|
|
215
|
+
return len(canonical), hashlib.sha256(canonical).digest()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_pointer(pointer: str) -> Tuple[str, ...]:
|
|
219
|
+
if not pointer.startswith("/") or pointer == "/":
|
|
220
|
+
if pointer == "/":
|
|
221
|
+
return ("",)
|
|
222
|
+
raise ConfigurationError("ignore paths must be non-empty RFC 6901 JSON Pointers")
|
|
223
|
+
tokens = []
|
|
224
|
+
for token in pointer[1:].split("/"):
|
|
225
|
+
index = 0
|
|
226
|
+
decoded = []
|
|
227
|
+
while index < len(token):
|
|
228
|
+
if token[index] != "~":
|
|
229
|
+
decoded.append(token[index])
|
|
230
|
+
index += 1
|
|
231
|
+
elif index + 1 >= len(token) or token[index + 1] not in "01":
|
|
232
|
+
raise ConfigurationError("invalid JSON Pointer escape in {!r}".format(pointer))
|
|
233
|
+
else:
|
|
234
|
+
decoded.append("/" if token[index + 1] == "1" else "~")
|
|
235
|
+
index += 2
|
|
236
|
+
tokens.append("".join(decoded))
|
|
237
|
+
return tuple(tokens)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _ignore_tree(paths: Sequence[str], keys: Sequence[str]) -> Dict[str, Any]:
|
|
241
|
+
tree = {}
|
|
242
|
+
for pointer in paths:
|
|
243
|
+
tokens = _parse_pointer(pointer)
|
|
244
|
+
if len(tokens) == 1 and tokens[0] in keys:
|
|
245
|
+
raise ConfigurationError("identity field {!r} cannot be ignored".format(tokens[0]))
|
|
246
|
+
node = tree
|
|
247
|
+
for token in tokens:
|
|
248
|
+
node = node.setdefault(token, {})
|
|
249
|
+
node[_MISSING] = True
|
|
250
|
+
return tree
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _remove_ignored(value: Any, tree: Dict[str, Any]) -> Any:
|
|
254
|
+
if not tree:
|
|
255
|
+
return value
|
|
256
|
+
if isinstance(value, list):
|
|
257
|
+
raise ValueError("ignore paths may not traverse arrays")
|
|
258
|
+
if not isinstance(value, dict):
|
|
259
|
+
return value
|
|
260
|
+
result = {}
|
|
261
|
+
for name, item in value.items():
|
|
262
|
+
child = tree.get(name)
|
|
263
|
+
if child is None:
|
|
264
|
+
result[name] = item
|
|
265
|
+
elif _MISSING not in child:
|
|
266
|
+
result[name] = _remove_ignored(item, child)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _identity(record: Dict[str, Any], keys: Sequence[str]) -> IdentityKey:
|
|
271
|
+
values = []
|
|
272
|
+
for name in keys:
|
|
273
|
+
if name not in record:
|
|
274
|
+
raise ValueError("missing identity field {!r}".format(name))
|
|
275
|
+
value = record[name]
|
|
276
|
+
if value is None or not isinstance(value, (str, Decimal, int, float, bool)):
|
|
277
|
+
raise ValueError("identity field {!r} must be a non-null scalar".format(name))
|
|
278
|
+
values.append(value)
|
|
279
|
+
return tuple(values)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _records(source: Any, on_error: Any) -> Iterator[Any]:
|
|
283
|
+
yield from jsonl.load(
|
|
284
|
+
source,
|
|
285
|
+
parse_int=Decimal,
|
|
286
|
+
parse_float=Decimal,
|
|
287
|
+
parse_constant=_reject_constant,
|
|
288
|
+
object_pairs_hook=_object,
|
|
289
|
+
_on_error=on_error,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class DiffResult:
|
|
294
|
+
"""A context-managed, disk-backed comparison result."""
|
|
295
|
+
|
|
296
|
+
def __init__(self, old: Any, new: Any, config: DiffConfig):
|
|
297
|
+
self._old = old
|
|
298
|
+
self._new = new
|
|
299
|
+
self.config = config
|
|
300
|
+
self._ignore_tree = _ignore_tree(config.ignore, config.key)
|
|
301
|
+
self._workspace = None
|
|
302
|
+
self._connection = None
|
|
303
|
+
self._summary_value = None
|
|
304
|
+
self._started = False
|
|
305
|
+
|
|
306
|
+
def _open(self) -> None:
|
|
307
|
+
try:
|
|
308
|
+
self._workspace = tempfile.TemporaryDirectory(prefix="jsonl-diff-")
|
|
309
|
+
self._connection = sqlite3.connect(os.path.join(self._workspace.name, "index.sqlite3"))
|
|
310
|
+
self._configure_database()
|
|
311
|
+
self._create_schema()
|
|
312
|
+
except (OSError, sqlite3.Error) as error:
|
|
313
|
+
self.close()
|
|
314
|
+
raise ResourceError("could not create the temporary index") from error
|
|
315
|
+
|
|
316
|
+
def _configure_database(self) -> None:
|
|
317
|
+
self._connection.execute("PRAGMA journal_mode = OFF")
|
|
318
|
+
self._connection.execute("PRAGMA temp_store = FILE")
|
|
319
|
+
if self.config.max_temp is not None:
|
|
320
|
+
pages = max(1, self.config.max_temp // 4096)
|
|
321
|
+
self._connection.execute("PRAGMA page_size = 4096")
|
|
322
|
+
self._connection.execute("PRAGMA max_page_count = {}".format(pages))
|
|
323
|
+
|
|
324
|
+
def _create_schema(self) -> None:
|
|
325
|
+
self._connection.execute(
|
|
326
|
+
"""
|
|
327
|
+
CREATE TABLE records (
|
|
328
|
+
side INTEGER NOT NULL,
|
|
329
|
+
identity BLOB NOT NULL,
|
|
330
|
+
line INTEGER NOT NULL,
|
|
331
|
+
length INTEGER NOT NULL,
|
|
332
|
+
digest BLOB NOT NULL,
|
|
333
|
+
canonical BLOB NOT NULL,
|
|
334
|
+
PRIMARY KEY (side, identity)
|
|
335
|
+
) WITHOUT ROWID
|
|
336
|
+
""",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _require_summary(self) -> Summary:
|
|
340
|
+
if self._summary_value is None:
|
|
341
|
+
raise RuntimeError("diff() must be used as a context manager")
|
|
342
|
+
return self._summary_value
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def summary(self) -> Summary:
|
|
346
|
+
"""Return comparison totals after the context has been entered."""
|
|
347
|
+
return self._require_summary()
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def equal(self) -> int:
|
|
351
|
+
return self._require_summary().equal
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def added(self) -> int:
|
|
355
|
+
return self._require_summary().added
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def deleted(self) -> int:
|
|
359
|
+
return self._require_summary().deleted
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def modified(self) -> int:
|
|
363
|
+
return self._require_summary().modified
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def different(self) -> bool:
|
|
367
|
+
return self._require_summary().different
|
|
368
|
+
|
|
369
|
+
def changes(self, operation: Optional[ChangeOperation] = None) -> Iterator[Change]:
|
|
370
|
+
"""Iterate changed identities in deterministic order."""
|
|
371
|
+
if self._connection is None:
|
|
372
|
+
raise RuntimeError("the diff result is closed")
|
|
373
|
+
requested = None if operation is None else ChangeOperation(operation)
|
|
374
|
+
query = """
|
|
375
|
+
SELECT identity, line, length, digest, canonical
|
|
376
|
+
FROM records
|
|
377
|
+
WHERE side = ?
|
|
378
|
+
ORDER BY identity
|
|
379
|
+
"""
|
|
380
|
+
old_records = iter(self._connection.execute(query, (0,)))
|
|
381
|
+
new_records = iter(self._connection.execute(query, (1,)))
|
|
382
|
+
old_record = next(old_records, None)
|
|
383
|
+
new_record = next(new_records, None)
|
|
384
|
+
while old_record is not None or new_record is not None:
|
|
385
|
+
comparison = (
|
|
386
|
+
1
|
|
387
|
+
if old_record is None
|
|
388
|
+
else -1
|
|
389
|
+
if new_record is None
|
|
390
|
+
else (old_record[0] > new_record[0]) - (old_record[0] < new_record[0])
|
|
391
|
+
)
|
|
392
|
+
if comparison < 0:
|
|
393
|
+
change = self._change(ChangeOperation.DELETED, old_record, None)
|
|
394
|
+
old_record = next(old_records, None)
|
|
395
|
+
elif comparison > 0:
|
|
396
|
+
change = self._change(ChangeOperation.ADDED, None, new_record)
|
|
397
|
+
new_record = next(new_records, None)
|
|
398
|
+
else:
|
|
399
|
+
old_signature = old_record[2:]
|
|
400
|
+
new_signature = new_record[2:]
|
|
401
|
+
change = (
|
|
402
|
+
None
|
|
403
|
+
if old_signature == new_signature
|
|
404
|
+
else self._change(ChangeOperation.MODIFIED, old_record, new_record)
|
|
405
|
+
)
|
|
406
|
+
old_record = next(old_records, None)
|
|
407
|
+
new_record = next(new_records, None)
|
|
408
|
+
if change is not None and (requested is None or change.operation == requested):
|
|
409
|
+
yield change
|
|
410
|
+
|
|
411
|
+
def _change(
|
|
412
|
+
self,
|
|
413
|
+
operation: ChangeOperation,
|
|
414
|
+
old_record: Optional[Tuple[Any, ...]],
|
|
415
|
+
new_record: Optional[Tuple[Any, ...]],
|
|
416
|
+
) -> Change:
|
|
417
|
+
record = old_record or new_record
|
|
418
|
+
key = tuple(_decode_json(record[0].decode("utf-8")))
|
|
419
|
+
old_line = None if old_record is None else old_record[1]
|
|
420
|
+
new_line = None if new_record is None else new_record[1]
|
|
421
|
+
return Change(
|
|
422
|
+
operation,
|
|
423
|
+
key,
|
|
424
|
+
None if old_line is None else SourceLocation("OLD", old_line),
|
|
425
|
+
None if new_line is None else SourceLocation("NEW", new_line),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def close(self) -> None:
|
|
429
|
+
"""Release database and temporary files."""
|
|
430
|
+
if self._connection is not None:
|
|
431
|
+
try:
|
|
432
|
+
self._connection.close()
|
|
433
|
+
except sqlite3.Error as error:
|
|
434
|
+
warnings.warn(
|
|
435
|
+
"could not close temporary database: {}".format(error),
|
|
436
|
+
stacklevel=2,
|
|
437
|
+
)
|
|
438
|
+
self._connection = None
|
|
439
|
+
if self._workspace is not None:
|
|
440
|
+
try:
|
|
441
|
+
self._workspace.cleanup()
|
|
442
|
+
except OSError as error:
|
|
443
|
+
warnings.warn(
|
|
444
|
+
"could not remove temporary files: {}".format(error),
|
|
445
|
+
stacklevel=2,
|
|
446
|
+
)
|
|
447
|
+
self._workspace = None
|
|
448
|
+
|
|
449
|
+
def __enter__(self) -> "DiffResult":
|
|
450
|
+
if self._started:
|
|
451
|
+
raise RuntimeError("the diff result cannot be entered more than once")
|
|
452
|
+
self._started = True
|
|
453
|
+
try:
|
|
454
|
+
self._open()
|
|
455
|
+
self._index(self._old, 0)
|
|
456
|
+
self._index(self._new, 1)
|
|
457
|
+
self._summary_value = self._calculate_summary()
|
|
458
|
+
return self
|
|
459
|
+
except BaseException:
|
|
460
|
+
self.close()
|
|
461
|
+
raise
|
|
462
|
+
|
|
463
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
464
|
+
self.close()
|
|
465
|
+
|
|
466
|
+
def _insert_records(self, records: Iterable[Any], side: int, source: str) -> None:
|
|
467
|
+
cursor = self._connection.cursor()
|
|
468
|
+
insert = {
|
|
469
|
+
DuplicatePolicy.ERROR: "INSERT",
|
|
470
|
+
DuplicatePolicy.FIRST: "INSERT OR IGNORE",
|
|
471
|
+
DuplicatePolicy.LAST: "INSERT OR REPLACE",
|
|
472
|
+
}[self.config.duplicates]
|
|
473
|
+
for line, record in enumerate(records, start=1):
|
|
474
|
+
if not isinstance(record, dict):
|
|
475
|
+
raise InputError("each record must be a JSON object", source, line)
|
|
476
|
+
try:
|
|
477
|
+
key = _identity(record, self.config.key)
|
|
478
|
+
normalized = _remove_ignored(record, self._ignore_tree)
|
|
479
|
+
canonical = _canonical(normalized)
|
|
480
|
+
identity = _canonical(list(key))
|
|
481
|
+
length, digest = _fingerprint(canonical)
|
|
482
|
+
except (TypeError, ValueError) as error:
|
|
483
|
+
raise InputError(str(error), source, line) from error
|
|
484
|
+
try:
|
|
485
|
+
cursor.execute(
|
|
486
|
+
"{} INTO records VALUES (?, ?, ?, ?, ?, ?)".format(insert),
|
|
487
|
+
(
|
|
488
|
+
side,
|
|
489
|
+
identity,
|
|
490
|
+
line,
|
|
491
|
+
length,
|
|
492
|
+
digest,
|
|
493
|
+
canonical,
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
except sqlite3.IntegrityError as error:
|
|
497
|
+
first = cursor.execute(
|
|
498
|
+
"SELECT line FROM records WHERE side = ? AND identity = ?",
|
|
499
|
+
(side, identity),
|
|
500
|
+
).fetchone()[0]
|
|
501
|
+
raise DuplicateKeyError(key, source, (first, line)) from error
|
|
502
|
+
except sqlite3.DatabaseError as error:
|
|
503
|
+
raise ResourceError("could not write the temporary index") from error
|
|
504
|
+
if self.config.max_temp is not None:
|
|
505
|
+
self._check_size()
|
|
506
|
+
self._check_size()
|
|
507
|
+
|
|
508
|
+
def _index(self, source: Any, side: int) -> None:
|
|
509
|
+
name = "OLD" if side == 0 else "NEW"
|
|
510
|
+
error_line = [None]
|
|
511
|
+
|
|
512
|
+
def on_error(line: int, error: Exception) -> None:
|
|
513
|
+
error_line[0] = line
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
with self._connection:
|
|
517
|
+
self._insert_records(_records(source, on_error), side, name)
|
|
518
|
+
except JsonlDiffError:
|
|
519
|
+
raise
|
|
520
|
+
except (OSError, EOFError, ValueError, RuntimeError) as error:
|
|
521
|
+
line = error_line[0]
|
|
522
|
+
message = "invalid input ({})".format(type(error).__name__)
|
|
523
|
+
raise InputError(message, name, line) from error
|
|
524
|
+
except sqlite3.DatabaseError as error:
|
|
525
|
+
raise ResourceError("could not build the temporary index") from error
|
|
526
|
+
|
|
527
|
+
def _check_size(self) -> None:
|
|
528
|
+
if self.config.max_temp is None:
|
|
529
|
+
return
|
|
530
|
+
usage = sum(
|
|
531
|
+
entry.stat().st_size
|
|
532
|
+
for entry in Path(self._workspace.name).iterdir()
|
|
533
|
+
if entry.is_file()
|
|
534
|
+
)
|
|
535
|
+
if usage > self.config.max_temp:
|
|
536
|
+
raise ResourceError(
|
|
537
|
+
"temporary storage exceeded {}".format(self.config.max_temp),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _calculate_summary(self) -> Summary:
|
|
541
|
+
row = self._connection.execute(
|
|
542
|
+
"""
|
|
543
|
+
SELECT
|
|
544
|
+
(SELECT COUNT(*)
|
|
545
|
+
FROM records AS o JOIN records AS n USING (identity)
|
|
546
|
+
WHERE o.side = 0 AND n.side = 1
|
|
547
|
+
AND o.length = n.length
|
|
548
|
+
AND o.digest = n.digest
|
|
549
|
+
AND o.canonical = n.canonical),
|
|
550
|
+
(SELECT COUNT(*)
|
|
551
|
+
FROM records AS n
|
|
552
|
+
WHERE n.side = 1 AND NOT EXISTS (
|
|
553
|
+
SELECT 1 FROM records AS o
|
|
554
|
+
WHERE o.side = 0 AND o.identity = n.identity
|
|
555
|
+
)),
|
|
556
|
+
(SELECT COUNT(*)
|
|
557
|
+
FROM records AS o
|
|
558
|
+
WHERE o.side = 0 AND NOT EXISTS (
|
|
559
|
+
SELECT 1 FROM records AS n
|
|
560
|
+
WHERE n.side = 1 AND n.identity = o.identity
|
|
561
|
+
)),
|
|
562
|
+
(SELECT COUNT(*)
|
|
563
|
+
FROM records AS o JOIN records AS n USING (identity)
|
|
564
|
+
WHERE o.side = 0 AND n.side = 1
|
|
565
|
+
AND (
|
|
566
|
+
o.length != n.length
|
|
567
|
+
OR o.digest != n.digest
|
|
568
|
+
OR o.canonical != n.canonical
|
|
569
|
+
))
|
|
570
|
+
""",
|
|
571
|
+
).fetchone()
|
|
572
|
+
return Summary(*(int(value or 0) for value in row))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _configuration(
|
|
576
|
+
key: Union[str, Sequence[str]],
|
|
577
|
+
ignore: Sequence[str],
|
|
578
|
+
duplicates: Union[str, DuplicatePolicy],
|
|
579
|
+
max_temp: Optional[int],
|
|
580
|
+
) -> DiffConfig:
|
|
581
|
+
keys = (key,) if isinstance(key, str) else tuple(key)
|
|
582
|
+
if not keys or any(not isinstance(name, str) or not name for name in keys):
|
|
583
|
+
raise ConfigurationError("at least one non-empty identity field is required")
|
|
584
|
+
if len(set(keys)) != len(keys):
|
|
585
|
+
raise ConfigurationError("identity fields must not be repeated")
|
|
586
|
+
keys = tuple(sorted(keys))
|
|
587
|
+
ignores = tuple(dict.fromkeys(ignore))
|
|
588
|
+
_ignore_tree(ignores, keys)
|
|
589
|
+
try:
|
|
590
|
+
duplicate_policy = DuplicatePolicy(duplicates)
|
|
591
|
+
except ValueError as error:
|
|
592
|
+
raise ConfigurationError("invalid duplicate policy {!r}".format(duplicates)) from error
|
|
593
|
+
if (
|
|
594
|
+
max_temp is not None
|
|
595
|
+
and (not isinstance(max_temp, int) or isinstance(max_temp, bool) or max_temp <= 0)
|
|
596
|
+
):
|
|
597
|
+
raise ConfigurationError("max_temp must be a positive integer")
|
|
598
|
+
return DiffConfig(keys, ignores, duplicate_policy, max_temp)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def diff(
|
|
602
|
+
old: Any,
|
|
603
|
+
new: Any,
|
|
604
|
+
*,
|
|
605
|
+
key: Union[str, Sequence[str]],
|
|
606
|
+
ignore: Sequence[str] = (),
|
|
607
|
+
duplicates: Union[str, DuplicatePolicy] = DuplicatePolicy.ERROR,
|
|
608
|
+
max_temp: Optional[int] = None,
|
|
609
|
+
) -> DiffResult:
|
|
610
|
+
"""Create a context-managed, disk-backed comparison."""
|
|
611
|
+
config = _configuration(key, ignore, duplicates, max_temp)
|
|
612
|
+
return DiffResult(old, new, config)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _change_dict(change: Change) -> Dict[str, Any]:
|
|
616
|
+
result = {
|
|
617
|
+
"type": "change",
|
|
618
|
+
"op": change.operation.value,
|
|
619
|
+
"key": list(change.key),
|
|
620
|
+
}
|
|
621
|
+
if change.old_line is not None:
|
|
622
|
+
result["old_line"] = change.old_line
|
|
623
|
+
if change.new_line is not None:
|
|
624
|
+
result["new_line"] = change.new_line
|
|
625
|
+
return result
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _write_text(result: DiffResult) -> None:
|
|
629
|
+
summary = result.summary
|
|
630
|
+
print("Records:")
|
|
631
|
+
print(" equal: {:,}".format(summary.equal))
|
|
632
|
+
print(" added: {:,}".format(summary.added))
|
|
633
|
+
print(" deleted: {:,}".format(summary.deleted))
|
|
634
|
+
print(" modified: {:,}".format(summary.modified))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _write_details(result: DiffResult, path: Union[str, os.PathLike]) -> None:
|
|
638
|
+
def events() -> Iterator[Dict[str, Any]]:
|
|
639
|
+
yield {
|
|
640
|
+
"type": "meta",
|
|
641
|
+
"key": list(result.config.key),
|
|
642
|
+
"ignore": list(result.config.ignore),
|
|
643
|
+
"duplicates": result.config.duplicates.value,
|
|
644
|
+
}
|
|
645
|
+
for change in result.changes():
|
|
646
|
+
yield _change_dict(change)
|
|
647
|
+
yield {
|
|
648
|
+
"type": "summary",
|
|
649
|
+
"equal": result.equal,
|
|
650
|
+
"added": result.added,
|
|
651
|
+
"deleted": result.deleted,
|
|
652
|
+
"modified": result.modified,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
jsonl.dump(events(), path, cls=_canonical_text)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
659
|
+
def error(self, message: str) -> None:
|
|
660
|
+
self.print_usage(sys.stderr)
|
|
661
|
+
self.exit(3, "{}: error: {}\n".format(self.prog, message))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _parser() -> argparse.ArgumentParser:
|
|
665
|
+
parser = _ArgumentParser(prog="jsonl-diff")
|
|
666
|
+
parser.add_argument("old")
|
|
667
|
+
parser.add_argument("new")
|
|
668
|
+
parser.add_argument("--key", action="append", required=True)
|
|
669
|
+
parser.add_argument("--ignore", action="append", default=[])
|
|
670
|
+
parser.add_argument(
|
|
671
|
+
"--duplicates",
|
|
672
|
+
choices=tuple(policy.value for policy in DuplicatePolicy),
|
|
673
|
+
default=DuplicatePolicy.ERROR.value,
|
|
674
|
+
)
|
|
675
|
+
parser.add_argument("--details", metavar="FILE")
|
|
676
|
+
parser.add_argument("--quiet", action="store_true")
|
|
677
|
+
parser.add_argument("--max-temp", type=int)
|
|
678
|
+
return parser
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _run_comparison(arguments: argparse.Namespace, old: Any, new: Any, keys: Tuple[str, ...]) -> int:
|
|
682
|
+
with diff(
|
|
683
|
+
old,
|
|
684
|
+
new,
|
|
685
|
+
key=keys,
|
|
686
|
+
ignore=arguments.ignore,
|
|
687
|
+
duplicates=arguments.duplicates,
|
|
688
|
+
max_temp=arguments.max_temp,
|
|
689
|
+
) as result:
|
|
690
|
+
if arguments.details:
|
|
691
|
+
_write_details(result, arguments.details)
|
|
692
|
+
if not arguments.quiet:
|
|
693
|
+
_write_text(result)
|
|
694
|
+
sys.stdout.flush()
|
|
695
|
+
return 1 if result.different else 0
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
699
|
+
"""Run the jsonl-diff command-line interface."""
|
|
700
|
+
parser = _parser()
|
|
701
|
+
arguments = parser.parse_args(argv)
|
|
702
|
+
if arguments.old == "-" and arguments.new == "-":
|
|
703
|
+
parser.error("OLD and NEW cannot both read from stdin")
|
|
704
|
+
old = sys.stdin.buffer if arguments.old == "-" else arguments.old
|
|
705
|
+
new = sys.stdin.buffer if arguments.new == "-" else arguments.new
|
|
706
|
+
keys = tuple(name for item in arguments.key for name in item.split(","))
|
|
707
|
+
try:
|
|
708
|
+
return _run_comparison(arguments, old, new, keys)
|
|
709
|
+
except ConfigurationError as error:
|
|
710
|
+
parser.error(str(error))
|
|
711
|
+
except (InputError, ResourceError) as error:
|
|
712
|
+
print("ERROR: {}".format(error), file=sys.stderr)
|
|
713
|
+
return 2
|
|
714
|
+
except OSError as error:
|
|
715
|
+
print("ERROR: output failed ({})".format(type(error).__name__), file=sys.stderr)
|
|
716
|
+
return 2
|
|
717
|
+
except KeyboardInterrupt:
|
|
718
|
+
print("ERROR: interrupted", file=sys.stderr)
|
|
719
|
+
return 2
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
if __name__ == "__main__":
|
|
723
|
+
sys.exit(main())
|