xstructured 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.
- xstructured/__init__.py +40 -0
- xstructured/core/__init__.py +25 -0
- xstructured/core/config.py +58 -0
- xstructured/core/errors.py +51 -0
- xstructured/core/result.py +32 -0
- xstructured/envelope/__init__.py +6 -0
- xstructured/envelope/scanner.py +174 -0
- xstructured/envelope/spec.py +25 -0
- xstructured/langchain/__init__.py +10 -0
- xstructured/langchain/_support.py +19 -0
- xstructured/langchain/repair.py +129 -0
- xstructured/langchain/result.py +41 -0
- xstructured/langchain/runnable.py +416 -0
- xstructured/parser/__init__.py +6 -0
- xstructured/parser/json_safety.py +59 -0
- xstructured/parser/parser.py +198 -0
- xstructured/parser/recovery.py +34 -0
- xstructured/py.typed +1 -0
- xstructured/schema/__init__.py +19 -0
- xstructured/schema/fingerprint.py +64 -0
- xstructured/schema/instructions.py +77 -0
- xstructured/schema/introspection.py +55 -0
- xstructured/schema/named.py +77 -0
- xstructured/streaming/__init__.py +6 -0
- xstructured/streaming/decoder.py +207 -0
- xstructured/streaming/events.py +30 -0
- xstructured-0.1.0.dist-info/METADATA +217 -0
- xstructured-0.1.0.dist-info/RECORD +30 -0
- xstructured-0.1.0.dist-info/WHEEL +4 -0
- xstructured-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""LangChain Runnable integration for the xstructured protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any, Generic, Literal, TypeVar, cast, overload
|
|
10
|
+
|
|
11
|
+
from langchain_core.messages import BaseMessage, SystemMessage
|
|
12
|
+
from langchain_core.prompt_values import PromptValue
|
|
13
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
14
|
+
|
|
15
|
+
from xstructured.core import ParseError, ParserConfig, RepairConfig
|
|
16
|
+
from xstructured.envelope import EnvelopeSpec
|
|
17
|
+
from xstructured.parser import StructuredParser
|
|
18
|
+
from xstructured.schema import (
|
|
19
|
+
NamedSchemas,
|
|
20
|
+
NamedSchemaTargets,
|
|
21
|
+
SchemaTarget,
|
|
22
|
+
fingerprint_schema,
|
|
23
|
+
inspect_named_schemas,
|
|
24
|
+
schema_instructions,
|
|
25
|
+
)
|
|
26
|
+
from xstructured.streaming import StreamDecoder, StreamEvent, StreamEventKind
|
|
27
|
+
|
|
28
|
+
from ._support import output_text
|
|
29
|
+
from .repair import Repairer
|
|
30
|
+
from .result import XStructuredResult
|
|
31
|
+
|
|
32
|
+
Input = TypeVar("Input")
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XStructuredRunnable(
|
|
37
|
+
Runnable[Input, XStructuredResult[T] | StreamEvent[T]],
|
|
38
|
+
Generic[Input, T],
|
|
39
|
+
):
|
|
40
|
+
"""A Runnable that applies and decodes the xstructured protocol."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
runnable: Runnable[Input, Any],
|
|
45
|
+
schema: SchemaTarget | NamedSchemaTargets | NamedSchemas,
|
|
46
|
+
*,
|
|
47
|
+
envelope: EnvelopeSpec | None = None,
|
|
48
|
+
parser_config: ParserConfig | None = None,
|
|
49
|
+
multiple: bool = False,
|
|
50
|
+
multiple_envelopes: bool = False,
|
|
51
|
+
inject_instructions: bool = True,
|
|
52
|
+
repair: Runnable[Any, Any] | None = None,
|
|
53
|
+
repair_config: RepairConfig | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._runnable = runnable
|
|
56
|
+
self.envelope = envelope or EnvelopeSpec()
|
|
57
|
+
config = parser_config or ParserConfig()
|
|
58
|
+
if not config.require_envelope:
|
|
59
|
+
config = config.model_copy(update={"require_envelope": True})
|
|
60
|
+
self._parser: StructuredParser[T] = StructuredParser(
|
|
61
|
+
schema,
|
|
62
|
+
config=config,
|
|
63
|
+
envelope=self.envelope,
|
|
64
|
+
multiple=multiple,
|
|
65
|
+
multiple_envelopes=multiple_envelopes,
|
|
66
|
+
)
|
|
67
|
+
instruction_schema = (
|
|
68
|
+
inspect_named_schemas(schema) if isinstance(schema, Mapping) else schema
|
|
69
|
+
)
|
|
70
|
+
self.instructions = schema_instructions(
|
|
71
|
+
instruction_schema,
|
|
72
|
+
envelope=self.envelope.start,
|
|
73
|
+
multiple=multiple,
|
|
74
|
+
multiple_envelopes=multiple_envelopes,
|
|
75
|
+
)
|
|
76
|
+
self.schema_fingerprint = fingerprint_schema(schema)
|
|
77
|
+
self.inject_instructions = inject_instructions
|
|
78
|
+
self.multiple = multiple
|
|
79
|
+
self.multiple_envelopes = multiple_envelopes
|
|
80
|
+
self._repairer: Repairer[T] | None = (
|
|
81
|
+
Repairer(self._parser, repair, repair_config or RepairConfig(), self.instructions)
|
|
82
|
+
if repair is not None
|
|
83
|
+
else None
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def invoke(
|
|
87
|
+
self,
|
|
88
|
+
input: Input, # noqa: A002 - LangChain Runnable API compatibility
|
|
89
|
+
config: RunnableConfig | None = None,
|
|
90
|
+
**kwargs: Any | None,
|
|
91
|
+
) -> XStructuredResult[T]:
|
|
92
|
+
"""Invoke the wrapped Runnable and parse its complete response."""
|
|
93
|
+
raw = self._runnable.invoke(
|
|
94
|
+
self._prepare_input(input),
|
|
95
|
+
config=config,
|
|
96
|
+
**kwargs,
|
|
97
|
+
)
|
|
98
|
+
return self._parse_output(raw, config=config)
|
|
99
|
+
|
|
100
|
+
async def ainvoke(
|
|
101
|
+
self,
|
|
102
|
+
input: Input, # noqa: A002 - LangChain Runnable API compatibility
|
|
103
|
+
config: RunnableConfig | None = None,
|
|
104
|
+
**kwargs: Any | None,
|
|
105
|
+
) -> XStructuredResult[T]:
|
|
106
|
+
"""Asynchronously invoke and parse the wrapped Runnable."""
|
|
107
|
+
raw = await self._runnable.ainvoke(
|
|
108
|
+
self._prepare_input(input),
|
|
109
|
+
config=config,
|
|
110
|
+
**kwargs,
|
|
111
|
+
)
|
|
112
|
+
return await self._aparse_output(raw, config=config)
|
|
113
|
+
|
|
114
|
+
def stream(
|
|
115
|
+
self,
|
|
116
|
+
input: Input, # noqa: A002 - LangChain Runnable API compatibility
|
|
117
|
+
config: RunnableConfig | None = None,
|
|
118
|
+
**kwargs: Any | None,
|
|
119
|
+
) -> Iterator[StreamEvent[T]]:
|
|
120
|
+
"""Yield ordered protocol events from the wrapped Runnable's stream."""
|
|
121
|
+
if self.multiple_envelopes:
|
|
122
|
+
raise ValueError("Streaming multiple named envelopes is not supported")
|
|
123
|
+
decoder = StreamDecoder(self._parser, self.envelope)
|
|
124
|
+
raw_chunks: list[Any] = []
|
|
125
|
+
raw_text_parts: list[str] = []
|
|
126
|
+
structured: T | None = None
|
|
127
|
+
structured_found = False
|
|
128
|
+
|
|
129
|
+
for raw_chunk in self._runnable.stream(self._prepare_input(input), config=config, **kwargs):
|
|
130
|
+
raw_chunks.append(raw_chunk)
|
|
131
|
+
text = output_text(raw_chunk)
|
|
132
|
+
raw_text_parts.append(text)
|
|
133
|
+
for event in decoder.feed(text):
|
|
134
|
+
if event.kind is StreamEventKind.STRUCTURED_END:
|
|
135
|
+
structured = event.structured
|
|
136
|
+
structured_found = True
|
|
137
|
+
yield event
|
|
138
|
+
|
|
139
|
+
yield from decoder.finalize()
|
|
140
|
+
if not structured_found:
|
|
141
|
+
raise RuntimeError("Streaming decoder completed without a structured value")
|
|
142
|
+
parsed = self._parser.parse("".join(raw_text_parts))
|
|
143
|
+
result = self._build_result(
|
|
144
|
+
raw=tuple(raw_chunks),
|
|
145
|
+
raw_text="".join(raw_text_parts),
|
|
146
|
+
content=decoder.text,
|
|
147
|
+
structured=cast(T, structured),
|
|
148
|
+
json_text=parsed.json_text,
|
|
149
|
+
recovered=parsed.recovered,
|
|
150
|
+
schema_name=parsed.schema_name,
|
|
151
|
+
repaired=parsed.repaired,
|
|
152
|
+
repair_attempt_count=parsed.repair_attempt_count,
|
|
153
|
+
stream_completed=True,
|
|
154
|
+
)
|
|
155
|
+
yield StreamEvent(
|
|
156
|
+
kind=StreamEventKind.RESULT,
|
|
157
|
+
sequence=decoder.next_sequence,
|
|
158
|
+
result=result,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def astream(
|
|
162
|
+
self,
|
|
163
|
+
input: Input, # noqa: A002 - LangChain Runnable API compatibility
|
|
164
|
+
config: RunnableConfig | None = None,
|
|
165
|
+
**kwargs: Any | None,
|
|
166
|
+
) -> AsyncIterator[StreamEvent[T]]:
|
|
167
|
+
"""Asynchronously yield ordered protocol events."""
|
|
168
|
+
if self.multiple_envelopes:
|
|
169
|
+
raise ValueError("Streaming multiple named envelopes is not supported")
|
|
170
|
+
decoder = StreamDecoder(self._parser, self.envelope)
|
|
171
|
+
raw_chunks: list[Any] = []
|
|
172
|
+
raw_text_parts: list[str] = []
|
|
173
|
+
structured: T | None = None
|
|
174
|
+
structured_found = False
|
|
175
|
+
|
|
176
|
+
async for raw_chunk in self._runnable.astream(
|
|
177
|
+
self._prepare_input(input), config=config, **kwargs
|
|
178
|
+
):
|
|
179
|
+
raw_chunks.append(raw_chunk)
|
|
180
|
+
text = output_text(raw_chunk)
|
|
181
|
+
raw_text_parts.append(text)
|
|
182
|
+
for event in decoder.feed(text):
|
|
183
|
+
if event.kind is StreamEventKind.STRUCTURED_END:
|
|
184
|
+
structured = event.structured
|
|
185
|
+
structured_found = True
|
|
186
|
+
yield event
|
|
187
|
+
|
|
188
|
+
for event in decoder.finalize():
|
|
189
|
+
yield event
|
|
190
|
+
if not structured_found:
|
|
191
|
+
raise RuntimeError("Streaming decoder completed without a structured value")
|
|
192
|
+
parsed = self._parser.parse("".join(raw_text_parts))
|
|
193
|
+
result = self._build_result(
|
|
194
|
+
raw=tuple(raw_chunks),
|
|
195
|
+
raw_text="".join(raw_text_parts),
|
|
196
|
+
content=decoder.text,
|
|
197
|
+
structured=cast(T, structured),
|
|
198
|
+
json_text=parsed.json_text,
|
|
199
|
+
recovered=parsed.recovered,
|
|
200
|
+
schema_name=parsed.schema_name,
|
|
201
|
+
repaired=parsed.repaired,
|
|
202
|
+
repair_attempt_count=parsed.repair_attempt_count,
|
|
203
|
+
stream_completed=True,
|
|
204
|
+
)
|
|
205
|
+
yield StreamEvent(
|
|
206
|
+
kind=StreamEventKind.RESULT,
|
|
207
|
+
sequence=decoder.next_sequence,
|
|
208
|
+
result=result,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _prepare_input(self, value: Input) -> Input:
|
|
212
|
+
if not self.inject_instructions:
|
|
213
|
+
return value
|
|
214
|
+
if isinstance(value, str):
|
|
215
|
+
return cast(Input, f"{value}\n\n{self.instructions}")
|
|
216
|
+
if isinstance(value, PromptValue):
|
|
217
|
+
messages = [
|
|
218
|
+
SystemMessage(content=self.instructions),
|
|
219
|
+
*value.to_messages(),
|
|
220
|
+
]
|
|
221
|
+
return cast(Input, messages)
|
|
222
|
+
if (
|
|
223
|
+
isinstance(value, Sequence)
|
|
224
|
+
and not isinstance(value, (str, bytes))
|
|
225
|
+
and all(isinstance(item, BaseMessage) for item in value)
|
|
226
|
+
):
|
|
227
|
+
return cast(
|
|
228
|
+
Input,
|
|
229
|
+
[SystemMessage(content=self.instructions), *value],
|
|
230
|
+
)
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
def _parse_output(
|
|
234
|
+
self, raw: Any, *, config: RunnableConfig | None = None
|
|
235
|
+
) -> XStructuredResult[T]:
|
|
236
|
+
raw_text = output_text(raw)
|
|
237
|
+
started = perf_counter()
|
|
238
|
+
try:
|
|
239
|
+
parsed = self._parser.parse(raw_text)
|
|
240
|
+
except ParseError as error:
|
|
241
|
+
if self._repairer is None:
|
|
242
|
+
raise
|
|
243
|
+
parsed = self._repairer.repair(raw_text, error, config=config)
|
|
244
|
+
parse_duration = perf_counter() - started
|
|
245
|
+
return self._build_result(
|
|
246
|
+
raw=raw,
|
|
247
|
+
raw_text=raw_text,
|
|
248
|
+
content=_natural_content(raw_text, self.envelope),
|
|
249
|
+
structured=parsed.value,
|
|
250
|
+
json_text=parsed.json_text,
|
|
251
|
+
recovered=parsed.recovered,
|
|
252
|
+
schema_name=parsed.schema_name,
|
|
253
|
+
repaired=parsed.repaired,
|
|
254
|
+
repair_attempt_count=parsed.repair_attempt_count,
|
|
255
|
+
parse_duration=parse_duration,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def _aparse_output(
|
|
259
|
+
self, raw: Any, *, config: RunnableConfig | None = None
|
|
260
|
+
) -> XStructuredResult[T]:
|
|
261
|
+
raw_text = output_text(raw)
|
|
262
|
+
started = perf_counter()
|
|
263
|
+
try:
|
|
264
|
+
parsed = self._parser.parse(raw_text)
|
|
265
|
+
except ParseError as error:
|
|
266
|
+
if self._repairer is None:
|
|
267
|
+
raise
|
|
268
|
+
parsed = await self._repairer.arepair(raw_text, error, config=config)
|
|
269
|
+
parse_duration = perf_counter() - started
|
|
270
|
+
return self._build_result(
|
|
271
|
+
raw=raw,
|
|
272
|
+
raw_text=raw_text,
|
|
273
|
+
content=_natural_content(raw_text, self.envelope),
|
|
274
|
+
structured=parsed.value,
|
|
275
|
+
json_text=parsed.json_text,
|
|
276
|
+
recovered=parsed.recovered,
|
|
277
|
+
schema_name=parsed.schema_name,
|
|
278
|
+
repaired=parsed.repaired,
|
|
279
|
+
repair_attempt_count=parsed.repair_attempt_count,
|
|
280
|
+
parse_duration=parse_duration,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _build_result(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
raw: Any,
|
|
287
|
+
raw_text: str,
|
|
288
|
+
content: str,
|
|
289
|
+
structured: T,
|
|
290
|
+
json_text: str,
|
|
291
|
+
recovered: bool,
|
|
292
|
+
schema_name: str | None = None,
|
|
293
|
+
repaired: bool = False,
|
|
294
|
+
repair_attempt_count: int = 0,
|
|
295
|
+
stream_completed: bool = True,
|
|
296
|
+
parse_duration: float = 0.0,
|
|
297
|
+
) -> XStructuredResult[T]:
|
|
298
|
+
envelope_count = raw_text.count(self.envelope.start)
|
|
299
|
+
metadata: dict[str, Any] = {
|
|
300
|
+
"schema_fingerprint": self.schema_fingerprint,
|
|
301
|
+
"envelope_detected": envelope_count > 0,
|
|
302
|
+
"envelope_count": envelope_count,
|
|
303
|
+
"parse_duration": parse_duration,
|
|
304
|
+
"validation_duration": parse_duration,
|
|
305
|
+
"repair_attempted": repaired,
|
|
306
|
+
"repair_attempt_count": repair_attempt_count,
|
|
307
|
+
"stream_completed": stream_completed,
|
|
308
|
+
}
|
|
309
|
+
if isinstance(raw, BaseMessage):
|
|
310
|
+
metadata.update(
|
|
311
|
+
{
|
|
312
|
+
"message_id": raw.id,
|
|
313
|
+
"message_name": raw.name,
|
|
314
|
+
"message_type": raw.type,
|
|
315
|
+
"response_metadata": dict(raw.response_metadata),
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
usage_metadata = getattr(raw, "usage_metadata", None)
|
|
319
|
+
if usage_metadata is not None:
|
|
320
|
+
metadata["usage_metadata"] = dict(usage_metadata)
|
|
321
|
+
return XStructuredResult(
|
|
322
|
+
content=content,
|
|
323
|
+
structured=structured,
|
|
324
|
+
raw=raw,
|
|
325
|
+
raw_text=raw_text,
|
|
326
|
+
json_text=json_text,
|
|
327
|
+
recovered=recovered,
|
|
328
|
+
metadata=MappingProxyType(metadata),
|
|
329
|
+
schema_name=schema_name,
|
|
330
|
+
repaired=repaired,
|
|
331
|
+
repair_attempt_count=repair_attempt_count,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@overload
|
|
336
|
+
def with_xstructured_output(
|
|
337
|
+
runnable: Runnable[Input, Any],
|
|
338
|
+
schema: SchemaTarget | NamedSchemaTargets | NamedSchemas,
|
|
339
|
+
*,
|
|
340
|
+
multiple: Literal[True],
|
|
341
|
+
envelope: EnvelopeSpec | None = None,
|
|
342
|
+
parser_config: ParserConfig | None = None,
|
|
343
|
+
inject_instructions: bool = True,
|
|
344
|
+
repair: Runnable[Any, Any] | None = None,
|
|
345
|
+
repair_config: RepairConfig | None = None,
|
|
346
|
+
) -> XStructuredRunnable[Input, list[T]]: ...
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@overload
|
|
350
|
+
def with_xstructured_output(
|
|
351
|
+
runnable: Runnable[Input, Any],
|
|
352
|
+
schema: SchemaTarget | NamedSchemaTargets | NamedSchemas,
|
|
353
|
+
*,
|
|
354
|
+
multiple: Literal[False] = False,
|
|
355
|
+
envelope: EnvelopeSpec | None = None,
|
|
356
|
+
parser_config: ParserConfig | None = None,
|
|
357
|
+
inject_instructions: bool = True,
|
|
358
|
+
repair: Runnable[Any, Any] | None = None,
|
|
359
|
+
repair_config: RepairConfig | None = None,
|
|
360
|
+
) -> XStructuredRunnable[Input, T]: ...
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def with_xstructured_output(
|
|
364
|
+
runnable: Runnable[Input, Any],
|
|
365
|
+
schema: SchemaTarget | NamedSchemaTargets | NamedSchemas,
|
|
366
|
+
*,
|
|
367
|
+
envelope: EnvelopeSpec | None = None,
|
|
368
|
+
parser_config: ParserConfig | None = None,
|
|
369
|
+
multiple: bool = False,
|
|
370
|
+
multiple_envelopes: bool = False,
|
|
371
|
+
inject_instructions: bool = True,
|
|
372
|
+
repair: Runnable[Any, Any] | None = None,
|
|
373
|
+
repair_config: RepairConfig | None = None,
|
|
374
|
+
) -> XStructuredRunnable[Input, T | list[T]]:
|
|
375
|
+
"""Wrap a Runnable with instruction injection and xstructured decoding.
|
|
376
|
+
|
|
377
|
+
*schema* may be a single schema target, or a named set of schema targets
|
|
378
|
+
(`Mapping[str, SchemaTarget]` or `NamedSchemas`) to validate the response
|
|
379
|
+
against one of several allowed shapes -- see `xstructured.schema.named`.
|
|
380
|
+
|
|
381
|
+
*repair* is an optional, strictly opt-in `Runnable` invoked, bounded by
|
|
382
|
+
`repair_config.max_attempts` (default 1), to ask a model to fix a
|
|
383
|
+
response that failed schema validation. It is never invoked unless
|
|
384
|
+
explicitly supplied here; see `xstructured.langchain.repair` for the
|
|
385
|
+
bounded retry contract.
|
|
386
|
+
"""
|
|
387
|
+
return XStructuredRunnable(
|
|
388
|
+
runnable,
|
|
389
|
+
schema,
|
|
390
|
+
envelope=envelope,
|
|
391
|
+
parser_config=parser_config,
|
|
392
|
+
multiple=multiple,
|
|
393
|
+
multiple_envelopes=multiple_envelopes,
|
|
394
|
+
inject_instructions=inject_instructions,
|
|
395
|
+
repair=repair,
|
|
396
|
+
repair_config=repair_config,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _natural_content(text: str, envelope: EnvelopeSpec) -> str:
|
|
401
|
+
named_pattern = re.compile(
|
|
402
|
+
r"<xstructured name=\"[A-Za-z0-9_.-]+\">.*?</xstructured>", re.DOTALL
|
|
403
|
+
)
|
|
404
|
+
if named_pattern.search(text):
|
|
405
|
+
return named_pattern.sub("", text)
|
|
406
|
+
start_at = text.find(envelope.start)
|
|
407
|
+
if start_at < 0:
|
|
408
|
+
return text
|
|
409
|
+
from xstructured.envelope import EnvelopeScanner
|
|
410
|
+
|
|
411
|
+
scanner = EnvelopeScanner(envelope)
|
|
412
|
+
scanner.feed(text)
|
|
413
|
+
if not scanner.complete or scanner.payload is None:
|
|
414
|
+
return text[:start_at]
|
|
415
|
+
end_at = start_at + len(envelope.start) + len(scanner.payload)
|
|
416
|
+
return text[:start_at] + text[end_at + len(envelope.end) :]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Bounded strict JSON decoding helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonSafetyError(ValueError):
|
|
10
|
+
"""Raised when JSON exceeds a configured safety constraint."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def loads_strict(text: str, *, max_nesting_depth: int) -> Any:
|
|
14
|
+
"""Decode standard JSON after rejecting excessive container nesting."""
|
|
15
|
+
_validate_nesting_depth(text, max_nesting_depth)
|
|
16
|
+
return json.loads(
|
|
17
|
+
text,
|
|
18
|
+
parse_constant=_reject_non_standard_constant,
|
|
19
|
+
object_pairs_hook=_reject_duplicate_keys,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_nesting_depth(text: str, max_nesting_depth: int) -> None:
|
|
24
|
+
depth = 0
|
|
25
|
+
in_string = False
|
|
26
|
+
escaped = False
|
|
27
|
+
|
|
28
|
+
for character in text:
|
|
29
|
+
if in_string:
|
|
30
|
+
if escaped:
|
|
31
|
+
escaped = False
|
|
32
|
+
elif character == "\\":
|
|
33
|
+
escaped = True
|
|
34
|
+
elif character == '"':
|
|
35
|
+
in_string = False
|
|
36
|
+
continue
|
|
37
|
+
if character == '"':
|
|
38
|
+
in_string = True
|
|
39
|
+
elif character in "[{":
|
|
40
|
+
depth += 1
|
|
41
|
+
if depth > max_nesting_depth:
|
|
42
|
+
raise JsonSafetyError(
|
|
43
|
+
f"JSON nesting exceeds configured limit of {max_nesting_depth}"
|
|
44
|
+
)
|
|
45
|
+
elif character in "]}":
|
|
46
|
+
depth -= 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reject_non_standard_constant(value: str) -> None:
|
|
50
|
+
raise JsonSafetyError(f"Non-standard JSON constant {value!r} is not permitted")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
54
|
+
result: dict[str, Any] = {}
|
|
55
|
+
for key, value in pairs:
|
|
56
|
+
if key in result:
|
|
57
|
+
raise JsonSafetyError(f"Duplicate JSON object key {key!r} is not permitted")
|
|
58
|
+
result[key] = value
|
|
59
|
+
return result
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Schema-aware JSON parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, Generic, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from xstructured.core import ParseError, ParserConfig, ParseResult, RecoveryError, SchemaError
|
|
10
|
+
from xstructured.envelope import EnvelopeScanner, EnvelopeSpec
|
|
11
|
+
from xstructured.schema import SchemaInfo, SchemaTarget, inspect_schema
|
|
12
|
+
from xstructured.schema.named import NamedSchemas, NamedSchemaTargets, inspect_named_schemas
|
|
13
|
+
|
|
14
|
+
from .json_safety import JsonSafetyError, loads_strict
|
|
15
|
+
from .recovery import recovery_candidates
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StructuredParser(Generic[T]):
|
|
21
|
+
"""Parse JSON text and validate it with a Pydantic v2 schema target.
|
|
22
|
+
|
|
23
|
+
*schema* is either a single schema target (a `BaseModel` subclass, a
|
|
24
|
+
`TypeAdapter`, or anything `TypeAdapter` accepts) or a *named* set of
|
|
25
|
+
schema targets -- a `Mapping[str, SchemaTarget]` or an already-built
|
|
26
|
+
`NamedSchemas`. With named schemas, the JSON payload must be a single
|
|
27
|
+
object shaped ``{"schema": "<name>", "payload": <value>}`` and
|
|
28
|
+
`ParseResult.schema_name` reports which named schema matched. With
|
|
29
|
+
``multiple=True``, the JSON value must be an array and each item is
|
|
30
|
+
validated independently; named schemas may be mixed in that array.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
schema: SchemaTarget | NamedSchemaTargets | NamedSchemas,
|
|
36
|
+
*,
|
|
37
|
+
config: ParserConfig | None = None,
|
|
38
|
+
envelope: EnvelopeSpec | None = None,
|
|
39
|
+
multiple: bool = False,
|
|
40
|
+
multiple_envelopes: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
if isinstance(schema, NamedSchemas):
|
|
43
|
+
self.schema: SchemaInfo | None = None
|
|
44
|
+
self.named_schemas: NamedSchemas | None = schema
|
|
45
|
+
elif isinstance(schema, Mapping):
|
|
46
|
+
self.schema = None
|
|
47
|
+
self.named_schemas = inspect_named_schemas(schema)
|
|
48
|
+
else:
|
|
49
|
+
self.schema = inspect_schema(schema)
|
|
50
|
+
self.named_schemas = None
|
|
51
|
+
self.config = config or ParserConfig()
|
|
52
|
+
self.envelope = envelope
|
|
53
|
+
self.multiple = multiple
|
|
54
|
+
self.multiple_envelopes = multiple_envelopes
|
|
55
|
+
|
|
56
|
+
def parse(self, text: str) -> ParseResult[T]:
|
|
57
|
+
"""Parse one response, optionally extracting its configured envelope."""
|
|
58
|
+
if not isinstance(text, str):
|
|
59
|
+
raise TypeError("Parser input must be a string")
|
|
60
|
+
if len(text) > self.config.max_input_chars:
|
|
61
|
+
raise ParseError(
|
|
62
|
+
f"Input exceeds configured limit of {self.config.max_input_chars} characters", text
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if self.multiple_envelopes:
|
|
66
|
+
return self._parse_named_envelopes(text)
|
|
67
|
+
json_text, envelope_found = self._extract_envelope(text)
|
|
68
|
+
candidates = [json_text]
|
|
69
|
+
if self.config.recovery.enabled:
|
|
70
|
+
candidates = list(
|
|
71
|
+
recovery_candidates(
|
|
72
|
+
json_text,
|
|
73
|
+
strip_markdown_fences=self.config.recovery.strip_markdown_fences,
|
|
74
|
+
strip_surrounding_text=self.config.recovery.strip_surrounding_text,
|
|
75
|
+
)
|
|
76
|
+
)[: self.config.recovery.max_candidates]
|
|
77
|
+
|
|
78
|
+
failures: list[str] = []
|
|
79
|
+
last_error: Exception | None = None
|
|
80
|
+
for index, candidate in enumerate(candidates):
|
|
81
|
+
try:
|
|
82
|
+
if len(candidate) > self.config.max_payload_chars:
|
|
83
|
+
raise JsonSafetyError(
|
|
84
|
+
"JSON payload exceeds configured limit of "
|
|
85
|
+
f"{self.config.max_payload_chars} characters"
|
|
86
|
+
)
|
|
87
|
+
decoded: Any = loads_strict(
|
|
88
|
+
candidate,
|
|
89
|
+
max_nesting_depth=self.config.max_nesting_depth,
|
|
90
|
+
)
|
|
91
|
+
value, schema_name = self._validate(decoded)
|
|
92
|
+
except (JsonSafetyError, ValueError, TypeError, SchemaError) as exc:
|
|
93
|
+
failures.append(str(exc))
|
|
94
|
+
last_error = exc
|
|
95
|
+
continue
|
|
96
|
+
return ParseResult(
|
|
97
|
+
value=value,
|
|
98
|
+
raw=text,
|
|
99
|
+
json_text=candidate,
|
|
100
|
+
recovered=index > 0,
|
|
101
|
+
envelope_found=envelope_found,
|
|
102
|
+
schema_name=schema_name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if self.config.recovery.enabled:
|
|
106
|
+
raise RecoveryError(
|
|
107
|
+
"Unable to parse and validate any recovery candidate",
|
|
108
|
+
text,
|
|
109
|
+
last_error,
|
|
110
|
+
tuple(failures),
|
|
111
|
+
) from last_error
|
|
112
|
+
detail = f": {last_error}" if last_error is not None else ""
|
|
113
|
+
raise ParseError(
|
|
114
|
+
f"Unable to parse and validate JSON{detail}", text, last_error
|
|
115
|
+
) from last_error
|
|
116
|
+
|
|
117
|
+
def _parse_named_envelopes(self, text: str) -> ParseResult[T]:
|
|
118
|
+
if self.named_schemas is None:
|
|
119
|
+
raise SchemaError("multiple_envelopes requires named schema targets")
|
|
120
|
+
pattern = re.compile(
|
|
121
|
+
r'<xstructured name="([A-Za-z0-9_.-]+)">(.*?)</xstructured>',
|
|
122
|
+
re.DOTALL,
|
|
123
|
+
)
|
|
124
|
+
matches = list(pattern.finditer(text))
|
|
125
|
+
if not matches:
|
|
126
|
+
raise ParseError("No named xstructured envelopes were found", text)
|
|
127
|
+
values: dict[str, Any] = {}
|
|
128
|
+
for match in matches:
|
|
129
|
+
name, payload = match.groups()
|
|
130
|
+
if name in values:
|
|
131
|
+
raise ParseError(f"Duplicate named envelope: {name!r}", text)
|
|
132
|
+
if name not in self.named_schemas.schemas:
|
|
133
|
+
raise ParseError(f"Unknown named envelope: {name!r}", text)
|
|
134
|
+
if len(payload) > self.config.max_payload_chars:
|
|
135
|
+
raise ParseError("Named envelope payload exceeds configured limit", text)
|
|
136
|
+
try:
|
|
137
|
+
decoded = loads_strict(payload, max_nesting_depth=self.config.max_nesting_depth)
|
|
138
|
+
values[name] = self.named_schemas.resolve(name).validate_python(decoded)
|
|
139
|
+
except (JsonSafetyError, ValueError, TypeError, SchemaError) as exc:
|
|
140
|
+
raise ParseError(f"Invalid named envelope {name!r}: {exc}", text, exc) from exc
|
|
141
|
+
return ParseResult(
|
|
142
|
+
value=cast(T, values),
|
|
143
|
+
raw=text,
|
|
144
|
+
json_text="".join(match.group(2) for match in matches),
|
|
145
|
+
envelope_found=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _validate(self, decoded: Any) -> tuple[Any, str | None]:
|
|
149
|
+
if self.multiple:
|
|
150
|
+
if not isinstance(decoded, list):
|
|
151
|
+
raise ValueError("Multiple payload mode requires a JSON array")
|
|
152
|
+
values: list[Any] = []
|
|
153
|
+
names: list[str] = []
|
|
154
|
+
for item in decoded:
|
|
155
|
+
value, name = self._validate_one(item)
|
|
156
|
+
values.append(value)
|
|
157
|
+
if name is not None:
|
|
158
|
+
names.append(name)
|
|
159
|
+
schema_name = names[0] if names and all(name == names[0] for name in names) else None
|
|
160
|
+
return values, schema_name
|
|
161
|
+
return self._validate_one(decoded)
|
|
162
|
+
|
|
163
|
+
def _validate_one(self, decoded: Any) -> tuple[Any, str | None]:
|
|
164
|
+
if self.schema is not None:
|
|
165
|
+
return self.schema.validate_python(decoded), None
|
|
166
|
+
assert self.named_schemas is not None
|
|
167
|
+
spec = self.named_schemas.spec
|
|
168
|
+
if not isinstance(decoded, dict):
|
|
169
|
+
raise ValueError("Named schema payloads must be a JSON object")
|
|
170
|
+
if spec.schema_key not in decoded:
|
|
171
|
+
raise ValueError(f"Missing required {spec.schema_key!r} discriminator key")
|
|
172
|
+
name = decoded[spec.schema_key]
|
|
173
|
+
if not isinstance(name, str):
|
|
174
|
+
raise ValueError(f"{spec.schema_key!r} must be a string schema name")
|
|
175
|
+
if spec.payload_key not in decoded:
|
|
176
|
+
raise ValueError(f"Missing required {spec.payload_key!r} payload key")
|
|
177
|
+
schema_info = self.named_schemas.resolve(name)
|
|
178
|
+
return schema_info.validate_python(decoded[spec.payload_key]), name
|
|
179
|
+
|
|
180
|
+
def _extract_envelope(self, text: str) -> tuple[str, bool]:
|
|
181
|
+
if self.envelope is None:
|
|
182
|
+
if self.config.require_envelope:
|
|
183
|
+
raise ParseError(
|
|
184
|
+
"An envelope is required but no envelope spec was configured",
|
|
185
|
+
text,
|
|
186
|
+
)
|
|
187
|
+
return text, False
|
|
188
|
+
scanner = EnvelopeScanner(
|
|
189
|
+
self.envelope,
|
|
190
|
+
max_envelope_chars=self.config.max_envelope_chars,
|
|
191
|
+
max_payload_chars=self.config.max_payload_chars,
|
|
192
|
+
)
|
|
193
|
+
scanner.feed(text)
|
|
194
|
+
if scanner.complete:
|
|
195
|
+
return scanner.finalize(), True
|
|
196
|
+
if self.config.require_envelope:
|
|
197
|
+
raise ParseError("No complete envelope was found", text)
|
|
198
|
+
return text, False
|