structx 0.6.0__tar.gz → 0.6.2__tar.gz

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.
Files changed (51) hide show
  1. {structx-0.6.0/structx.egg-info → structx-0.6.2}/PKG-INFO +5 -2
  2. {structx-0.6.0 → structx-0.6.2}/README.md +4 -1
  3. {structx-0.6.0 → structx-0.6.2}/pyproject.toml +3 -3
  4. {structx-0.6.0 → structx-0.6.2}/structx/__init__.py +17 -1
  5. {structx-0.6.0 → structx-0.6.2}/structx/core/models.py +13 -2
  6. {structx-0.6.0 → structx-0.6.2}/structx/core/type_system.py +61 -22
  7. {structx-0.6.0 → structx-0.6.2}/structx/extraction/generator.py +11 -6
  8. structx-0.6.2/structx/schema.py +368 -0
  9. {structx-0.6.0 → structx-0.6.2}/structx/utils/prompts.py +2 -1
  10. {structx-0.6.0 → structx-0.6.2/structx.egg-info}/PKG-INFO +5 -2
  11. {structx-0.6.0 → structx-0.6.2}/structx.egg-info/SOURCES.txt +2 -0
  12. structx-0.6.2/tests/test_schema_contract.py +169 -0
  13. {structx-0.6.0 → structx-0.6.2}/LICENSE +0 -0
  14. {structx-0.6.0 → structx-0.6.2}/setup.cfg +0 -0
  15. {structx-0.6.0 → structx-0.6.2}/structx/core/__init__.py +0 -0
  16. {structx-0.6.0 → structx-0.6.2}/structx/core/config.py +0 -0
  17. {structx-0.6.0 → structx-0.6.2}/structx/core/exceptions.py +0 -0
  18. {structx-0.6.0 → structx-0.6.2}/structx/core/input.py +0 -0
  19. {structx-0.6.0 → structx-0.6.2}/structx/extraction/__init__.py +0 -0
  20. {structx-0.6.0 → structx-0.6.2}/structx/extraction/core/__init__.py +0 -0
  21. {structx-0.6.0 → structx-0.6.2}/structx/extraction/core/llm_core.py +0 -0
  22. {structx-0.6.0 → structx-0.6.2}/structx/extraction/engines/__init__.py +0 -0
  23. {structx-0.6.0 → structx-0.6.2}/structx/extraction/engines/extraction_engine.py +0 -0
  24. {structx-0.6.0 → structx-0.6.2}/structx/extraction/extractor.py +0 -0
  25. {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/__init__.py +0 -0
  26. {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/batch_processor.py +0 -0
  27. {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/content_analyzer.py +0 -0
  28. {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/input_processor.py +0 -0
  29. {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/model_operations.py +0 -0
  30. {structx-0.6.0 → structx-0.6.2}/structx/extraction/result_manager.py +0 -0
  31. {structx-0.6.0 → structx-0.6.2}/structx/utils/__init__.py +0 -0
  32. {structx-0.6.0 → structx-0.6.2}/structx/utils/file_reader.py +0 -0
  33. {structx-0.6.0 → structx-0.6.2}/structx/utils/helpers.py +0 -0
  34. {structx-0.6.0 → structx-0.6.2}/structx/utils/types.py +0 -0
  35. {structx-0.6.0 → structx-0.6.2}/structx/utils/usage.py +0 -0
  36. {structx-0.6.0 → structx-0.6.2}/structx.egg-info/dependency_links.txt +0 -0
  37. {structx-0.6.0 → structx-0.6.2}/structx.egg-info/requires.txt +0 -0
  38. {structx-0.6.0 → structx-0.6.2}/structx.egg-info/top_level.txt +0 -0
  39. {structx-0.6.0 → structx-0.6.2}/tests/test_config_and_model_utils.py +0 -0
  40. {structx-0.6.0 → structx-0.6.2}/tests/test_content_analyzer.py +0 -0
  41. {structx-0.6.0 → structx-0.6.2}/tests/test_document_conversion_integration.py +0 -0
  42. {structx-0.6.0 → structx-0.6.2}/tests/test_extraction_engine.py +0 -0
  43. {structx-0.6.0 → structx-0.6.2}/tests/test_extractor_async.py +0 -0
  44. {structx-0.6.0 → structx-0.6.2}/tests/test_extractor_flow.py +0 -0
  45. {structx-0.6.0 → structx-0.6.2}/tests/test_file_reader.py +0 -0
  46. {structx-0.6.0 → structx-0.6.2}/tests/test_input_and_batch_processors.py +0 -0
  47. {structx-0.6.0 → structx-0.6.2}/tests/test_live_llm.py +0 -0
  48. {structx-0.6.0 → structx-0.6.2}/tests/test_llm_configuration.py +0 -0
  49. {structx-0.6.0 → structx-0.6.2}/tests/test_result_manager_and_helpers.py +0 -0
  50. {structx-0.6.0 → structx-0.6.2}/tests/test_type_system_and_planning.py +0 -0
  51. {structx-0.6.0 → structx-0.6.2}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structx
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Structured data extraction from text using LLMs and dynamic model generation
5
5
  Author-email: blacksuan19 <py@aolabs.dev>
6
6
  Classifier: Development Status :: 5 - Production/Stable
@@ -64,9 +64,12 @@ Advanced structured data extraction from any document using LLMs with multimodal
64
64
  support.
65
65
 
66
66
  [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg?style=for-the-badge)](https://structx.aolabs.dev "Documentation")
67
- [![PyPI](https://img.shields.io/badge/PyPi-0.6.0-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
67
+ [![PyPI](https://img.shields.io/badge/PyPi-0.6.2-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
68
68
  [![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](# "Build with GitHub Actions")
69
69
 
70
+ See the [project roadmap](docs/ROADMAP.md) for planned portable business rules
71
+ and semantic validation work.
72
+
70
73
  `structx` is a powerful Python library for extracting structured data from text,
71
74
  tables, and documents using Large Language Models (LLMs). It passes existing
72
75
  PDFs directly to vision-capable models; the optional `docs` extra converts other
@@ -4,9 +4,12 @@ Advanced structured data extraction from any document using LLMs with multimodal
4
4
  support.
5
5
 
6
6
  [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg?style=for-the-badge)](https://structx.aolabs.dev "Documentation")
7
- [![PyPI](https://img.shields.io/badge/PyPi-0.6.0-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
7
+ [![PyPI](https://img.shields.io/badge/PyPi-0.6.2-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
8
8
  [![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](# "Build with GitHub Actions")
9
9
 
10
+ See the [project roadmap](docs/ROADMAP.md) for planned portable business rules
11
+ and semantic validation work.
12
+
10
13
  `structx` is a powerful Python library for extracting structured data from text,
11
14
  tables, and documents using Large Language Models (LLMs). It passes existing
12
15
  PDFs directly to vision-capable models; the optional `docs` extra converts other
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structx"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Structured data extraction from text using LLMs and dynamic model generation"
9
9
  authors = [{ name = "blacksuan19", email = "py@aolabs.dev" }]
10
10
  license-files = ["LICENSE"]
@@ -92,9 +92,9 @@ torchvision = [
92
92
  ]
93
93
 
94
94
  [tool.bumpver]
95
- current_version = "0.6.0"
95
+ current_version = "0.6.2"
96
96
  version_pattern = "MAJOR.MINOR.PATCH"
97
- commit_message = "Bump version {old_version} -> {new_version}"
97
+ commit_message = "chore(release): bump version {old_version} -> {new_version}"
98
98
  commit = true
99
99
  tag = true
100
100
  push = true
@@ -11,8 +11,17 @@ from structx.core.models import (
11
11
  RowResult,
12
12
  )
13
13
  from structx.extraction.extractor import Extractor
14
+ from structx.schema import (
15
+ ContainerTypeCapability,
16
+ ScalarTypeCapability,
17
+ TypeCapabilities,
18
+ TypeModifierCapabilities,
19
+ get_type_capabilities,
20
+ model_from_extraction_request,
21
+ model_to_extraction_request,
22
+ )
14
23
 
15
- __version__ = "0.6.0"
24
+ __version__ = "0.6.2"
16
25
  __all__ = [
17
26
  "Extractor",
18
27
  "ExtractionConfig",
@@ -22,4 +31,11 @@ __all__ = [
22
31
  "ExtractionRequest",
23
32
  "ExtractionResult",
24
33
  "RowResult",
34
+ "ScalarTypeCapability",
35
+ "ContainerTypeCapability",
36
+ "TypeModifierCapabilities",
37
+ "TypeCapabilities",
38
+ "get_type_capabilities",
39
+ "model_to_extraction_request",
40
+ "model_from_extraction_request",
25
41
  ]
@@ -35,6 +35,13 @@ class ModelField(BaseModel):
35
35
  nullable: bool = Field(
36
36
  default=True, description="Whether the generated field may be null"
37
37
  )
38
+ has_default: bool = Field(
39
+ default=False, description="Whether the generated field has an explicit default"
40
+ )
41
+ default: Any = Field(
42
+ default=None,
43
+ description="JSON-serializable default value when has_default is true",
44
+ )
38
45
 
39
46
  @model_validator(mode="before")
40
47
  @classmethod
@@ -54,8 +61,12 @@ class ModelField(BaseModel):
54
61
 
55
62
  @model_validator(mode="after")
56
63
  def validate_presence_semantics(self) -> "ModelField":
57
- if not self.required and not self.nullable:
58
- raise ValueError("A non-required field must be nullable")
64
+ if self.required and self.has_default:
65
+ raise ValueError("A required field cannot also have a default")
66
+ if not self.required and not self.nullable and not self.has_default:
67
+ raise ValueError(
68
+ "A non-required field must be nullable unless it has an explicit default"
69
+ )
59
70
  return self
60
71
 
61
72
 
@@ -4,7 +4,18 @@ import ast
4
4
  import re
5
5
  from datetime import date, datetime, time
6
6
  from decimal import Decimal
7
- from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Set, Tuple, Type
7
+ from typing import (
8
+ Any,
9
+ Dict,
10
+ FrozenSet,
11
+ List,
12
+ Mapping,
13
+ Optional,
14
+ Set,
15
+ Tuple,
16
+ Type,
17
+ get_origin,
18
+ )
8
19
  from uuid import UUID
9
20
 
10
21
 
@@ -49,7 +60,7 @@ _ALIASES = {
49
60
  "uuid": "UUID",
50
61
  }
51
62
 
52
- _PRIMITIVES = {
63
+ CANONICAL_SCALAR_TYPES = (
53
64
  "Any",
54
65
  "bool",
55
66
  "date",
@@ -60,7 +71,33 @@ _PRIMITIVES = {
60
71
  "str",
61
72
  "time",
62
73
  "UUID",
63
- }
74
+ )
75
+
76
+ CANONICAL_CONTAINER_TYPES = ("List", "Set", "Dict", "FrozenSet", "Tuple")
77
+
78
+ SUPPORTED_FIELD_CONSTRAINTS = frozenset(
79
+ {
80
+ "alias",
81
+ "deprecated",
82
+ "examples",
83
+ "exclude",
84
+ "frozen",
85
+ "ge",
86
+ "gt",
87
+ "json_schema_extra",
88
+ "le",
89
+ "lt",
90
+ "max_length",
91
+ "min_length",
92
+ "multiple_of",
93
+ "pattern",
94
+ "repr",
95
+ "strict",
96
+ "title",
97
+ }
98
+ )
99
+
100
+ _PRIMITIVES = set(CANONICAL_SCALAR_TYPES)
64
101
 
65
102
  _RUNTIME_TYPES: Dict[str, Type[Any]] = {
66
103
  "Any": Any,
@@ -75,24 +112,16 @@ _RUNTIME_TYPES: Dict[str, Type[Any]] = {
75
112
  "UUID": UUID,
76
113
  }
77
114
 
78
- _FIELD_CONSTRAINTS = {
79
- "alias",
80
- "deprecated",
81
- "examples",
82
- "exclude",
83
- "frozen",
84
- "ge",
85
- "gt",
86
- "json_schema_extra",
87
- "le",
88
- "lt",
89
- "max_length",
90
- "min_length",
91
- "multiple_of",
92
- "pattern",
93
- "repr",
94
- "strict",
95
- "title",
115
+ _RUNTIME_TYPE_NAMES = {
116
+ runtime_type: name for name, runtime_type in _RUNTIME_TYPES.items()
117
+ }
118
+
119
+ _CONTAINER_ORIGIN_NAMES = {
120
+ list: "List",
121
+ set: "Set",
122
+ dict: "Dict",
123
+ frozenset: "FrozenSet",
124
+ tuple: "Tuple",
96
125
  }
97
126
 
98
127
  _LEGACY_TYPES = {
@@ -218,7 +247,7 @@ def _normalize_validation(
218
247
  normalized: Dict[str, Any] = {}
219
248
 
220
249
  for key, value in raw_validation.items():
221
- if key in _FIELD_CONSTRAINTS:
250
+ if key in SUPPORTED_FIELD_CONSTRAINTS:
222
251
  normalized[key] = value
223
252
 
224
253
  for source_key, target_key in _VALIDATION_ALIASES.items():
@@ -250,6 +279,16 @@ def normalize_field_definition(
250
279
  return normalize_type_expression(legacy_type), normalized_validation
251
280
 
252
281
 
282
+ def canonical_scalar_name(annotation: Any) -> Optional[str]:
283
+ """Return the canonical name for a supported runtime scalar type."""
284
+ return _RUNTIME_TYPE_NAMES.get(annotation)
285
+
286
+
287
+ def canonical_container_name(annotation: Any) -> Optional[str]:
288
+ """Return the canonical name for a supported container annotation."""
289
+ return _CONTAINER_ORIGIN_NAMES.get(get_origin(annotation) or annotation)
290
+
291
+
253
292
  def normalize_type_expression(value: str) -> str:
254
293
  """Normalize common JSON, TypeScript, and Python type syntax."""
255
294
  if not isinstance(value, str) or not value.strip():
@@ -38,6 +38,14 @@ class ModelGenerator:
38
38
  return Optional[field_type]
39
39
  return field_type
40
40
 
41
+ @staticmethod
42
+ def _field_default(field: ModelField):
43
+ if field.required:
44
+ return ...
45
+ if field.has_default:
46
+ return field.default
47
+ return None
48
+
41
49
  @classmethod
42
50
  def _create_nested_model(
43
51
  cls,
@@ -72,7 +80,7 @@ class ModelGenerator:
72
80
  field_definitions[field.name] = (
73
81
  cls._field_annotation(field, field_type),
74
82
  Field(
75
- default=... if field.required else None,
83
+ default=cls._field_default(field),
76
84
  description=field.description,
77
85
  **(field.validation or {}),
78
86
  ),
@@ -81,11 +89,7 @@ class ModelGenerator:
81
89
  model_name = f"{parent_name}{field_name}" if parent_name else field_name
82
90
 
83
91
  # Create the model using Pydantic v2 style
84
- model_options = (
85
- {"__config__": ConfigDict(frozen=True)}
86
- if frozen
87
- else {"__base__": BaseModel}
88
- )
92
+ model_options = {"__config__": ConfigDict(frozen=frozen, validate_default=True)}
89
93
  model = create_model(model_name, **model_options, **field_definitions)
90
94
 
91
95
  # Add description as model docstring
@@ -117,6 +121,7 @@ class ModelGenerator:
117
121
  field_description=request.model_description,
118
122
  nested_fields=request.fields,
119
123
  )
124
+ setattr(model, "__structx_definition__", request.model_copy(deep=True))
120
125
 
121
126
  logger.debug("Model generation completed")
122
127
 
@@ -0,0 +1,368 @@
1
+ """Portable schema definitions and UI-facing type capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import UnionType
6
+ from typing import (
7
+ Annotated,
8
+ Any,
9
+ Dict,
10
+ List,
11
+ Mapping,
12
+ Optional,
13
+ Type,
14
+ Union,
15
+ get_args,
16
+ get_origin,
17
+ )
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+ from pydantic_core import PydanticUndefined, to_jsonable_python
21
+
22
+ from structx.core.exceptions import ModelGenerationError
23
+ from structx.core.models import ExtractionRequest, ModelField
24
+ from structx.core.type_system import (
25
+ CANONICAL_CONTAINER_TYPES,
26
+ CANONICAL_SCALAR_TYPES,
27
+ SUPPORTED_FIELD_CONSTRAINTS,
28
+ canonical_container_name,
29
+ canonical_scalar_name,
30
+ )
31
+ from structx.extraction.generator import ModelGenerator
32
+
33
+
34
+ class ScalarTypeCapability(BaseModel):
35
+ """One canonical scalar type available to schema builders."""
36
+
37
+ model_config = ConfigDict(frozen=True)
38
+
39
+ id: str
40
+ label: str
41
+ constraints: List[str] = Field(default_factory=list)
42
+ advanced: bool = False
43
+
44
+
45
+ class ContainerTypeCapability(BaseModel):
46
+ """One canonical container and the item shapes supported by StructX."""
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ id: str
51
+ label: str
52
+ description: str
53
+ allowed_item_kinds: List[str]
54
+ ordered: bool
55
+ unique: bool
56
+ advanced: bool = False
57
+
58
+
59
+ class TypeModifierCapabilities(BaseModel):
60
+ """Presence and nullability controls kept separate from value types."""
61
+
62
+ model_config = ConfigDict(frozen=True)
63
+
64
+ required: bool = True
65
+ nullable: bool = True
66
+ default: bool = True
67
+
68
+
69
+ class TypeCapabilities(BaseModel):
70
+ """Versioned canonical type catalog for external schema builders."""
71
+
72
+ model_config = ConfigDict(frozen=True)
73
+
74
+ schema_version: str = "1"
75
+ scalars: List[ScalarTypeCapability]
76
+ containers: List[ContainerTypeCapability]
77
+ modifiers: TypeModifierCapabilities = Field(
78
+ default_factory=TypeModifierCapabilities
79
+ )
80
+
81
+
82
+ _STRING_CONSTRAINTS = ["min_length", "max_length", "pattern", "strict"]
83
+ _NUMBER_CONSTRAINTS = ["gt", "ge", "lt", "le", "multiple_of", "strict"]
84
+ _SCALAR_METADATA = {
85
+ "str": ("Text", _STRING_CONSTRAINTS, False),
86
+ "int": ("Integer", _NUMBER_CONSTRAINTS, False),
87
+ "float": ("Number", _NUMBER_CONSTRAINTS, False),
88
+ "Decimal": ("Decimal", _NUMBER_CONSTRAINTS, False),
89
+ "bool": ("Boolean", ["strict"], False),
90
+ "date": ("Date", ["strict"], False),
91
+ "datetime": ("Date and time", ["strict"], False),
92
+ "time": ("Time", ["strict"], False),
93
+ "UUID": ("UUID", ["strict"], False),
94
+ "Any": ("Any value", [], True),
95
+ }
96
+
97
+ _CONTAINER_METADATA = {
98
+ "List": {
99
+ "label": "List",
100
+ "description": "Ordered values that may contain duplicates",
101
+ "allowed_item_kinds": ["scalar", "object", "container"],
102
+ "ordered": True,
103
+ "unique": False,
104
+ },
105
+ "Set": {
106
+ "label": "Set",
107
+ "description": "Unique values without a stable order",
108
+ "allowed_item_kinds": ["hashable_scalar", "object"],
109
+ "ordered": False,
110
+ "unique": True,
111
+ },
112
+ "Dict": {
113
+ "label": "Map",
114
+ "description": "Text keys mapped to values",
115
+ "allowed_item_kinds": ["scalar", "container"],
116
+ "ordered": False,
117
+ "unique": True,
118
+ "advanced": True,
119
+ },
120
+ "FrozenSet": {
121
+ "label": "Frozen set",
122
+ "description": "Immutable unique values without a stable order",
123
+ "allowed_item_kinds": ["hashable_scalar", "object"],
124
+ "ordered": False,
125
+ "unique": True,
126
+ "advanced": True,
127
+ },
128
+ "Tuple": {
129
+ "label": "Tuple",
130
+ "description": "Fixed-position values",
131
+ "allowed_item_kinds": ["scalar", "object"],
132
+ "ordered": True,
133
+ "unique": False,
134
+ "advanced": True,
135
+ },
136
+ }
137
+
138
+ _TYPE_CAPABILITIES = TypeCapabilities(
139
+ scalars=[
140
+ ScalarTypeCapability(
141
+ id=type_id,
142
+ label=_SCALAR_METADATA[type_id][0],
143
+ constraints=_SCALAR_METADATA[type_id][1],
144
+ advanced=_SCALAR_METADATA[type_id][2],
145
+ )
146
+ for type_id in CANONICAL_SCALAR_TYPES
147
+ ],
148
+ containers=[
149
+ ContainerTypeCapability(id=type_id, **_CONTAINER_METADATA[type_id])
150
+ for type_id in CANONICAL_CONTAINER_TYPES
151
+ ],
152
+ )
153
+
154
+
155
+ def get_type_capabilities() -> TypeCapabilities:
156
+ """Return the canonical, alias-free type catalog for schema-builder UIs."""
157
+ return _TYPE_CAPABILITIES.model_copy(deep=True)
158
+
159
+
160
+ def _unsupported(message: str) -> ModelGenerationError:
161
+ return ModelGenerationError(message)
162
+
163
+
164
+ def _model_has_custom_behavior(model: Type[BaseModel]) -> bool:
165
+ decorators = getattr(model, "__pydantic_decorators__", None)
166
+ if decorators is not None:
167
+ collections = (
168
+ "validators",
169
+ "field_validators",
170
+ "root_validators",
171
+ "field_serializers",
172
+ "model_serializers",
173
+ "model_validators",
174
+ "computed_fields",
175
+ )
176
+ if any(getattr(decorators, name, {}) for name in collections):
177
+ return True
178
+ return bool(getattr(model, "model_computed_fields", {}))
179
+
180
+
181
+ def _unwrap_annotated(annotation: Any) -> Any:
182
+ if get_origin(annotation) is Annotated:
183
+ return get_args(annotation)[0]
184
+ return annotation
185
+
186
+
187
+ def _unwrap_nullable(annotation: Any) -> tuple[Any, bool]:
188
+ annotation = _unwrap_annotated(annotation)
189
+ origin = get_origin(annotation)
190
+ if origin not in {Union, UnionType}:
191
+ return annotation, False
192
+
193
+ arguments = get_args(annotation)
194
+ non_null = [argument for argument in arguments if argument is not type(None)]
195
+ if len(non_null) != len(arguments) - 1 or len(non_null) != 1:
196
+ raise _unsupported(
197
+ f"Only a single type combined with None is supported, received {annotation!r}"
198
+ )
199
+ return _unwrap_annotated(non_null[0]), True
200
+
201
+
202
+ def _model_fields(
203
+ model: Type[BaseModel], seen: set[Type[BaseModel]]
204
+ ) -> List[ModelField]:
205
+ if model in seen:
206
+ raise _unsupported(
207
+ f"Recursive model definitions are not supported: {model.__name__}"
208
+ )
209
+ if _model_has_custom_behavior(model):
210
+ raise _unsupported(
211
+ f"Custom validators, serializers, and computed fields cannot be serialized: {model.__name__}"
212
+ )
213
+
214
+ seen.add(model)
215
+ try:
216
+ return [
217
+ _field_from_pydantic(name, field, seen)
218
+ for name, field in model.model_fields.items()
219
+ ]
220
+ finally:
221
+ seen.remove(model)
222
+
223
+
224
+ def _annotation_definition(
225
+ annotation: Any, seen: set[Type[BaseModel]]
226
+ ) -> tuple[str, Optional[List[ModelField]], bool]:
227
+ annotation, nullable = _unwrap_nullable(annotation)
228
+ scalar = canonical_scalar_name(annotation)
229
+ if scalar is not None:
230
+ return scalar, None, nullable
231
+
232
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
233
+ return "Dict[str, Any]", _model_fields(annotation, seen), nullable
234
+
235
+ container = canonical_container_name(annotation)
236
+ if container is None:
237
+ raise _unsupported(f"Unsupported Pydantic field type: {annotation!r}")
238
+
239
+ arguments = get_args(annotation)
240
+ if container in {"List", "Set", "FrozenSet"}:
241
+ if len(arguments) != 1:
242
+ raise _unsupported(f"{container} requires one item type")
243
+ item_type, nested_fields, item_nullable = _annotation_definition(
244
+ arguments[0], seen
245
+ )
246
+ if item_nullable:
247
+ item_type = f"Optional[{item_type}]"
248
+ expression = f"{container}[{item_type}]"
249
+ elif container == "Dict":
250
+ if len(arguments) != 2:
251
+ raise _unsupported("Dict requires key and value types")
252
+ key_type, key_nested, key_nullable = _annotation_definition(arguments[0], seen)
253
+ value_type, value_nested, value_nullable = _annotation_definition(
254
+ arguments[1], seen
255
+ )
256
+ if key_nested or value_nested or key_nullable:
257
+ raise _unsupported(
258
+ "Nested models and nullable keys inside Dict are not supported"
259
+ )
260
+ if value_nullable:
261
+ value_type = f"Optional[{value_type}]"
262
+ expression = f"Dict[{key_type}, {value_type}]"
263
+ nested_fields = None
264
+ else:
265
+ if not arguments or Ellipsis in arguments:
266
+ raise _unsupported("Tuple requires explicit fixed-position item types")
267
+ item_definitions = [
268
+ _annotation_definition(argument, seen) for argument in arguments
269
+ ]
270
+ nested = [definition for definition in item_definitions if definition[1]]
271
+ if len(nested) > 1 or (nested and len(arguments) > 1):
272
+ raise _unsupported(
273
+ "Tuple cannot contain a nested model alongside other item types"
274
+ )
275
+ item_types = [
276
+ f"Optional[{item_type}]" if item_nullable else item_type
277
+ for item_type, _, item_nullable in item_definitions
278
+ ]
279
+ expression = f"Tuple[{', '.join(item_types)}]"
280
+ nested_fields = nested[0][1] if nested else None
281
+
282
+ if nullable:
283
+ expression = f"Optional[{expression}]"
284
+ return expression, nested_fields, nullable
285
+
286
+
287
+ def _field_validation(field: Any) -> Dict[str, Any]:
288
+ validation: Dict[str, Any] = {}
289
+ for key in SUPPORTED_FIELD_CONSTRAINTS:
290
+ value = getattr(field, key, None)
291
+ if key == "repr" and value is True:
292
+ continue
293
+ if value is not None and not callable(value):
294
+ validation[key] = to_jsonable_python(value)
295
+
296
+ for metadata in field.metadata:
297
+ for key in SUPPORTED_FIELD_CONSTRAINTS:
298
+ value = getattr(metadata, key, None)
299
+ if value is not None and not callable(value):
300
+ validation[key] = to_jsonable_python(value)
301
+ return validation
302
+
303
+
304
+ def _field_from_pydantic(
305
+ name: str, field: Any, seen: set[Type[BaseModel]]
306
+ ) -> ModelField:
307
+ if field.default_factory is not None:
308
+ raise _unsupported(f"Default factories cannot be serialized for field {name!r}")
309
+
310
+ field_type, nested_fields, nullable = _annotation_definition(field.annotation, seen)
311
+ required = field.is_required()
312
+ has_default = not required and field.default is not PydanticUndefined
313
+ default = to_jsonable_python(field.default) if has_default else None
314
+ if has_default and default is None:
315
+ nullable = True
316
+
317
+ return ModelField(
318
+ name=name,
319
+ type=field_type,
320
+ description=field.description or "",
321
+ validation=_field_validation(field),
322
+ nested_fields=nested_fields,
323
+ required=required,
324
+ nullable=nullable,
325
+ has_default=has_default,
326
+ default=default,
327
+ )
328
+
329
+
330
+ def model_to_extraction_request(
331
+ model: Type[BaseModel],
332
+ *,
333
+ model_name: Optional[str] = None,
334
+ model_description: Optional[str] = None,
335
+ ) -> ExtractionRequest:
336
+ """Convert a declarative Pydantic model into a portable definition."""
337
+ if not isinstance(model, type) or not issubclass(model, BaseModel):
338
+ raise TypeError("model must be a Pydantic BaseModel class")
339
+
340
+ stored_definition = getattr(model, "__structx_definition__", None)
341
+ if isinstance(stored_definition, ExtractionRequest):
342
+ updates = {}
343
+ if model_name is not None:
344
+ updates["model_name"] = model_name
345
+ if model_description is not None:
346
+ updates["model_description"] = model_description
347
+ return stored_definition.model_copy(deep=True, update=updates)
348
+
349
+ description = model_description
350
+ if description is None:
351
+ description = (model.__dict__.get("__doc__") or "").strip()
352
+ return ExtractionRequest(
353
+ model_name=model_name or model.__name__,
354
+ model_description=description,
355
+ fields=_model_fields(model, set()),
356
+ )
357
+
358
+
359
+ def model_from_extraction_request(
360
+ request: ExtractionRequest | Mapping[str, Any],
361
+ ) -> Type[BaseModel]:
362
+ """Create a runtime Pydantic model from a portable definition or JSON data."""
363
+ definition = (
364
+ request
365
+ if isinstance(request, ExtractionRequest)
366
+ else ExtractionRequest.model_validate(request)
367
+ )
368
+ return ModelGenerator.from_extraction_request(definition)
@@ -6,7 +6,8 @@ Use only these field type forms: str, int, float, bool, date, datetime, time,
6
6
  Decimal, UUID, Any, List[T], Dict[str, T], Set[T], and Optional[T].
7
7
  Fields are optional and nullable by default. Set required=true for core values
8
8
  explicitly requested by the user. Set nullable=false only together with
9
- required=true when null is invalid.
9
+ required=true or has_default=true when null is invalid. Use has_default and
10
+ default only when the user explicitly requests a default value.
10
11
  Do not add Optional unless it clarifies intent.
11
12
  Represent nested objects with nested_fields and use List[Any] or Dict[str, Any]
12
13
  only when a more precise type cannot be determined."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structx
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Structured data extraction from text using LLMs and dynamic model generation
5
5
  Author-email: blacksuan19 <py@aolabs.dev>
6
6
  Classifier: Development Status :: 5 - Production/Stable
@@ -64,9 +64,12 @@ Advanced structured data extraction from any document using LLMs with multimodal
64
64
  support.
65
65
 
66
66
  [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg?style=for-the-badge)](https://structx.aolabs.dev "Documentation")
67
- [![PyPI](https://img.shields.io/badge/PyPi-0.6.0-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
67
+ [![PyPI](https://img.shields.io/badge/PyPi-0.6.2-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
68
68
  [![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](# "Build with GitHub Actions")
69
69
 
70
+ See the [project roadmap](docs/ROADMAP.md) for planned portable business rules
71
+ and semantic validation work.
72
+
70
73
  `structx` is a powerful Python library for extracting structured data from text,
71
74
  tables, and documents using Large Language Models (LLMs). It passes existing
72
75
  PDFs directly to vision-capable models; the optional `docs` extra converts other
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  structx/__init__.py
5
+ structx/schema.py
5
6
  structx.egg-info/PKG-INFO
6
7
  structx.egg-info/SOURCES.txt
7
8
  structx.egg-info/dependency_links.txt
@@ -43,5 +44,6 @@ tests/test_input_and_batch_processors.py
43
44
  tests/test_live_llm.py
44
45
  tests/test_llm_configuration.py
45
46
  tests/test_result_manager_and_helpers.py
47
+ tests/test_schema_contract.py
46
48
  tests/test_type_system_and_planning.py
47
49
  tests/test_usage.py
@@ -0,0 +1,169 @@
1
+ import json
2
+ from datetime import date
3
+ from decimal import Decimal
4
+ from typing import Optional
5
+ from uuid import UUID
6
+
7
+ import pytest
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+ from structx import (
11
+ ExtractionRequest,
12
+ ModelField,
13
+ get_type_capabilities,
14
+ model_from_extraction_request,
15
+ model_to_extraction_request,
16
+ )
17
+ from structx.core.exceptions import ModelGenerationError
18
+
19
+
20
+ class Address(BaseModel):
21
+ street: str = Field(min_length=2)
22
+ postal_code: Optional[str] = None
23
+
24
+
25
+ class ManualRecord(BaseModel):
26
+ record_id: UUID
27
+ amount: Decimal = Decimal("1.50")
28
+ issued_on: date
29
+ tags: list[str]
30
+ unique_codes: set[int]
31
+ address: Address
32
+
33
+
34
+ def field_by_name(request: ExtractionRequest, name: str) -> ModelField:
35
+ return next(field for field in request.fields if field.name == name)
36
+
37
+
38
+ def test_type_capabilities_are_canonical_and_alias_free():
39
+ capabilities = get_type_capabilities()
40
+
41
+ scalar_ids = {item.id for item in capabilities.scalars}
42
+ container_ids = {item.id for item in capabilities.containers}
43
+
44
+ assert scalar_ids == {
45
+ "Any",
46
+ "bool",
47
+ "date",
48
+ "datetime",
49
+ "Decimal",
50
+ "float",
51
+ "int",
52
+ "str",
53
+ "time",
54
+ "UUID",
55
+ }
56
+ assert container_ids == {"Dict", "FrozenSet", "List", "Set", "Tuple"}
57
+ assert "string" not in scalar_ids
58
+ assert "array" not in container_ids
59
+ assert capabilities.modifiers.required is True
60
+ assert capabilities.modifiers.nullable is True
61
+
62
+
63
+ def test_manual_pydantic_model_round_trips_through_extraction_request():
64
+ request = model_to_extraction_request(
65
+ ManualRecord,
66
+ model_description="A manually defined record",
67
+ )
68
+
69
+ assert request.model_name == "ManualRecord"
70
+ assert request.model_description == "A manually defined record"
71
+
72
+ amount = field_by_name(request, "amount")
73
+ assert amount.type == "Decimal"
74
+ assert amount.required is False
75
+ assert amount.nullable is False
76
+ assert amount.has_default is True
77
+ assert amount.default == "1.50"
78
+
79
+ assert field_by_name(request, "tags").type == "List[str]"
80
+ assert field_by_name(request, "unique_codes").type == "Set[int]"
81
+
82
+ address = field_by_name(request, "address")
83
+ assert address.type == "Dict[str, Any]"
84
+ assert address.nested_fields is not None
85
+ assert [field.name for field in address.nested_fields] == [
86
+ "street",
87
+ "postal_code",
88
+ ]
89
+ assert address.nested_fields[0].validation == {"min_length": 2}
90
+
91
+ stored_definition = request.model_dump(mode="json")
92
+ rebuilt = model_from_extraction_request(stored_definition)
93
+ value = rebuilt(
94
+ record_id="64c26f2a-8fd0-4ef8-b1ec-7969ebbe91cb",
95
+ issued_on="2026-07-11",
96
+ tags=["one", "two"],
97
+ unique_codes=[1, 1, 2],
98
+ address={"street": "Main Street"},
99
+ )
100
+
101
+ assert value.record_id == UUID("64c26f2a-8fd0-4ef8-b1ec-7969ebbe91cb")
102
+ assert value.amount == Decimal("1.50")
103
+ assert value.issued_on == date(2026, 7, 11)
104
+ assert value.unique_codes == {1, 2}
105
+ assert value.address.street == "Main Street"
106
+
107
+ dumped = value.model_dump(mode="json")
108
+ assert dumped["record_id"] == "64c26f2a-8fd0-4ef8-b1ec-7969ebbe91cb"
109
+ assert dumped["amount"] == "1.50"
110
+ assert dumped["issued_on"] == "2026-07-11"
111
+ assert sorted(dumped["unique_codes"]) == [1, 2]
112
+ json.dumps(dumped)
113
+
114
+ python_dump = value.model_dump()
115
+ assert python_dump["amount"] == Decimal("1.50")
116
+ assert python_dump["issued_on"] == date(2026, 7, 11)
117
+
118
+
119
+ def test_extraction_request_methods_preserve_the_original_definition():
120
+ request = ExtractionRequest(
121
+ model_name="Invoice",
122
+ model_description="Invoice fields",
123
+ fields=[
124
+ ModelField(
125
+ name="invoice_number",
126
+ type="string",
127
+ description="Unique invoice number",
128
+ required=True,
129
+ nullable=False,
130
+ validation={"min_length": 2},
131
+ )
132
+ ],
133
+ )
134
+
135
+ model = model_from_extraction_request(request)
136
+ restored = model_to_extraction_request(model)
137
+
138
+ assert restored == request
139
+ assert model(invoice_number="A1").invoice_number == "A1"
140
+
141
+
142
+ def test_model_conversion_does_not_inherit_base_model_documentation():
143
+ class UndocumentedModel(BaseModel):
144
+ value: str
145
+
146
+ request = model_to_extraction_request(UndocumentedModel)
147
+
148
+ assert request.model_description == ""
149
+
150
+
151
+ def test_model_conversion_rejects_executable_model_behavior():
152
+ class ValidatedModel(BaseModel):
153
+ value: str
154
+
155
+ @field_validator("value")
156
+ @classmethod
157
+ def normalize_value(cls, value: str) -> str:
158
+ return value.strip()
159
+
160
+ with pytest.raises(ModelGenerationError, match="Custom validators"):
161
+ model_to_extraction_request(ValidatedModel)
162
+
163
+
164
+ def test_model_conversion_rejects_default_factories():
165
+ class FactoryModel(BaseModel):
166
+ values: list[str] = Field(default_factory=list)
167
+
168
+ with pytest.raises(ModelGenerationError, match="Default factories"):
169
+ model_to_extraction_request(FactoryModel)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes