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 ADDED
@@ -0,0 +1,8 @@
1
+ """Public package interface for laga."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .core import loads, repair, repair_to_str
6
+ from .errors import LagaError
7
+
8
+ __all__ = ["LagaError", "loads", "repair", "repair_to_str"]
laga/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.1"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ laga = laga.core:main
@@ -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.