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,40 @@
1
+ """Schema-guided structured output parsing."""
2
+
3
+ from .core import ParserConfig, ParseResult, RecoveryConfig, RepairConfig, RepairError
4
+ from .envelope import EnvelopeScanner, EnvelopeSpec
5
+ from .langchain import XStructuredResult, XStructuredRunnable, with_xstructured_output
6
+ from .parser import StructuredParser
7
+ from .schema import (
8
+ NamedSchemas,
9
+ NamedSchemaSpec,
10
+ SchemaInfo,
11
+ fingerprint_schema,
12
+ inspect_named_schemas,
13
+ inspect_schema,
14
+ schema_instructions,
15
+ )
16
+ from .streaming import StreamDecoder, StreamEvent, StreamEventKind
17
+
18
+ __all__ = [
19
+ "EnvelopeScanner",
20
+ "EnvelopeSpec",
21
+ "NamedSchemaSpec",
22
+ "NamedSchemas",
23
+ "ParseResult",
24
+ "ParserConfig",
25
+ "RecoveryConfig",
26
+ "RepairConfig",
27
+ "RepairError",
28
+ "SchemaInfo",
29
+ "StreamDecoder",
30
+ "StreamEvent",
31
+ "StreamEventKind",
32
+ "StructuredParser",
33
+ "XStructuredResult",
34
+ "XStructuredRunnable",
35
+ "fingerprint_schema",
36
+ "inspect_named_schemas",
37
+ "inspect_schema",
38
+ "schema_instructions",
39
+ "with_xstructured_output",
40
+ ]
@@ -0,0 +1,25 @@
1
+ """Core configuration, errors, and result types."""
2
+
3
+ from .config import ParserConfig, RecoveryConfig, RepairConfig
4
+ from .errors import (
5
+ EnvelopeError,
6
+ ParseError,
7
+ RecoveryError,
8
+ RepairError,
9
+ SchemaError,
10
+ XStructuredError,
11
+ )
12
+ from .result import ParseResult
13
+
14
+ __all__ = [
15
+ "EnvelopeError",
16
+ "ParseError",
17
+ "ParseResult",
18
+ "ParserConfig",
19
+ "RecoveryConfig",
20
+ "RecoveryError",
21
+ "RepairConfig",
22
+ "RepairError",
23
+ "SchemaError",
24
+ "XStructuredError",
25
+ ]
@@ -0,0 +1,58 @@
1
+ """Configuration for structured parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+
8
+ class RecoveryConfig(BaseModel):
9
+ """Controls conservative normalization before retrying JSON parsing."""
10
+
11
+ model_config = ConfigDict(frozen=True, extra="forbid")
12
+
13
+ enabled: bool = True
14
+ strip_markdown_fences: bool = True
15
+ strip_surrounding_text: bool = True
16
+ max_candidates: int = Field(default=8, ge=1, le=100)
17
+
18
+
19
+ class RepairConfig(BaseModel):
20
+ """Controls the optional, bounded LLM-assisted repair fallback.
21
+
22
+ Repair only runs when the caller explicitly supplies a repair
23
+ ``Runnable`` (for example via
24
+ ``with_xstructured_output(..., repair=repair_runnable)``); this config
25
+ only tunes how that opt-in fallback behaves once wired in. It is never
26
+ consulted when no repair ``Runnable`` is supplied, so existing
27
+ single-schema callers are unaffected by its defaults.
28
+ """
29
+
30
+ model_config = ConfigDict(frozen=True, extra="forbid")
31
+
32
+ enabled: bool = True
33
+ max_attempts: int = Field(default=1, ge=1, le=5)
34
+
35
+
36
+ class ParserConfig(BaseModel):
37
+ """Controls parser limits and recovery behavior."""
38
+
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ max_input_chars: int = Field(default=1_000_000, ge=1)
42
+ max_envelope_chars: int = Field(default=1_000_000, ge=1)
43
+ max_payload_chars: int = Field(default=1_000_000, ge=1)
44
+ max_nesting_depth: int = Field(default=100, ge=1)
45
+ recovery: RecoveryConfig = Field(default_factory=RecoveryConfig)
46
+ require_envelope: bool = False
47
+
48
+ @field_validator(
49
+ "max_input_chars",
50
+ "max_envelope_chars",
51
+ "max_payload_chars",
52
+ "max_nesting_depth",
53
+ )
54
+ @classmethod
55
+ def validate_positive_limit(cls, value: int) -> int:
56
+ if value < 1:
57
+ raise ValueError("resource limits must be positive")
58
+ return value
@@ -0,0 +1,51 @@
1
+ """Domain-specific exceptions with parse context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ class XStructuredError(Exception):
10
+ """Base exception for xstructured failures."""
11
+
12
+
13
+ class SchemaError(XStructuredError):
14
+ """Raised when a schema target cannot be introspected or validated."""
15
+
16
+
17
+ class EnvelopeError(XStructuredError):
18
+ """Raised for invalid envelope specifications or envelope extraction."""
19
+
20
+
21
+ @dataclass(eq=False)
22
+ class ParseError(XStructuredError):
23
+ """Raised when content cannot be parsed as JSON."""
24
+
25
+ message: str
26
+ text: str
27
+ cause: Exception | None = None
28
+
29
+ def __str__(self) -> str:
30
+ return self.message
31
+
32
+
33
+ @dataclass(eq=False)
34
+ class RecoveryError(ParseError):
35
+ """Raised after every configured recovery candidate has failed."""
36
+
37
+ attempts: tuple[str, ...] = ()
38
+ validation_errors: Any | None = None
39
+
40
+
41
+ @dataclass(eq=False)
42
+ class RepairError(ParseError):
43
+ """Raised when bounded, opt-in LLM-assisted repair exhausts its attempts.
44
+
45
+ Only raised when a repair ``Runnable`` was explicitly configured (see
46
+ ``RepairConfig``); repair is never attempted, and this error is never
47
+ raised, by default.
48
+ """
49
+
50
+ attempt_count: int = 0
51
+ repair_errors: tuple[str, ...] = ()
@@ -0,0 +1,32 @@
1
+ """Result values returned by structured parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Generic, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ParseResult(Generic[T]):
13
+ """A validated value and the exact parsing path used to produce it."""
14
+
15
+ value: T
16
+ raw: str
17
+ json_text: str
18
+ recovered: bool = False
19
+ envelope_found: bool = False
20
+ schema_name: str | None = None
21
+ """The matched schema name, when parsing against named multiple schemas.
22
+
23
+ ``None`` when the parser was constructed with a single schema target.
24
+ """
25
+ repaired: bool = False
26
+ """Whether this value came from the optional, bounded LLM repair fallback.
27
+
28
+ Always ``False`` for a plain `StructuredParser.parse` call; only ever
29
+ ``True`` when `xstructured.langchain.repair.Repairer` produced the value
30
+ after conservative recovery was exhausted.
31
+ """
32
+ repair_attempt_count: int = 0
@@ -0,0 +1,6 @@
1
+ """Delimited envelope specifications and streaming scanner."""
2
+
3
+ from .scanner import EnvelopeScanner, EnvelopeState, ScanEvent
4
+ from .spec import EnvelopeSpec
5
+
6
+ __all__ = ["EnvelopeScanner", "EnvelopeSpec", "EnvelopeState", "ScanEvent"]
@@ -0,0 +1,174 @@
1
+ """Incremental scanner for delimited structured payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ from xstructured.core.errors import EnvelopeError
9
+
10
+ from .spec import EnvelopeSpec
11
+
12
+
13
+ class EnvelopeState(StrEnum):
14
+ SEEKING_START = "seeking_start"
15
+ COLLECTING = "collecting"
16
+ COMPLETE = "complete"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ScanEvent:
21
+ """The scanner's observable state after accepting input."""
22
+
23
+ state: EnvelopeState
24
+ payload: str | None = None
25
+
26
+
27
+ class EnvelopeScanner:
28
+ """Find one envelope across arbitrary text chunks."""
29
+
30
+ def __init__(
31
+ self,
32
+ spec: EnvelopeSpec | None = None,
33
+ *,
34
+ max_envelope_chars: int = 1_000_000,
35
+ max_payload_chars: int = 1_000_000,
36
+ ) -> None:
37
+ if max_envelope_chars < 1 or max_payload_chars < 1:
38
+ raise ValueError("Envelope limits must be positive")
39
+ self.spec = spec or EnvelopeSpec()
40
+ self._max_envelope_chars = max_envelope_chars
41
+ self._max_payload_chars = max_payload_chars
42
+ self._state = EnvelopeState.SEEKING_START
43
+ self._buffer = ""
44
+ self._payload: str | None = None
45
+ self._payload_parts: list[str] = []
46
+ self._payload_chars = 0
47
+ self._in_string = False
48
+ self._escaped = False
49
+
50
+ @property
51
+ def state(self) -> EnvelopeState:
52
+ return self._state
53
+
54
+ @property
55
+ def payload(self) -> str | None:
56
+ return self._payload
57
+
58
+ @property
59
+ def complete(self) -> bool:
60
+ return self._state is EnvelopeState.COMPLETE
61
+
62
+ def feed(self, chunk: str) -> ScanEvent:
63
+ """Accept a chunk and return the resulting scanner event."""
64
+ if not isinstance(chunk, str):
65
+ raise TypeError("Envelope scanner chunks must be strings")
66
+ if self.complete:
67
+ if chunk:
68
+ raise EnvelopeError("Cannot feed data after envelope completion")
69
+ return ScanEvent(self._state, self._payload)
70
+
71
+ self._buffer += chunk
72
+ if self._state is EnvelopeState.SEEKING_START:
73
+ start_at = self._buffer.find(self.spec.start)
74
+ if start_at < 0:
75
+ self._buffer = (
76
+ self._buffer[-(len(self.spec.start) - 1) :] if len(self.spec.start) > 1 else ""
77
+ )
78
+ return ScanEvent(self._state)
79
+ self._buffer = self._buffer[start_at + len(self.spec.start) :]
80
+ self._state = EnvelopeState.COLLECTING
81
+
82
+ end_at = _find_json_safe_delimiter(
83
+ self._buffer,
84
+ self.spec.end,
85
+ in_string=self._in_string,
86
+ escaped=self._escaped,
87
+ )
88
+ if end_at < 0:
89
+ safe_length = max(0, len(self._buffer) - len(self.spec.end) + 1)
90
+ self._append_payload(self._take(safe_length))
91
+ self._validate_incomplete_limits()
92
+ return ScanEvent(self._state)
93
+
94
+ self._append_payload(self._take(end_at))
95
+ envelope_size = len(self.spec.start) + self._payload_chars + len(self.spec.end)
96
+ if envelope_size > self._max_envelope_chars:
97
+ raise EnvelopeError(
98
+ f"Envelope exceeds configured limit of {self._max_envelope_chars} characters"
99
+ )
100
+ self._take(len(self.spec.end))
101
+ self._payload = "".join(self._payload_parts)
102
+ self._buffer = ""
103
+ self._state = EnvelopeState.COMPLETE
104
+ return ScanEvent(self._state, self._payload)
105
+
106
+ def finalize(self) -> str:
107
+ """Return the payload, or raise when the envelope is incomplete."""
108
+ if self._payload is None:
109
+ raise EnvelopeError("No complete envelope was found")
110
+ return self._payload
111
+
112
+ def _validate_incomplete_limits(self) -> None:
113
+ envelope_size = len(self.spec.start) + self._payload_chars + len(self._buffer)
114
+ if envelope_size > self._max_envelope_chars:
115
+ raise EnvelopeError(
116
+ f"Envelope exceeds configured limit of {self._max_envelope_chars} characters"
117
+ )
118
+
119
+ def _append_payload(self, text: str) -> None:
120
+ if not text:
121
+ return
122
+ self._payload_parts.append(text)
123
+ self._payload_chars += len(text)
124
+ if self._payload_chars > self._max_payload_chars:
125
+ raise EnvelopeError(
126
+ f"Envelope payload exceeds configured limit of {self._max_payload_chars} characters"
127
+ )
128
+ self._in_string, self._escaped = _json_string_state(
129
+ text,
130
+ in_string=self._in_string,
131
+ escaped=self._escaped,
132
+ )
133
+
134
+ def _take(self, length: int) -> str:
135
+ value = self._buffer[:length]
136
+ self._buffer = self._buffer[length:]
137
+ return value
138
+
139
+
140
+ def _find_json_safe_delimiter(
141
+ text: str,
142
+ delimiter: str,
143
+ *,
144
+ in_string: bool = False,
145
+ escaped: bool = False,
146
+ ) -> int:
147
+ """Find a delimiter outside JSON strings, including escaped string content."""
148
+ for index, character in enumerate(text):
149
+ if in_string:
150
+ if escaped:
151
+ escaped = False
152
+ elif character == "\\":
153
+ escaped = True
154
+ elif character == '"':
155
+ in_string = False
156
+ elif character == '"':
157
+ in_string = True
158
+ elif text.startswith(delimiter, index):
159
+ return index
160
+ return -1
161
+
162
+
163
+ def _json_string_state(text: str, *, in_string: bool, escaped: bool) -> tuple[bool, bool]:
164
+ for character in text:
165
+ if in_string:
166
+ if escaped:
167
+ escaped = False
168
+ elif character == "\\":
169
+ escaped = True
170
+ elif character == '"':
171
+ in_string = False
172
+ elif character == '"':
173
+ in_string = True
174
+ return in_string, escaped
@@ -0,0 +1,25 @@
1
+ """Envelope delimiter specification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from xstructured.core.errors import EnvelopeError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class EnvelopeSpec:
12
+ """Opening and closing delimiters surrounding a structured payload."""
13
+
14
+ start: str = "<xstructured>"
15
+ end: str = "</xstructured>"
16
+
17
+ def __post_init__(self) -> None:
18
+ if not self.start or not self.end:
19
+ raise EnvelopeError("Envelope delimiters must be non-empty")
20
+ if self.start == self.end:
21
+ raise EnvelopeError("Envelope delimiters must differ")
22
+
23
+ def wrap(self, payload: str) -> str:
24
+ """Wrap *payload* with this specification's delimiters."""
25
+ return f"{self.start}{payload}{self.end}"
@@ -0,0 +1,10 @@
1
+ """LangChain-facing xstructured integration."""
2
+
3
+ from .result import XStructuredResult
4
+ from .runnable import XStructuredRunnable, with_xstructured_output
5
+
6
+ __all__ = [
7
+ "XStructuredResult",
8
+ "XStructuredRunnable",
9
+ "with_xstructured_output",
10
+ ]
@@ -0,0 +1,19 @@
1
+ """Internal helpers shared by the LangChain integration modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain_core.messages import BaseMessage
8
+
9
+
10
+ def output_text(output: Any) -> str:
11
+ """Extract the text content of a wrapped or repair Runnable's raw output."""
12
+ if isinstance(output, str):
13
+ return output
14
+ if isinstance(output, BaseMessage):
15
+ return output.text
16
+ raise TypeError(
17
+ "The wrapped Runnable must return strings or LangChain "
18
+ f"BaseMessage values, not {type(output).__name__}"
19
+ )
@@ -0,0 +1,129 @@
1
+ """Optional, bounded LLM-assisted repair for otherwise-unparseable output.
2
+
3
+ Recovery (`xstructured.core.RecoveryConfig`) only ever changes which
4
+ substring of a response is treated as JSON; it never rewrites syntax (see
5
+ ADR 0003). Repair is a separate, strictly opt-in escape hatch: it only runs
6
+ when the caller supplies a *repair* `Runnable` to `with_xstructured_output`,
7
+ and it is bounded by `RepairConfig.max_attempts` so it can never loop
8
+ indefinitely. Every repaired response is re-validated by the same
9
+ `StructuredParser` -- the same schema(s) and the same recovery rules -- as
10
+ the original response, so repair can change what an *invalid* response was,
11
+ but never weaken what counts as *valid*.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import replace
17
+ from typing import Any, Generic, TypeVar
18
+
19
+ from langchain_core.runnables import Runnable, RunnableConfig
20
+
21
+ from xstructured.core import ParseError, ParseResult, RecoveryError, RepairConfig, RepairError
22
+ from xstructured.parser import StructuredParser
23
+
24
+ from ._support import output_text
25
+
26
+ T = TypeVar("T")
27
+
28
+ _REPAIR_PREAMBLE = (
29
+ "The following response failed schema validation and must be corrected. "
30
+ "Return only a corrected response; do not add commentary or explanation."
31
+ )
32
+
33
+
34
+ class Repairer(Generic[T]):
35
+ """Bounded, opt-in retry loop that asks a Runnable to fix invalid output."""
36
+
37
+ def __init__(
38
+ self,
39
+ parser: StructuredParser[T],
40
+ repair_runnable: Runnable[Any, Any],
41
+ config: RepairConfig,
42
+ instructions: str,
43
+ ) -> None:
44
+ self._parser = parser
45
+ self._repair_runnable = repair_runnable
46
+ self._config = config
47
+ self._instructions = instructions
48
+
49
+ def repair(
50
+ self,
51
+ text: str,
52
+ error: ParseError,
53
+ *,
54
+ config: RunnableConfig | None = None,
55
+ ) -> ParseResult[T]:
56
+ """Synchronously retry parsing, invoking the repair Runnable each attempt."""
57
+ if not self._config.enabled:
58
+ raise error
59
+ current_text, current_error = text, error
60
+ for attempt in range(1, self._config.max_attempts + 1):
61
+ prompt = self._build_prompt(current_text, current_error, attempt)
62
+ response = self._repair_runnable.invoke(prompt, config=config)
63
+ current_text = output_text(response)
64
+ try:
65
+ return replace(
66
+ self._parser.parse(current_text),
67
+ repaired=True,
68
+ repair_attempt_count=attempt,
69
+ )
70
+ except ParseError as new_error:
71
+ current_error = new_error
72
+ raise self._exhausted(current_error)
73
+
74
+ async def arepair(
75
+ self,
76
+ text: str,
77
+ error: ParseError,
78
+ *,
79
+ config: RunnableConfig | None = None,
80
+ ) -> ParseResult[T]:
81
+ """Asynchronously retry parsing, invoking the repair Runnable each attempt."""
82
+ if not self._config.enabled:
83
+ raise error
84
+ current_text, current_error = text, error
85
+ for attempt in range(1, self._config.max_attempts + 1):
86
+ prompt = self._build_prompt(current_text, current_error, attempt)
87
+ response = await self._repair_runnable.ainvoke(prompt, config=config)
88
+ current_text = output_text(response)
89
+ try:
90
+ return replace(
91
+ self._parser.parse(current_text),
92
+ repaired=True,
93
+ repair_attempt_count=attempt,
94
+ )
95
+ except ParseError as new_error:
96
+ current_error = new_error
97
+ raise self._exhausted(current_error)
98
+
99
+ def _build_prompt(self, text: str, error: ParseError, attempt: int) -> str:
100
+ messages = _error_messages(error)
101
+ lines = [
102
+ _REPAIR_PREAMBLE,
103
+ "",
104
+ self._instructions,
105
+ "",
106
+ f"Repair attempt {attempt} of {self._config.max_attempts}.",
107
+ "Validation errors:",
108
+ *(f"- {message}" for message in messages),
109
+ "",
110
+ "Invalid response:",
111
+ text,
112
+ ]
113
+ return "\n".join(lines)
114
+
115
+ def _exhausted(self, error: ParseError) -> RepairError:
116
+ return RepairError(
117
+ "LLM-assisted repair did not produce schema-valid output within "
118
+ f"the configured attempt budget ({self._config.max_attempts})",
119
+ error.text,
120
+ error,
121
+ attempt_count=self._config.max_attempts,
122
+ repair_errors=_error_messages(error),
123
+ )
124
+
125
+
126
+ def _error_messages(error: ParseError) -> tuple[str, ...]:
127
+ if isinstance(error, RecoveryError) and error.attempts:
128
+ return error.attempts
129
+ return (str(error),)
@@ -0,0 +1,41 @@
1
+ """Result type returned by the LangChain integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from types import MappingProxyType
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class XStructuredResult(Generic[T]):
15
+ """Natural language, validated data, and the unmodified model response."""
16
+
17
+ content: str
18
+ structured: T
19
+ raw: Any
20
+ raw_text: str
21
+ json_text: str
22
+ recovered: bool = False
23
+ metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
24
+ schema_name: str | None = None
25
+ """Which named schema matched, when wrapped with multiple named schemas.
26
+
27
+ ``None`` when `with_xstructured_output` was called with a single schema
28
+ target.
29
+ """
30
+ repaired: bool = False
31
+ """Whether the optional, bounded LLM repair fallback produced this value.
32
+
33
+ Always ``False`` unless a *repair* `Runnable` was supplied to
34
+ `with_xstructured_output` and conservative recovery was exhausted first.
35
+ """
36
+ repair_attempt_count: int = 0
37
+
38
+ @property
39
+ def text(self) -> str:
40
+ """Alias for the natural-language content."""
41
+ return self.content