laga 0.1.1__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.
- laga/__init__.py +8 -0
- laga/_version.py +3 -0
- laga/core.py +385 -0
- laga/errors.py +18 -0
- laga/py.typed +0 -0
- laga/tokenizer.py +192 -0
- laga-0.1.1.dist-info/METADATA +147 -0
- laga-0.1.1.dist-info/RECORD +11 -0
- laga-0.1.1.dist-info/WHEEL +4 -0
- laga-0.1.1.dist-info/entry_points.txt +2 -0
- laga-0.1.1.dist-info/licenses/LICENSE +21 -0
laga/__init__.py
ADDED
laga/_version.py
ADDED
laga/core.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import LagaError
|
|
8
|
+
from .tokenizer import _Token, _Tokenizer
|
|
9
|
+
|
|
10
|
+
__all__ = ["loads", "main", "repair", "repair_to_str"]
|
|
11
|
+
|
|
12
|
+
_MAX_NESTING_DEPTH = 256
|
|
13
|
+
|
|
14
|
+
_SMART_QUOTES = str.maketrans(
|
|
15
|
+
{
|
|
16
|
+
chr(0x201C): '"',
|
|
17
|
+
chr(0x201D): '"',
|
|
18
|
+
chr(0x201E): '"',
|
|
19
|
+
chr(0x201F): '"',
|
|
20
|
+
chr(0x2018): "'",
|
|
21
|
+
chr(0x2019): "'",
|
|
22
|
+
chr(0x201A): "'",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
_LITERAL_MAP = {"true": True, "false": False, "null": None, "none": None}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repair(text: str, *, strict: bool = False) -> Any:
|
|
29
|
+
"""Parse text as JSON, repairing common errors when needed.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
text: Input text that should contain JSON.
|
|
33
|
+
strict: When `True`, reject ambiguous repairs such as missing commas or
|
|
34
|
+
truncated containers.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The parsed Python value.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
LagaError: If the text cannot be repaired into valid JSON.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(text)
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
return _repair_text(text, strict=strict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def repair_to_str(text: str, *, strict: bool = False) -> str:
|
|
50
|
+
"""Repair JSON text and return normalized JSON.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
text: Input text that should contain JSON.
|
|
54
|
+
strict: When `True`, reject ambiguous repairs such as missing commas or
|
|
55
|
+
truncated containers.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A normalized JSON string.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
LagaError: If the text cannot be repaired into valid JSON.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return json.dumps(
|
|
65
|
+
repair(text, strict=strict), ensure_ascii=False, separators=(",", ":")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
loads = repair
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
73
|
+
"""Run a small command line interface for manual repair checks.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
argv: Optional argument sequence. When omitted, `sys.argv[1:]` is used.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Process exit code.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
import argparse
|
|
83
|
+
import sys
|
|
84
|
+
|
|
85
|
+
parser = argparse.ArgumentParser(prog="laga")
|
|
86
|
+
parser.add_argument("text", nargs="?", help="Text containing malformed JSON")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--strict", action="store_true", help="Reject ambiguous repairs"
|
|
89
|
+
)
|
|
90
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
91
|
+
|
|
92
|
+
if args.text is None:
|
|
93
|
+
sample = sys.stdin.read()
|
|
94
|
+
else:
|
|
95
|
+
sample = args.text
|
|
96
|
+
try:
|
|
97
|
+
print(repair_to_str(sample, strict=args.strict))
|
|
98
|
+
except LagaError as exc:
|
|
99
|
+
print(str(exc), file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _repair_text(text: str, *, strict: bool) -> Any:
|
|
105
|
+
normalized = _normalize_text(text)
|
|
106
|
+
candidates = list(_candidate_texts(normalized))
|
|
107
|
+
last_error: LagaError | None = None
|
|
108
|
+
for candidate in candidates:
|
|
109
|
+
try:
|
|
110
|
+
tokens = _Tokenizer(candidate, strict=strict).tokenize()
|
|
111
|
+
parser = _Parser(tokens, strict=strict)
|
|
112
|
+
return parser.parse()
|
|
113
|
+
except LagaError as exc:
|
|
114
|
+
last_error = exc
|
|
115
|
+
if last_error is None:
|
|
116
|
+
raise LagaError("No JSON value found", 0)
|
|
117
|
+
raise last_error
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_text(text: str) -> str:
|
|
121
|
+
stripped = _extract_fenced_text(text)
|
|
122
|
+
if stripped is None:
|
|
123
|
+
stripped = text
|
|
124
|
+
return stripped.translate(_SMART_QUOTES)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_fenced_text(text: str) -> str | None:
|
|
128
|
+
fence_start = text.find("```")
|
|
129
|
+
if fence_start == -1:
|
|
130
|
+
return None
|
|
131
|
+
line_end = text.find("\n", fence_start + 3)
|
|
132
|
+
if line_end == -1:
|
|
133
|
+
return text[fence_start + 3 :].strip()
|
|
134
|
+
fence_end = text.find("```", line_end + 1)
|
|
135
|
+
if fence_end == -1:
|
|
136
|
+
return text[line_end + 1 :].strip()
|
|
137
|
+
return text[line_end + 1 : fence_end].strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _candidate_texts(text: str) -> Iterable[str]:
|
|
141
|
+
if "```" in text:
|
|
142
|
+
yield text
|
|
143
|
+
return
|
|
144
|
+
found = False
|
|
145
|
+
for index, char in enumerate(text):
|
|
146
|
+
if char in "[{":
|
|
147
|
+
found = True
|
|
148
|
+
yield _capture_balanced_region(text, index)
|
|
149
|
+
if not found:
|
|
150
|
+
yield text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _capture_balanced_region(text: str, start: int) -> str:
|
|
154
|
+
opening = text[start]
|
|
155
|
+
closing = "]" if opening == "[" else "}"
|
|
156
|
+
depth = 0
|
|
157
|
+
in_string = False
|
|
158
|
+
quote = ""
|
|
159
|
+
escaped = False
|
|
160
|
+
index = start
|
|
161
|
+
while index < len(text):
|
|
162
|
+
char = text[index]
|
|
163
|
+
if in_string:
|
|
164
|
+
if escaped:
|
|
165
|
+
escaped = False
|
|
166
|
+
elif char == "\\":
|
|
167
|
+
escaped = True
|
|
168
|
+
elif char == quote:
|
|
169
|
+
in_string = False
|
|
170
|
+
else:
|
|
171
|
+
if char in "\"'":
|
|
172
|
+
in_string = True
|
|
173
|
+
quote = char
|
|
174
|
+
elif char == "/" and _next_char(text, index) == "/":
|
|
175
|
+
index = _skip_line_comment(text, index + 2)
|
|
176
|
+
continue
|
|
177
|
+
elif char == "/" and _next_char(text, index) == "*":
|
|
178
|
+
index = _skip_block_comment(text, index + 2)
|
|
179
|
+
continue
|
|
180
|
+
elif char == opening:
|
|
181
|
+
depth += 1
|
|
182
|
+
elif char == closing:
|
|
183
|
+
depth -= 1
|
|
184
|
+
if depth == 0:
|
|
185
|
+
return text[start : index + 1]
|
|
186
|
+
index += 1
|
|
187
|
+
return text[start:]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _next_char(text: str, index: int) -> str | None:
|
|
191
|
+
next_index = index + 1
|
|
192
|
+
if next_index >= len(text):
|
|
193
|
+
return None
|
|
194
|
+
return text[next_index]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _skip_line_comment(text: str, index: int) -> int:
|
|
198
|
+
while index < len(text) and text[index] not in "\r\n":
|
|
199
|
+
index += 1
|
|
200
|
+
return index - 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _skip_block_comment(text: str, index: int) -> int:
|
|
204
|
+
while index < len(text) - 1:
|
|
205
|
+
if text[index] == "*" and text[index + 1] == "/":
|
|
206
|
+
return index + 1
|
|
207
|
+
index += 1
|
|
208
|
+
return len(text) - 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Parser:
|
|
212
|
+
def __init__(self, tokens: list[_Token], *, strict: bool) -> None:
|
|
213
|
+
self._tokens = tokens
|
|
214
|
+
self._strict = strict
|
|
215
|
+
self._index = 0
|
|
216
|
+
self._depth = 0
|
|
217
|
+
|
|
218
|
+
def parse(self) -> Any:
|
|
219
|
+
value = self._parse_value()
|
|
220
|
+
if self._peek() is not None:
|
|
221
|
+
token = self._peek()
|
|
222
|
+
assert token is not None
|
|
223
|
+
raise LagaError("Unexpected trailing content", token.position)
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
def _parse_value(self) -> Any:
|
|
227
|
+
token = self._peek()
|
|
228
|
+
if token is None:
|
|
229
|
+
raise LagaError("Expected JSON value", self._current_position())
|
|
230
|
+
if token.kind == "LBRACE":
|
|
231
|
+
return self._parse_object()
|
|
232
|
+
if token.kind == "LBRACKET":
|
|
233
|
+
return self._parse_array()
|
|
234
|
+
if token.kind == "STRING":
|
|
235
|
+
self._index += 1
|
|
236
|
+
return token.value
|
|
237
|
+
if token.kind == "NUMBER":
|
|
238
|
+
self._index += 1
|
|
239
|
+
try:
|
|
240
|
+
return json.loads(token.value)
|
|
241
|
+
except json.JSONDecodeError as exc:
|
|
242
|
+
raise LagaError("Invalid number", token.position) from exc
|
|
243
|
+
if token.kind == "IDENT":
|
|
244
|
+
self._index += 1
|
|
245
|
+
lowered = token.value.lower()
|
|
246
|
+
if lowered in _LITERAL_MAP:
|
|
247
|
+
return _LITERAL_MAP[lowered]
|
|
248
|
+
raise LagaError("Unexpected identifier", token.position)
|
|
249
|
+
raise LagaError("Expected JSON value", token.position)
|
|
250
|
+
|
|
251
|
+
def _parse_object(self) -> dict[str, Any]:
|
|
252
|
+
self._expect("LBRACE")
|
|
253
|
+
self._enter_container()
|
|
254
|
+
result: dict[str, Any] = {}
|
|
255
|
+
try:
|
|
256
|
+
if self._match("RBRACE"):
|
|
257
|
+
return result
|
|
258
|
+
while True:
|
|
259
|
+
token = self._peek()
|
|
260
|
+
if token is None:
|
|
261
|
+
if self._strict:
|
|
262
|
+
raise LagaError("Unterminated object", self._current_position())
|
|
263
|
+
return result
|
|
264
|
+
if token.kind == "RBRACE":
|
|
265
|
+
self._index += 1
|
|
266
|
+
return result
|
|
267
|
+
key = self._parse_key()
|
|
268
|
+
self._expect("COLON")
|
|
269
|
+
result[key] = self._parse_value()
|
|
270
|
+
token = self._peek()
|
|
271
|
+
if token is None:
|
|
272
|
+
if self._strict:
|
|
273
|
+
raise LagaError("Unterminated object", self._current_position())
|
|
274
|
+
return result
|
|
275
|
+
if token.kind == "COMMA":
|
|
276
|
+
self._index += 1
|
|
277
|
+
if self._match("RBRACE"):
|
|
278
|
+
return result
|
|
279
|
+
continue
|
|
280
|
+
if token.kind == "RBRACE":
|
|
281
|
+
self._index += 1
|
|
282
|
+
return result
|
|
283
|
+
if self._begins_member(token):
|
|
284
|
+
if self._strict:
|
|
285
|
+
raise LagaError(
|
|
286
|
+
"Missing comma between object members", token.position
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
raise LagaError("Expected comma or closing brace", token.position)
|
|
290
|
+
finally:
|
|
291
|
+
self._exit_container()
|
|
292
|
+
|
|
293
|
+
def _parse_array(self) -> list[Any]:
|
|
294
|
+
self._expect("LBRACKET")
|
|
295
|
+
self._enter_container()
|
|
296
|
+
result: list[Any] = []
|
|
297
|
+
try:
|
|
298
|
+
if self._match("RBRACKET"):
|
|
299
|
+
return result
|
|
300
|
+
while True:
|
|
301
|
+
token = self._peek()
|
|
302
|
+
if token is None:
|
|
303
|
+
if self._strict:
|
|
304
|
+
raise LagaError("Unterminated array", self._current_position())
|
|
305
|
+
return result
|
|
306
|
+
if token.kind == "RBRACKET":
|
|
307
|
+
self._index += 1
|
|
308
|
+
return result
|
|
309
|
+
result.append(self._parse_value())
|
|
310
|
+
token = self._peek()
|
|
311
|
+
if token is None:
|
|
312
|
+
if self._strict:
|
|
313
|
+
raise LagaError("Unterminated array", self._current_position())
|
|
314
|
+
return result
|
|
315
|
+
if token.kind == "COMMA":
|
|
316
|
+
self._index += 1
|
|
317
|
+
if self._match("RBRACKET"):
|
|
318
|
+
return result
|
|
319
|
+
continue
|
|
320
|
+
if token.kind == "RBRACKET":
|
|
321
|
+
self._index += 1
|
|
322
|
+
return result
|
|
323
|
+
if self._begins_value(token):
|
|
324
|
+
if self._strict:
|
|
325
|
+
raise LagaError(
|
|
326
|
+
"Missing comma between array items", token.position
|
|
327
|
+
)
|
|
328
|
+
continue
|
|
329
|
+
raise LagaError("Expected comma or closing bracket", token.position)
|
|
330
|
+
finally:
|
|
331
|
+
self._exit_container()
|
|
332
|
+
|
|
333
|
+
def _parse_key(self) -> str:
|
|
334
|
+
token = self._peek()
|
|
335
|
+
if token is None:
|
|
336
|
+
raise LagaError("Expected object key", self._current_position())
|
|
337
|
+
if token.kind == "STRING":
|
|
338
|
+
self._index += 1
|
|
339
|
+
return token.value
|
|
340
|
+
if token.kind == "IDENT":
|
|
341
|
+
self._index += 1
|
|
342
|
+
return token.value
|
|
343
|
+
raise LagaError("Expected object key", token.position)
|
|
344
|
+
|
|
345
|
+
def _expect(self, kind: str) -> _Token:
|
|
346
|
+
token = self._peek()
|
|
347
|
+
if token is None or token.kind != kind:
|
|
348
|
+
position = self._current_position() if token is None else token.position
|
|
349
|
+
raise LagaError(f"Expected {kind.lower()}", position)
|
|
350
|
+
self._index += 1
|
|
351
|
+
return token
|
|
352
|
+
|
|
353
|
+
def _match(self, kind: str) -> bool:
|
|
354
|
+
token = self._peek()
|
|
355
|
+
if token is None or token.kind != kind:
|
|
356
|
+
return False
|
|
357
|
+
self._index += 1
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def _peek(self) -> _Token | None:
|
|
361
|
+
if self._index >= len(self._tokens):
|
|
362
|
+
return None
|
|
363
|
+
return self._tokens[self._index]
|
|
364
|
+
|
|
365
|
+
def _current_position(self) -> int:
|
|
366
|
+
token = self._peek()
|
|
367
|
+
if token is not None:
|
|
368
|
+
return token.position
|
|
369
|
+
if not self._tokens:
|
|
370
|
+
return 0
|
|
371
|
+
return self._tokens[-1].position + len(self._tokens[-1].value)
|
|
372
|
+
|
|
373
|
+
def _begins_value(self, token: _Token) -> bool:
|
|
374
|
+
return token.kind in {"LBRACE", "LBRACKET", "STRING", "NUMBER", "IDENT"}
|
|
375
|
+
|
|
376
|
+
def _begins_member(self, token: _Token) -> bool:
|
|
377
|
+
return token.kind in {"STRING", "IDENT"}
|
|
378
|
+
|
|
379
|
+
def _enter_container(self) -> None:
|
|
380
|
+
self._depth += 1
|
|
381
|
+
if self._depth > _MAX_NESTING_DEPTH:
|
|
382
|
+
raise LagaError("JSON is too deeply nested", self._current_position())
|
|
383
|
+
|
|
384
|
+
def _exit_container(self) -> None:
|
|
385
|
+
self._depth -= 1
|
laga/errors.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LagaError(ValueError):
|
|
5
|
+
"""Raised when JSON repair cannot produce a valid result.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
message: Human-readable explanation of the failure.
|
|
9
|
+
position: Character offset where the problem was detected, if known.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, position: int | None = None) -> None:
|
|
13
|
+
self.position = position
|
|
14
|
+
if position is None:
|
|
15
|
+
formatted = message
|
|
16
|
+
else:
|
|
17
|
+
formatted = f"{message} at position {position}"
|
|
18
|
+
super().__init__(formatted)
|
laga/py.typed
ADDED
|
File without changes
|
laga/tokenizer.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .errors import LagaError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class _Token:
|
|
10
|
+
kind: str
|
|
11
|
+
value: str
|
|
12
|
+
position: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Tokenizer:
|
|
16
|
+
def __init__(self, text: str, *, strict: bool) -> None:
|
|
17
|
+
self._text = text
|
|
18
|
+
self._strict = strict
|
|
19
|
+
self._index = 0
|
|
20
|
+
self._length = len(text)
|
|
21
|
+
|
|
22
|
+
def tokenize(self) -> list[_Token]:
|
|
23
|
+
tokens: list[_Token] = []
|
|
24
|
+
while self._index < self._length:
|
|
25
|
+
char = self._text[self._index]
|
|
26
|
+
if char.isspace():
|
|
27
|
+
self._index += 1
|
|
28
|
+
continue
|
|
29
|
+
if char == "/" and self._peek_char(1) == "/":
|
|
30
|
+
self._skip_line_comment()
|
|
31
|
+
continue
|
|
32
|
+
if char == "/" and self._peek_char(1) == "*":
|
|
33
|
+
self._skip_block_comment()
|
|
34
|
+
continue
|
|
35
|
+
if char == "{":
|
|
36
|
+
tokens.append(_Token("LBRACE", char, self._index))
|
|
37
|
+
self._index += 1
|
|
38
|
+
continue
|
|
39
|
+
if char == "}":
|
|
40
|
+
tokens.append(_Token("RBRACE", char, self._index))
|
|
41
|
+
self._index += 1
|
|
42
|
+
continue
|
|
43
|
+
if char == "[":
|
|
44
|
+
tokens.append(_Token("LBRACKET", char, self._index))
|
|
45
|
+
self._index += 1
|
|
46
|
+
continue
|
|
47
|
+
if char == "]":
|
|
48
|
+
tokens.append(_Token("RBRACKET", char, self._index))
|
|
49
|
+
self._index += 1
|
|
50
|
+
continue
|
|
51
|
+
if char == ":":
|
|
52
|
+
tokens.append(_Token("COLON", char, self._index))
|
|
53
|
+
self._index += 1
|
|
54
|
+
continue
|
|
55
|
+
if char == ",":
|
|
56
|
+
tokens.append(_Token("COMMA", char, self._index))
|
|
57
|
+
self._index += 1
|
|
58
|
+
continue
|
|
59
|
+
if char in "\"'":
|
|
60
|
+
tokens.append(self._consume_string(char))
|
|
61
|
+
continue
|
|
62
|
+
if char == "-" or char.isdigit():
|
|
63
|
+
tokens.append(self._consume_number())
|
|
64
|
+
continue
|
|
65
|
+
if _is_identifier_start(char):
|
|
66
|
+
tokens.append(self._consume_identifier())
|
|
67
|
+
continue
|
|
68
|
+
raise LagaError(f"Unexpected character {char!r}", self._index)
|
|
69
|
+
return tokens
|
|
70
|
+
|
|
71
|
+
def _peek_char(self, offset: int) -> str | None:
|
|
72
|
+
index = self._index + offset
|
|
73
|
+
if index >= self._length:
|
|
74
|
+
return None
|
|
75
|
+
return self._text[index]
|
|
76
|
+
|
|
77
|
+
def _skip_line_comment(self) -> None:
|
|
78
|
+
self._index += 2
|
|
79
|
+
while self._index < self._length and self._text[self._index] not in "\r\n":
|
|
80
|
+
self._index += 1
|
|
81
|
+
|
|
82
|
+
def _skip_block_comment(self) -> None:
|
|
83
|
+
self._index += 2
|
|
84
|
+
while self._index < self._length - 1:
|
|
85
|
+
if self._text[self._index] == "*" and self._text[self._index + 1] == "/":
|
|
86
|
+
self._index += 2
|
|
87
|
+
return
|
|
88
|
+
self._index += 1
|
|
89
|
+
if self._strict:
|
|
90
|
+
raise LagaError("Unterminated block comment", self._index)
|
|
91
|
+
self._index = self._length
|
|
92
|
+
|
|
93
|
+
def _consume_string(self, quote: str) -> _Token:
|
|
94
|
+
start = self._index
|
|
95
|
+
self._index += 1
|
|
96
|
+
pieces: list[str] = []
|
|
97
|
+
while self._index < self._length:
|
|
98
|
+
char = self._text[self._index]
|
|
99
|
+
self._index += 1
|
|
100
|
+
if char == quote:
|
|
101
|
+
return _Token("STRING", "".join(pieces), start)
|
|
102
|
+
if char == "\\":
|
|
103
|
+
pieces.append(self._consume_escape(start))
|
|
104
|
+
continue
|
|
105
|
+
pieces.append(char)
|
|
106
|
+
if self._strict:
|
|
107
|
+
raise LagaError("Unterminated string literal", start)
|
|
108
|
+
return _Token("STRING", "".join(pieces), start)
|
|
109
|
+
|
|
110
|
+
def _consume_escape(self, string_start: int) -> str:
|
|
111
|
+
if self._index >= self._length:
|
|
112
|
+
if self._strict:
|
|
113
|
+
raise LagaError("Incomplete escape sequence", string_start)
|
|
114
|
+
return "\\"
|
|
115
|
+
char = self._text[self._index]
|
|
116
|
+
self._index += 1
|
|
117
|
+
if char in '"\\/':
|
|
118
|
+
return char
|
|
119
|
+
if char == "b":
|
|
120
|
+
return "\b"
|
|
121
|
+
if char == "f":
|
|
122
|
+
return "\f"
|
|
123
|
+
if char == "n":
|
|
124
|
+
return "\n"
|
|
125
|
+
if char == "r":
|
|
126
|
+
return "\r"
|
|
127
|
+
if char == "t":
|
|
128
|
+
return "\t"
|
|
129
|
+
if char == "u":
|
|
130
|
+
return self._consume_unicode_escape(string_start)
|
|
131
|
+
if self._strict:
|
|
132
|
+
raise LagaError(f"Invalid escape sequence \\{char}", string_start)
|
|
133
|
+
return char
|
|
134
|
+
|
|
135
|
+
def _consume_unicode_escape(self, string_start: int) -> str:
|
|
136
|
+
digits = self._text[self._index : self._index + 4]
|
|
137
|
+
if len(digits) == 4 and all(_is_hex_digit(char) for char in digits):
|
|
138
|
+
self._index += 4
|
|
139
|
+
return chr(int(digits, 16))
|
|
140
|
+
if self._strict:
|
|
141
|
+
raise LagaError("Invalid unicode escape", string_start)
|
|
142
|
+
return "u"
|
|
143
|
+
|
|
144
|
+
def _consume_number(self) -> _Token:
|
|
145
|
+
start = self._index
|
|
146
|
+
if self._text[self._index] == "-":
|
|
147
|
+
self._index += 1
|
|
148
|
+
if self._index >= self._length:
|
|
149
|
+
raise LagaError("Incomplete number", start)
|
|
150
|
+
if self._text[self._index] == "0":
|
|
151
|
+
self._index += 1
|
|
152
|
+
else:
|
|
153
|
+
if not self._text[self._index].isdigit():
|
|
154
|
+
raise LagaError("Invalid number", start)
|
|
155
|
+
while self._index < self._length and self._text[self._index].isdigit():
|
|
156
|
+
self._index += 1
|
|
157
|
+
if self._index < self._length and self._text[self._index] == ".":
|
|
158
|
+
self._index += 1
|
|
159
|
+
if self._index >= self._length or not self._text[self._index].isdigit():
|
|
160
|
+
raise LagaError("Invalid number", start)
|
|
161
|
+
while self._index < self._length and self._text[self._index].isdigit():
|
|
162
|
+
self._index += 1
|
|
163
|
+
if self._index < self._length and self._text[self._index] in "eE":
|
|
164
|
+
self._index += 1
|
|
165
|
+
if self._index < self._length and self._text[self._index] in "+-":
|
|
166
|
+
self._index += 1
|
|
167
|
+
if self._index >= self._length or not self._text[self._index].isdigit():
|
|
168
|
+
raise LagaError("Invalid number", start)
|
|
169
|
+
while self._index < self._length and self._text[self._index].isdigit():
|
|
170
|
+
self._index += 1
|
|
171
|
+
return _Token("NUMBER", self._text[start : self._index], start)
|
|
172
|
+
|
|
173
|
+
def _consume_identifier(self) -> _Token:
|
|
174
|
+
start = self._index
|
|
175
|
+
self._index += 1
|
|
176
|
+
while self._index < self._length and _is_identifier_part(
|
|
177
|
+
self._text[self._index]
|
|
178
|
+
):
|
|
179
|
+
self._index += 1
|
|
180
|
+
return _Token("IDENT", self._text[start : self._index], start)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_hex_digit(char: str) -> bool:
|
|
184
|
+
return char.isdigit() or char.lower() in "abcdef"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_identifier_start(char: str) -> bool:
|
|
188
|
+
return char == "_" or char.isalpha()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_identifier_part(char: str) -> bool:
|
|
192
|
+
return char == "_" or char.isalpha() or char.isdigit() or char == "$"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: laga
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Repair malformed JSON from language models and hand-written config.
|
|
5
|
+
Project-URL: Homepage, https://github.com/olaflaitinen/laga
|
|
6
|
+
Project-URL: Documentation, https://github.com/olaflaitinen/laga/tree/main/docs
|
|
7
|
+
Project-URL: Source, https://github.com/olaflaitinen/laga
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/olaflaitinen/laga/issues
|
|
9
|
+
Author: Gustav Olaf Yunus Laitinen-Fredriksson Lundström-Imanov
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Gustav Olaf Yunus Laitinen-Fredriksson Lundström-Imanov
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: data-cleanup,json,llm,parser,repair
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
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: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Classifier: Typing :: Typed
|
|
44
|
+
Requires-Python: <3.14,>=3.10
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
47
|
+
Requires-Dist: coverage[toml]>=7.6; extra == 'dev'
|
|
48
|
+
Requires-Dist: hypothesis>=6.108; extra == 'dev'
|
|
49
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'dev'
|
|
50
|
+
Requires-Dist: mkdocs>=1.6; extra == 'dev'
|
|
51
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
52
|
+
Requires-Dist: pre-commit>=3.8; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
55
|
+
Requires-Dist: ruff>=0.6.9; extra == 'dev'
|
|
56
|
+
Provides-Extra: docs
|
|
57
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
58
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
59
|
+
Provides-Extra: lint
|
|
60
|
+
Requires-Dist: mypy>=1.11; extra == 'lint'
|
|
61
|
+
Requires-Dist: ruff>=0.6.9; extra == 'lint'
|
|
62
|
+
Provides-Extra: test
|
|
63
|
+
Requires-Dist: coverage[toml]>=7.6; extra == 'test'
|
|
64
|
+
Requires-Dist: hypothesis>=6.108; extra == 'test'
|
|
65
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'test'
|
|
66
|
+
Requires-Dist: pytest>=8.3; extra == 'test'
|
|
67
|
+
Description-Content-Type: text/markdown
|
|
68
|
+
|
|
69
|
+
# laga
|
|
70
|
+
|
|
71
|
+
`laga` repairs malformed JSON and returns Python objects.
|
|
72
|
+
|
|
73
|
+
## Why
|
|
74
|
+
|
|
75
|
+
Large language models and hand-written configuration often produce almost-JSON with trailing commas, missing commas, comments, code fences, single quotes, unquoted keys, or truncated containers. `laga` keeps the public surface small, tries `json.loads` first for valid input, and only enters the repair path when necessary.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
`laga` 0.1.1 supports Python 3.10 through 3.13.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m pip install -e ".[dev]"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Quickstart
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import laga
|
|
89
|
+
|
|
90
|
+
text = """Here is the result:
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
'name': 'Ada',
|
|
94
|
+
'active': True,
|
|
95
|
+
'roles': ['admin', 'writer',],
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
data = laga.repair(text)
|
|
101
|
+
print(data)
|
|
102
|
+
print(laga.repair_to_str(text))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## API Reference
|
|
106
|
+
|
|
107
|
+
| Function | Returns | Description |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `laga.repair(text, strict=False)` | Python object | Repairs malformed JSON and returns Python data. |
|
|
110
|
+
| `laga.repair_to_str(text, strict=False)` | `str` | Repairs malformed JSON and returns normalized JSON text. |
|
|
111
|
+
| `laga.loads(text, strict=False)` | Python object | Alias for `laga.repair`. |
|
|
112
|
+
| `laga.LagaError` | Exception type | Raised when repair fails or input is unrecoverable. |
|
|
113
|
+
|
|
114
|
+
## Repair Rules
|
|
115
|
+
|
|
116
|
+
| Rule | Before | After |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| Trailing comma | `{"a": 1,}` | `{"a":1}` |
|
|
119
|
+
| Missing comma | `{"a": 1 "b": 2}` | `{"a":1,"b":2}` |
|
|
120
|
+
| Single quotes | `{'a': 'b'}` | `{"a":"b"}` |
|
|
121
|
+
| Unquoted keys | `{name: "Ada"}` | `{"name":"Ada"}` |
|
|
122
|
+
| Python literals | `{"ok": True, "none": None}` | `{"ok":true,"none":null}` |
|
|
123
|
+
| Comments | `{"a": 1 // note\n}` | `{"a":1}` |
|
|
124
|
+
| Markdown fences | <code>```json\n{"a":1}\n```</code> | `{"a":1}` |
|
|
125
|
+
| Smart quotes | `“a”` | `"a"` |
|
|
126
|
+
| Prose around JSON | `Result: {"a":1}` | `{"a":1}` |
|
|
127
|
+
| Truncated structure | `{"a": [1, 2` | `{"a":[1,2]}` |
|
|
128
|
+
|
|
129
|
+
## Limitations
|
|
130
|
+
|
|
131
|
+
`laga` is intentionally conservative. It does not evaluate expressions, it does not guess at arbitrary JavaScript syntax, and it does not try to recover malformed numbers or deeply ambiguous text in strict mode. If you already control the producer and the input is valid JSON, the standard library remains the best choice because `json.loads` is faster and stricter.
|
|
132
|
+
|
|
133
|
+
When an object contains duplicate keys, the last value wins, matching Python dict behavior.
|
|
134
|
+
|
|
135
|
+
## When To Use The Standard Library
|
|
136
|
+
|
|
137
|
+
Use `json.loads` when you control the producer and know the input is valid JSON. Use `laga` when the input is noisy, user-facing, or generated by a model and you need a predictable repair step before parsing.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
make install
|
|
143
|
+
make lint
|
|
144
|
+
make typecheck
|
|
145
|
+
make test
|
|
146
|
+
make build
|
|
147
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
laga/__init__.py,sha256=d-wmg_rND-_JXTVG2a4W2IGbE1s1q398zYPRJtdmXs0,216
|
|
2
|
+
laga/_version.py,sha256=VfIcjuN_CI8BoNxWKybanz18M5jCOR-HOOcbalOQD0M,58
|
|
3
|
+
laga/core.py,sha256=irZdUBFLizusF4IeBTuHG9WK3rgZI2aFhDzzdIq_z3o,12186
|
|
4
|
+
laga/errors.py,sha256=jqMLhz6Xf4OwZ6yYEBRip6AnpGP9v5k644IrR3RXr1Y,564
|
|
5
|
+
laga/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
laga/tokenizer.py,sha256=mlzFTR-TTWqA9NOSOWH6XiYIRNEFxVvfwUo0cKrw6aQ,6942
|
|
7
|
+
laga-0.1.1.dist-info/METADATA,sha256=LNrLJ0ENPExER0AknWAw2OprK2Rxk9z07ByrRIOkflc,5964
|
|
8
|
+
laga-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
laga-0.1.1.dist-info/entry_points.txt,sha256=laC_DfTG4q3bWsNFybztCbgt94ajYxuGQ4DkNgyLBP0,40
|
|
10
|
+
laga-0.1.1.dist-info/licenses/LICENSE,sha256=9DC2R658LFbR8ETB1XqOiMCbVi4AMnTOBDLSEbViduU,1113
|
|
11
|
+
laga-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gustav Olaf Yunus Laitinen-Fredriksson Lundström-Imanov
|
|
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.
|