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.
@@ -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,6 @@
1
+ """JSON parser and conservative recovery strategies."""
2
+
3
+ from .parser import StructuredParser
4
+ from .recovery import recovery_candidates
5
+
6
+ __all__ = ["StructuredParser", "recovery_candidates"]
@@ -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