llmparse-py 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.
llmparse/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """llmparse — parse the JSON your LLM meant to send.
2
+
3
+ Zero-dependency extraction and repair for JSON embedded in raw LLM
4
+ output: markdown fences, prose, single quotes, trailing commas,
5
+ Python literals, comments and truncated streams.
6
+ """
7
+
8
+ from .core import LLMParseError, extract, loads, parse
9
+ from .repair import repair
10
+
11
+ __version__ = "0.1.1"
12
+ __all__ = ["LLMParseError", "extract", "loads", "parse", "repair", "__version__"]
llmparse/core.py ADDED
@@ -0,0 +1,93 @@
1
+ """Extraction and parsing: find the JSON inside LLM output and load it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .repair import RULES, repair
9
+ from .scanner import find_balanced_end
10
+
11
+ __all__ = ["LLMParseError", "extract", "parse", "loads", "repair"]
12
+
13
+
14
+ class LLMParseError(ValueError):
15
+ """Raised when no JSON value can be recovered from the text."""
16
+
17
+
18
+ def _try_loads(text: str) -> tuple[bool, Any]:
19
+ try:
20
+ return True, json.loads(text)
21
+ except (ValueError, TypeError):
22
+ return False, None
23
+
24
+
25
+ def _load_with_repairs(text: str) -> tuple[bool, Any]:
26
+ """Try plain parsing, then re-try after each repair stage."""
27
+ ok, value = _try_loads(text.strip())
28
+ if ok:
29
+ return True, value
30
+ current = text
31
+ for rule in RULES:
32
+ current = rule(current)
33
+ ok, value = _try_loads(current.strip())
34
+ if ok:
35
+ return True, value
36
+ return False, None
37
+
38
+
39
+ def extract(text: str) -> str | None:
40
+ """Return the first recoverable JSON substring of ``text``, or None.
41
+
42
+ Candidates start at every ``{`` or ``[`` outside the previous
43
+ candidate; each candidate is cut at its balanced end (or at end of
44
+ text when truncated) and tested with the full repair pipeline.
45
+ """
46
+ if not text:
47
+ return None
48
+ ok, _ = _load_with_repairs(text)
49
+ if ok:
50
+ return repair(text).strip() if "```" in text else text.strip()
51
+ for start, ch in enumerate(text):
52
+ if ch not in "{[":
53
+ continue
54
+ end = find_balanced_end(text, start)
55
+ candidate = text[start:end] if end is not None else text[start:]
56
+ ok, _ = _load_with_repairs(candidate)
57
+ if ok:
58
+ return candidate
59
+ return None
60
+
61
+
62
+ _SENTINEL = object()
63
+
64
+
65
+ def parse(text: str, *, default: Any = _SENTINEL) -> Any:
66
+ """Parse JSON out of raw LLM output.
67
+
68
+ Handles markdown fences, surrounding prose, single quotes, bare
69
+ keys, Python literals, comments, trailing commas and truncation.
70
+ Returns ``default`` when given and nothing can be recovered;
71
+ otherwise raises :class:`LLMParseError`.
72
+ """
73
+ if isinstance(text, (dict, list)):
74
+ return text
75
+ if not isinstance(text, str) or not text.strip():
76
+ if default is not _SENTINEL:
77
+ return default
78
+ raise LLMParseError("no JSON found: empty input")
79
+ ok, value = _load_with_repairs(text)
80
+ if ok:
81
+ return value
82
+ candidate = extract(text)
83
+ if candidate is not None:
84
+ ok, value = _load_with_repairs(candidate)
85
+ if ok:
86
+ return value
87
+ if default is not _SENTINEL:
88
+ return default
89
+ raise LLMParseError(f"could not recover JSON from: {text[:120]!r}")
90
+
91
+
92
+ # Familiar alias for users coming from the json module.
93
+ loads = parse
llmparse/repair.py ADDED
@@ -0,0 +1,192 @@
1
+ """Repair rules for malformed JSON commonly produced by LLMs.
2
+
3
+ Each rule is a small ``str -> str`` function that fixes one class of
4
+ damage and is safe to apply to already-valid JSON (it must not change
5
+ valid input). Rules run in the order listed in ``RULES``; the parser
6
+ tries ``json.loads`` after every stage and stops as soon as it works.
7
+
8
+ Adding a rule is the easiest meaningful contribution to llmparse:
9
+ write the function, register it in ``RULES``, and add a test case with
10
+ a real-world-style broken input. See CONTRIBUTING.md.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from .scanner import in_string_at_end, iter_chars, unclosed_brackets
18
+
19
+
20
+ def strip_code_fences(text: str) -> str:
21
+ """Remove surrounding markdown ``` fences, if present."""
22
+ stripped = text.strip()
23
+ if "```" not in stripped:
24
+ return text
25
+ match = re.search(r"```(?:json|JSON)?\s*\n?(.*?)(?:```|$)", stripped, re.DOTALL)
26
+ return match.group(1).strip() if match else text
27
+
28
+
29
+ def remove_comments(text: str) -> str:
30
+ """Remove ``//`` line and ``/* */`` block comments outside strings."""
31
+ out: list[str] = []
32
+ i = 0
33
+ in_string = False
34
+ quote = ""
35
+ escaped = False
36
+ while i < len(text):
37
+ ch = text[i]
38
+ if in_string:
39
+ out.append(ch)
40
+ if escaped:
41
+ escaped = False
42
+ elif ch == "\\":
43
+ escaped = True
44
+ elif ch == quote:
45
+ in_string = False
46
+ i += 1
47
+ continue
48
+ if ch in ("'", '"'):
49
+ in_string, quote = True, ch
50
+ out.append(ch)
51
+ i += 1
52
+ continue
53
+ if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
54
+ while i < len(text) and text[i] != "\n":
55
+ i += 1
56
+ continue
57
+ if ch == "/" and i + 1 < len(text) and text[i + 1] == "*":
58
+ i += 2
59
+ while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
60
+ i += 1
61
+ i += 2
62
+ continue
63
+ out.append(ch)
64
+ i += 1
65
+ return "".join(out)
66
+
67
+
68
+ def single_to_double_quotes(text: str) -> str:
69
+ """Convert single-quoted strings to double-quoted strings."""
70
+ out: list[str] = []
71
+ in_string = False
72
+ quote = ""
73
+ escaped = False
74
+ for ch in text:
75
+ if in_string:
76
+ if escaped:
77
+ out.append(ch)
78
+ escaped = False
79
+ elif ch == "\\":
80
+ out.append(ch)
81
+ escaped = True
82
+ elif ch == quote:
83
+ out.append('"')
84
+ in_string = False
85
+ elif ch == '"' and quote == "'":
86
+ out.append('\\"')
87
+ else:
88
+ out.append(ch)
89
+ else:
90
+ if ch in ("'", '"'):
91
+ in_string, quote = True, ch
92
+ out.append('"')
93
+ else:
94
+ out.append(ch)
95
+ return "".join(out)
96
+
97
+
98
+ _LITERAL_MAP = {
99
+ "True": "true",
100
+ "False": "false",
101
+ "None": "null",
102
+ "undefined": "null",
103
+ "NaN": "null",
104
+ "Infinity": "null",
105
+ "-Infinity": "null",
106
+ }
107
+ _LITERAL_RE = re.compile(r"\b(True|False|None|undefined|NaN|Infinity|-Infinity)\b")
108
+
109
+
110
+ def fix_literals(text: str) -> str:
111
+ """Replace Python/JavaScript literals with JSON ones, outside strings."""
112
+ out: list[str] = []
113
+ buffer: list[str] = []
114
+ for _, ch, in_string, _ in iter_chars(text):
115
+ if in_string:
116
+ if buffer:
117
+ out.append(_LITERAL_RE.sub(lambda m: _LITERAL_MAP[m.group(1)], "".join(buffer)))
118
+ buffer = []
119
+ out.append(ch)
120
+ else:
121
+ buffer.append(ch)
122
+ if buffer:
123
+ out.append(_LITERAL_RE.sub(lambda m: _LITERAL_MAP[m.group(1)], "".join(buffer)))
124
+ return "".join(out)
125
+
126
+
127
+ _UNQUOTED_KEY_RE = re.compile(r"([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)")
128
+
129
+
130
+ def quote_unquoted_keys(text: str) -> str:
131
+ """Quote bare identifier keys: ``{name: 1}`` -> ``{"name": 1}``."""
132
+ out: list[str] = []
133
+ buffer: list[str] = []
134
+ for _, ch, in_string, _ in iter_chars(text):
135
+ if in_string:
136
+ if buffer:
137
+ out.append(_UNQUOTED_KEY_RE.sub(r'\1"\2"\3', "".join(buffer)))
138
+ buffer = []
139
+ out.append(ch)
140
+ else:
141
+ buffer.append(ch)
142
+ if buffer:
143
+ out.append(_UNQUOTED_KEY_RE.sub(r'\1"\2"\3', "".join(buffer)))
144
+ return "".join(out)
145
+
146
+
147
+ def remove_trailing_commas(text: str) -> str:
148
+ """Drop commas that immediately precede ``}`` or ``]`` outside strings."""
149
+ chars = [(ch, in_string) for _, ch, in_string, _ in iter_chars(text)]
150
+ out: list[str] = []
151
+ for idx, (ch, in_string) in enumerate(chars):
152
+ if ch == "," and not in_string:
153
+ lookahead = idx + 1
154
+ while lookahead < len(chars) and chars[lookahead][0] in " \t\r\n":
155
+ lookahead += 1
156
+ if lookahead < len(chars) and chars[lookahead][0] in "}]":
157
+ continue
158
+ out.append(ch)
159
+ return "".join(out)
160
+
161
+
162
+ def close_truncated(text: str) -> str:
163
+ """Close an unterminated string and any unclosed brackets, in order."""
164
+ result = text.rstrip()
165
+ if not result:
166
+ return result
167
+ # A dangling escape at the very end would swallow the closing quote.
168
+ if result.endswith("\\") and not result.endswith("\\\\"):
169
+ result = result[:-1]
170
+ closers = ""
171
+ if in_string_at_end(result):
172
+ closers += '"'
173
+ closers += "".join(unclosed_brackets(result))
174
+ return result + closers
175
+
176
+
177
+ RULES = [
178
+ strip_code_fences,
179
+ remove_comments,
180
+ single_to_double_quotes,
181
+ fix_literals,
182
+ quote_unquoted_keys,
183
+ remove_trailing_commas,
184
+ close_truncated,
185
+ ]
186
+
187
+
188
+ def repair(text: str) -> str:
189
+ """Apply every repair rule in order and return the repaired text."""
190
+ for rule in RULES:
191
+ text = rule(text)
192
+ return text
llmparse/scanner.py ADDED
@@ -0,0 +1,92 @@
1
+ """Character scanners shared by extraction and repair.
2
+
3
+ Everything here walks the text once while tracking whether we are
4
+ inside a string, so brackets or comment markers inside string values
5
+ are never mistaken for structure. Strings may be double-quoted,
6
+ single-quoted, or (in truncated output) unterminated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def iter_chars(text: str):
13
+ """Yield ``(index, char, in_string, quote_char)`` for each position.
14
+
15
+ ``in_string`` describes the state *at* the character: opening and
16
+ closing quote characters themselves are yielded with
17
+ ``in_string == False`` for the opener and ``True`` for the closer.
18
+ Backslash escapes inside strings are honoured.
19
+ """
20
+ in_string = False
21
+ quote = ""
22
+ escaped = False
23
+ for i, ch in enumerate(text):
24
+ if in_string:
25
+ yield i, ch, True, quote
26
+ if escaped:
27
+ escaped = False
28
+ elif ch == "\\":
29
+ escaped = True
30
+ elif ch == quote:
31
+ in_string = False
32
+ quote = ""
33
+ else:
34
+ yield i, ch, False, ""
35
+ if ch in ("'", '"'):
36
+ in_string = True
37
+ quote = ch
38
+
39
+
40
+ def find_balanced_end(text: str, start: int) -> int | None:
41
+ """Return the index just past the bracket matching ``text[start]``.
42
+
43
+ ``text[start]`` must be ``{`` or ``[``. Returns ``None`` when the
44
+ structure is truncated (no matching close before end of text).
45
+ """
46
+ pairs = {"{": "}", "[": "]"}
47
+ stack: list[str] = []
48
+ for i, ch, in_string, _ in iter_chars(text):
49
+ if i < start or in_string:
50
+ continue
51
+ if ch in pairs:
52
+ stack.append(pairs[ch])
53
+ elif ch in ("}", "]"):
54
+ if not stack or stack[-1] != ch:
55
+ return None
56
+ stack.pop()
57
+ if not stack:
58
+ return i + 1
59
+ return None
60
+
61
+
62
+ def unclosed_brackets(text: str) -> list[str]:
63
+ """Return the closing brackets needed to balance ``text``, in order."""
64
+ pairs = {"{": "}", "[": "]"}
65
+ stack: list[str] = []
66
+ for _, ch, in_string, _ in iter_chars(text):
67
+ if in_string:
68
+ continue
69
+ if ch in pairs:
70
+ stack.append(pairs[ch])
71
+ elif ch in ("}", "]") and stack and stack[-1] == ch:
72
+ stack.pop()
73
+ return list(reversed(stack))
74
+
75
+
76
+ def in_string_at_end(text: str) -> bool:
77
+ """True when ``text`` ends inside an unterminated string."""
78
+ in_string = False
79
+ quote = ""
80
+ escaped = False
81
+ for ch in text:
82
+ if in_string:
83
+ if escaped:
84
+ escaped = False
85
+ elif ch == "\\":
86
+ escaped = True
87
+ elif ch == quote:
88
+ in_string = False
89
+ elif ch in ("'", '"'):
90
+ in_string = True
91
+ quote = ch
92
+ return in_string
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmparse-py
3
+ Version: 0.1.1
4
+ Summary: Parse the JSON your LLM meant to send — zero-dependency extraction and repair for raw LLM output.
5
+ Author: gptfei
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/gptfei/llmparse
8
+ Project-URL: Repository, https://github.com/gptfei/llmparse
9
+ Project-URL: Issues, https://github.com/gptfei/llmparse/issues
10
+ Keywords: llm,json,parser,repair,openai,chatgpt,claude,structured-output
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Text Processing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # llmparse
23
+
24
+ **Parse the JSON your LLM meant to send.**
25
+
26
+ LLMs promise JSON and deliver markdown fences, apologies, single
27
+ quotes, trailing commas, Python literals — and, when the token budget
28
+ runs out, JSON that stops mid-string. `llmparse` is a zero-dependency
29
+ Python library that extracts the JSON from raw model output and repairs
30
+ the damage, so `json.loads` stops being the most fragile line in your
31
+ pipeline.
32
+
33
+ ```python
34
+ from llmparse import parse
35
+
36
+ parse('Sure! Here you go:\n```json\n{name: "ada", tags: ["x", "y",], ok: True}\n```')
37
+ # {'name': 'ada', 'tags': ['x', 'y'], 'ok': True}
38
+
39
+ parse('{"name": "ada", "tags": ["x", "y"') # truncated stream
40
+ # {'name': 'ada', 'tags': ['x', 'y']}
41
+ ```
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install llmparse-py # once published to PyPI
47
+ pip install git+https://github.com/gptfei/llmparse
48
+ ```
49
+
50
+ Python 3.9+. No third-party dependencies, ever.
51
+
52
+ ## What it fixes
53
+
54
+ | Damage in LLM output | Example |
55
+ | --- | --- |
56
+ | Markdown fences | ` ```json ... ``` ` |
57
+ | Prose around the JSON | `Here is the result: {...} hope it helps` |
58
+ | Single-quoted strings | `{'a': 'b'}` |
59
+ | Unquoted keys | `{name: "ada"}` |
60
+ | Python / JS literals | `True`, `None`, `undefined`, `NaN` |
61
+ | Comments | `{"a": 1} // note` |
62
+ | Trailing commas | `{"a": [1, 2,]}` |
63
+ | Truncation | `{"msg": "hello wor` → closed and parsed |
64
+
65
+ Brackets and comment markers *inside* string values are never touched.
66
+
67
+ ## API
68
+
69
+ - `parse(text, default=...)` — return the recovered Python object.
70
+ Raises `LLMParseError` when nothing is recoverable, unless `default`
71
+ is given. `loads` is an alias.
72
+ - `extract(text)` — return the first recoverable JSON substring, or `None`.
73
+ - `repair(text)` — return repaired JSON *text* (for when you need the
74
+ string, not the object).
75
+
76
+ Valid JSON is returned untouched: every repair rule is a no-op on
77
+ input that already parses.
78
+
79
+ ## Contributing
80
+
81
+ The repair pipeline is a list of small, independent rules — adding one
82
+ is the easiest meaningful contribution in open source right now:
83
+
84
+ 1. Find a way an LLM broke JSON that llmparse does not fix yet.
85
+ 2. Write the rule in `src/llmparse/repair.py`, register it in `RULES`.
86
+ 3. Add a test with the broken input.
87
+
88
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and issues labelled
89
+ `good first issue`.
90
+
91
+ ## 中文简介
92
+
93
+ 大模型说好返回 JSON,实际经常带 markdown 围栏、解释文字、单引号、
94
+ 尾随逗号、Python 的 `True/None`,甚至输出到一半被截断。`llmparse`
95
+ 是一个零依赖的 Python 库:从原始输出里提取 JSON、逐项修复这些损坏、
96
+ 再解析成 Python 对象。合法 JSON 不会被改动。
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ llmparse/__init__.py,sha256=YbcAqlgvNMQTRghyvOQP410hx3Vr_OtiIXzi1nq2exI,428
2
+ llmparse/core.py,sha256=UUp4659XQwAumRHSwKS8DZXYcFp1EhhMTUigHzs9tHM,2774
3
+ llmparse/repair.py,sha256=h10sSYRKoVMBXnGNlYFllEcBHOADQxxRWOhn4QV9c-8,5851
4
+ llmparse/scanner.py,sha256=ocIMyCSeiq-Cc_fno-2Cy1gflmXf5B0TLWg8H4IfMd8,2856
5
+ llmparse_py-0.1.1.dist-info/licenses/LICENSE,sha256=Cj0XVFIO6j89kpPNcfpffKbP5HMCbWcewAPMdY8yNVA,1063
6
+ llmparse_py-0.1.1.dist-info/METADATA,sha256=1ooBStwl7afNJpkY23tLomNioNw6r-LoySjMESaj11k,3505
7
+ llmparse_py-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ llmparse_py-0.1.1.dist-info/top_level.txt,sha256=DpTx5hVsGMrhzNpXRrrWbQcYUtEihokf2IyX2P-MGeM,9
9
+ llmparse_py-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gptfei
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
+ llmparse