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.
- {structx-0.6.0/structx.egg-info → structx-0.6.2}/PKG-INFO +5 -2
- {structx-0.6.0 → structx-0.6.2}/README.md +4 -1
- {structx-0.6.0 → structx-0.6.2}/pyproject.toml +3 -3
- {structx-0.6.0 → structx-0.6.2}/structx/__init__.py +17 -1
- {structx-0.6.0 → structx-0.6.2}/structx/core/models.py +13 -2
- {structx-0.6.0 → structx-0.6.2}/structx/core/type_system.py +61 -22
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/generator.py +11 -6
- structx-0.6.2/structx/schema.py +368 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/prompts.py +2 -1
- {structx-0.6.0 → structx-0.6.2/structx.egg-info}/PKG-INFO +5 -2
- {structx-0.6.0 → structx-0.6.2}/structx.egg-info/SOURCES.txt +2 -0
- structx-0.6.2/tests/test_schema_contract.py +169 -0
- {structx-0.6.0 → structx-0.6.2}/LICENSE +0 -0
- {structx-0.6.0 → structx-0.6.2}/setup.cfg +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/core/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/core/config.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/core/exceptions.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/core/input.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/core/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/core/llm_core.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/engines/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/engines/extraction_engine.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/extractor.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/batch_processor.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/content_analyzer.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/input_processor.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/processors/model_operations.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/extraction/result_manager.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/__init__.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/file_reader.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/helpers.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/types.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx/utils/usage.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx.egg-info/dependency_links.txt +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx.egg-info/requires.txt +0 -0
- {structx-0.6.0 → structx-0.6.2}/structx.egg-info/top_level.txt +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_config_and_model_utils.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_content_analyzer.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_document_conversion_integration.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_extraction_engine.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_extractor_async.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_extractor_flow.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_file_reader.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_input_and_batch_processors.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_live_llm.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_llm_configuration.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_result_manager_and_helpers.py +0 -0
- {structx-0.6.0 → structx-0.6.2}/tests/test_type_system_and_planning.py +0 -0
- {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.
|
|
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
|
[](https://structx.aolabs.dev "Documentation")
|
|
67
|
-
[](https://pypi.org/project/structx "Package")
|
|
68
68
|
[](# "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
|
[](https://structx.aolabs.dev "Documentation")
|
|
7
|
-
[](https://pypi.org/project/structx "Package")
|
|
8
8
|
[](# "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.
|
|
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.
|
|
95
|
+
current_version = "0.6.2"
|
|
96
96
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
97
|
-
commit_message = "
|
|
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.
|
|
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
|
|
58
|
-
raise ValueError("A
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
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
|
|
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
|
|
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.
|
|
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
|
[](https://structx.aolabs.dev "Documentation")
|
|
67
|
-
[](https://pypi.org/project/structx "Package")
|
|
68
68
|
[](# "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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|