pyroid.json_repair: drop-in json_repair¶
pyroid.json_repair is a Rust port of json_repair
0.64.5 by Stefano Baccianella (MIT). json_repair turns broken JSON from LLMs into valid JSON:
missing quotes, single quotes, comments, trailing commas, truncation, markdown fences, prose
around it, Python literals. Its parser is ported line by line, so pyroid returns the same
strings, objects and repair logs as json_repair 0.64.5 (parity).
It also stays linear on 13 input families where json_repair's lookaheads are quadratic, and deep
nesting never overflows the stack.
Change one import¶
-import json_repair
+import pyroid.json_repair as json_repair
json_repair.repair_json("{'a': 1, b: [1, 2,}") # '{"a": 1, "b": [1, 2]}'
json_repair.loads('{"title": "Notes on the Analytical Eng')
# {'title': 'Notes on the Analytical Eng'}
Repairing and decoding¶
repair_json, loads, load and from_file take json_repair's arguments:
| Function | Returns |
|---|---|
repair_json(json_str, ...) |
the repaired JSON as a string ("" when no JSON was found) |
loads(json_str, ...) |
the decoded value, like json.loads after a repair |
load(fd, ...) |
the same, reading an open text file |
from_file(filename, ...) |
the same, opening the file by name |
Valid JSON comes back as json.dumps(json.loads(text)). Anything else goes through json_repair's
heuristics.
import pyroid.json_repair as json_repair
reply = 'Sure! Here is the JSON:\n```json\n{"name": "Ada", "langs": ["en", "fr"\n```'
json_repair.repair_json(reply) # '{"name": "Ada", "langs": ["en", "fr"]}'
json_repair.loads("{'ok': True, 'value': None, // comment\n 'n': 1,}")
# {'ok': True, 'value': None, 'n': 1}
json_repair.repair_json("[1, 2", logging=True)
# ([1, 2], [{'text': 'While parsing an array we missed the closing ], ignoring it',
# 'context': '[1, 2'}])
json_repair.repair_json("{'city': 'Zürich'", ensure_ascii=False) # '{"city": "Zürich"}'
The options are json_repair's:
return_objects=Truereturns the value instead of a string (whatloadsdoes).logging=Truereturns(value, repair_log): a list of{"text": ..., "context": ...}dicts, empty when nothing was repaired.skip_json_loads=Trueskips thejson.loadsvalidity check, so valid JSON goes through the repair parser too.stream_stable=Truekeeps the repairs of a growing, streamed prefix stable.strict=TrueraisesValueErroron structural problems instead of repairing them, with json_repair's messages ("Missing ':' after key in strict mode while parsing object.").json_fd=reads the text from an open file (preferload).chunk_lengthis accepted and ignored, because pyroid reads the file at once.- Other keyword arguments go to
json.dumpsfor the string result.ensure_ascii,indent,separators,sort_keysandallow_nanare reproduced in Rust; any others calljson.dumps.
Lone surrogates are handled as json_repair handles them ("['a\ud800" gives '["a\\ud800"]').
Unicode-dependent checks (str.isspace, isalpha, isdigit, isalnum) follow the running
interpreter's Unicode version, 14.0 to 17.0. A newer Python gets a RuntimeWarning and the 17.0
tables.
Many documents: repair_json_batch¶
repair_json_batch is pyroid's addition. It runs repair_json over a batch on pyroid's thread
pool, without the GIL. Each result equals json_repair.repair_json(doc, ...) with the same
skip_json_loads, stream_stable, strict and json.dumps arguments.
import pyarrow as pa
import pyroid.json_repair as json_repair
docs = ["{'a': 1", '{"b": 2}', "[1, 2,"]
fixed = json_repair.repair_json_batch(docs) # ['{"a": 1}', '{"b": 2}', '[1, 2]']
fixed[1] is docs[1] # True: a document that needs no change is the same object
json_repair.repair_json_batch(docs, threads=4, indent=2) # at most 4 tasks at once
column = json_repair.repair_json_batch(pa.array(["{'a': 1", None, "[1, 2,"]))
column.to_pylist() # ['{"a": 1}', None, '[1, 2]']
It takes a str, a list or tuple of str, or an Arrow string column (pyarrow, polars,
datasets) and returns the same kind. For Arrow input that is a
pyroid.Column, with nulls kept. In strict mode a
failing document raises for the whole batch, and the exception's note names its index.
json.dumps arguments other than ensure_ascii, indent, separators, sort_keys and
allow_nan work for str, list and tuple input only (one repair_json call per document).
Deep nesting: NestingError¶
pyroid's parser follows up to MAX_DEPTH (1,000) levels of broken nesting. Deeper input raises
NestingError, which is a pyroid.PyroidError, a
ValueError and a RecursionError, so except clauses written for json_repair still catch it.
json_repair itself raises ValueError past about 140 to 330 levels, depending on
sys.getrecursionlimit() and the caller's stack.
import pyroid.json_repair as json_repair
json_repair.repair_json("[" * 1000) # '[]' (json_repair raises ValueError)
try:
json_repair.repair_json("[" * 1001)
except json_repair.NestingError as e:
print(e) # Input nesting exceeds the supported parser recursion depth.
Delegated features¶
These are handed to the real json_repair, which must then be installed. The first call emits a
pyroid.FallbackWarning, and without json_repair the call raises
pyroid.PyroidError:
- every
schema=call, which needs jsonschema or pydantic as in json_repair (listed inpyroid.json_repair.DELEGATED); - input that is not a
str(for examplebytes); - the rare text holding a character in U+10F800..U+10FFFF, raw or as an escaped surrogate pair,
because the port uses that range to store lone surrogates. In
repair_json_batch, such documents in a list or tuple are delegated one by one; Arrow input raisesPyroidErrorinstead.
json_repair.repair_json(b"{'a': 1") # json_repair's result, and:
# FallbackWarning: pyroid.json_repair.repair_json: a bytes input is not supported by the Rust
# port; delegating to json_repair (slow path). This warning is shown once.
json_repair's command line (python -m json_repair) and its internal modules are not provided.
Pathological input¶
json_repair has lookaheads and string copies that make some inputs quadratic. The port keeps every heuristic but memoizes those scans, and stays linear on all 13 super-linear input families found (json_repair's log-log slopes are 1.5 to 2.0). The table shows a few of them, at sizes doubling from 16 KiB to 1 MiB, with times at the largest size json_repair finished. CPython 3.12, on a Docker/OrbStack VM on Apple Silicon, indicative:
| Input family (json_repair's cause) | Slope, json_repair / pyroid | json_repair → pyroid |
|---|---|---|
truncated_string (string_acc += copies) |
1.64 / 1.00 | 512 KB: 6.2 s → 9.2 ms; 1 MB: > 20 s → 19 ms |
escaped_code_string ([:-1] per escape) |
1.71 / 1.02 | 1 MB: 18.1 s → 17.6 ms |
markdown_in_array (a scan per ]) |
2.00 / 1.02 | 128 KB: 10.0 s → 2.4 ms |
single_quoted_apostrophes (pairwise quote walk) |
2.04 / 1.05 | 64 KB: 15.9 s → 2.2 ms |
records_missing_separators (input rebuilt per split) |
1.47 / 0.97 | 1 MB: 6.1 s → 40 ms |
nested_open_containers (adversarial, {[ × 60) |
O(depth·n) / 1.02 | 512 KB: 12.5 s → 9.2 ms |
These families come from reading json_repair's code. Fuzzing found one more that is quadratic in both: a 62-character run of garbage, repeated, takes pyroid 8, 32, 127 and 511 ms at 7,936, 15,872, 31,744 and 63,488 characters, and json_repair 4.6 s at 15,872 and 18.4 s at 31,744 (Docker/OrbStack VM, indicative).
Differences from json_repair¶
From the module's documentation (pyroid/json_repair.pyi):
- json_repair raises
ValueErrorpast about 140-330 levels of broken input (it depends onsys.getrecursionlimit()and the caller's stack). pyroid repairs up toMAX_DEPTHlevels, and deeper broken input raisesNestingError, which is both aValueErrorand aRecursionError. - Valid JSON is decoded by
json.loadsfor objects, as in json_repair, so itsRecursionErrorpast about 10,000 levels on 3.12 is the same. For string results pyroid decodes it at any depth (json_repair raises there too). load/from_fileread the file once (fd.read()) instead of inchunk_lengthpieces. The result is the same,chunk_lengthis ignored and unseekable streams work.- json_repair's command line (
python -m json_repair) is not part of this module.
And by design, the delegated features need json_repair installed.
Parity¶
There were 0 differences in 184,403 comparisons on CPython 3.12 and 184,254 on free-threaded
3.15t, where the schema= replays are skipped for lack of jsonschema. The suite replays
json_repair's own test suite as 672 recorded calls, 20,000 seeded broken LLM outputs under 8
option sets, Hypothesis grammar fuzzing with lone surrogates under 9 option sets, and load and
from_file. Details: Compatibility.
Speed¶
These timings come from a Docker/OrbStack VM on Apple Silicon, indicative. Single documents, CPython 3.12, best time per call (3.15t is within ±10%):
| Document | json_repair | pyroid | Speedup |
|---|---|---|---|
valid ~1 KB, repair_json |
11.3 µs | 6.6 µs | 1.7× |
valid ~1 KB, loads |
6.2 µs | 6.4 µs | 1.0× |
broken ~1 KB, repair_json |
600 µs | 20.1 µs | 30× |
broken ~1 KB, loads |
589 µs | 22.2 µs | 27× |
valid 1.2 MB, repair_json |
9.9 ms | 8.1 ms | 1.2× |
valid 1.2 MB, loads |
5.7 ms | 5.8 ms | 1.0× |
broken 1 MB, repair_json |
544 ms | 17.1 ms | 32× |
broken 1 MB, loads |
548 ms | 18.6 ms | 30× |
On valid JSON there is little to gain. Both libraries start with CPython's json.loads, so
loads of valid JSON costs the same. The speedup is on broken JSON, where json_repair's parser
runs in Python.
For batches, 10,000 broken documents of about 1.2 KB on free-threaded CPython 3.15t (GIL off), on the same VM:
| Run | Documents/s | vs json_repair loop |
|---|---|---|
| json_repair, loop | 1,204 | 1× |
| json_repair, 8-thread pool | 4,681 | 3.9× |
pyroid repair_json, loop |
41,414 | 34× |
repair_json_batch, threads=1 |
46,221 | 38× |
repair_json_batch, threads=8 |
156,134 | 130× |
The 8-thread run is limited by the shared, loaded VM. See also Benchmarks.