ostruct-cli 0.5.0__py3-none-any.whl → 0.6.1__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.
- ostruct/cli/cli.py +119 -374
- ostruct/cli/errors.py +63 -18
- ostruct/cli/model_creation.py +507 -0
- ostruct/cli/schema_validation.py +213 -0
- {ostruct_cli-0.5.0.dist-info → ostruct_cli-0.6.1.dist-info}/METADATA +211 -32
- {ostruct_cli-0.5.0.dist-info → ostruct_cli-0.6.1.dist-info}/RECORD +9 -7
- {ostruct_cli-0.5.0.dist-info → ostruct_cli-0.6.1.dist-info}/WHEEL +1 -1
- {ostruct_cli-0.5.0.dist-info → ostruct_cli-0.6.1.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.5.0.dist-info → ostruct_cli-0.6.1.dist-info}/entry_points.txt +0 -0
ostruct/cli/cli.py
CHANGED
@@ -5,7 +5,6 @@ import json
|
|
5
5
|
import logging
|
6
6
|
import os
|
7
7
|
import sys
|
8
|
-
from enum import Enum, IntEnum
|
9
8
|
from typing import (
|
10
9
|
Any,
|
11
10
|
AsyncGenerator,
|
@@ -20,12 +19,11 @@ from typing import (
|
|
20
19
|
TypeVar,
|
21
20
|
Union,
|
22
21
|
cast,
|
23
|
-
get_origin,
|
24
22
|
overload,
|
25
23
|
)
|
26
24
|
|
27
25
|
if sys.version_info >= (3, 11):
|
28
|
-
|
26
|
+
pass
|
29
27
|
|
30
28
|
from datetime import date, datetime, time
|
31
29
|
from pathlib import Path
|
@@ -48,15 +46,7 @@ from openai_structured.errors import (
|
|
48
46
|
StreamBufferError,
|
49
47
|
)
|
50
48
|
from openai_structured.model_registry import ModelRegistry
|
51
|
-
from pydantic import
|
52
|
-
AnyUrl,
|
53
|
-
BaseModel,
|
54
|
-
ConfigDict,
|
55
|
-
EmailStr,
|
56
|
-
Field,
|
57
|
-
ValidationError,
|
58
|
-
create_model,
|
59
|
-
)
|
49
|
+
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
60
50
|
from pydantic.fields import FieldInfo as FieldInfoType
|
61
51
|
from pydantic.functional_validators import BeforeValidator
|
62
52
|
from pydantic.types import constr
|
@@ -69,11 +59,8 @@ from .. import __version__ # noqa: F401 - Used in package metadata
|
|
69
59
|
from .errors import (
|
70
60
|
CLIError,
|
71
61
|
DirectoryNotFoundError,
|
72
|
-
FieldDefinitionError,
|
73
62
|
InvalidJSONError,
|
74
63
|
ModelCreationError,
|
75
|
-
ModelValidationError,
|
76
|
-
NestedModelError,
|
77
64
|
OstructFileNotFoundError,
|
78
65
|
PathSecurityError,
|
79
66
|
SchemaFileError,
|
@@ -86,6 +73,7 @@ from .errors import (
|
|
86
73
|
VariableValueError,
|
87
74
|
)
|
88
75
|
from .file_utils import FileInfoList, collect_files
|
76
|
+
from .model_creation import _create_enum_type, create_dynamic_model
|
89
77
|
from .path_utils import validate_path_mapping
|
90
78
|
from .security import SecurityManager
|
91
79
|
from .serialization import LogSerializer
|
@@ -97,6 +85,71 @@ from .token_utils import estimate_tokens_with_encoding
|
|
97
85
|
DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
|
98
86
|
|
99
87
|
|
88
|
+
# Validation functions
|
89
|
+
def pattern(regex: str) -> Any:
|
90
|
+
return constr(pattern=regex)
|
91
|
+
|
92
|
+
|
93
|
+
def min_length(length: int) -> Any:
|
94
|
+
return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
|
95
|
+
|
96
|
+
|
97
|
+
def max_length(length: int) -> Any:
|
98
|
+
return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
|
99
|
+
|
100
|
+
|
101
|
+
def ge(value: Union[int, float]) -> Any:
|
102
|
+
return BeforeValidator(lambda v: v if float(v) >= value else None)
|
103
|
+
|
104
|
+
|
105
|
+
def le(value: Union[int, float]) -> Any:
|
106
|
+
return BeforeValidator(lambda v: v if float(v) <= value else None)
|
107
|
+
|
108
|
+
|
109
|
+
def gt(value: Union[int, float]) -> Any:
|
110
|
+
return BeforeValidator(lambda v: v if float(v) > value else None)
|
111
|
+
|
112
|
+
|
113
|
+
def lt(value: Union[int, float]) -> Any:
|
114
|
+
return BeforeValidator(lambda v: v if float(v) < value else None)
|
115
|
+
|
116
|
+
|
117
|
+
def multiple_of(value: Union[int, float]) -> Any:
|
118
|
+
return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
|
119
|
+
|
120
|
+
|
121
|
+
def create_template_context(
|
122
|
+
files: Optional[
|
123
|
+
Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
|
124
|
+
] = None,
|
125
|
+
variables: Optional[Dict[str, str]] = None,
|
126
|
+
json_variables: Optional[Dict[str, Any]] = None,
|
127
|
+
security_manager: Optional[SecurityManager] = None,
|
128
|
+
stdin_content: Optional[str] = None,
|
129
|
+
) -> Dict[str, Any]:
|
130
|
+
"""Create template context from files and variables."""
|
131
|
+
context: Dict[str, Any] = {}
|
132
|
+
|
133
|
+
# Add file variables
|
134
|
+
if files:
|
135
|
+
for name, file_list in files.items():
|
136
|
+
context[name] = file_list # Always keep FileInfoList wrapper
|
137
|
+
|
138
|
+
# Add simple variables
|
139
|
+
if variables:
|
140
|
+
context.update(variables)
|
141
|
+
|
142
|
+
# Add JSON variables
|
143
|
+
if json_variables:
|
144
|
+
context.update(json_variables)
|
145
|
+
|
146
|
+
# Add stdin if provided
|
147
|
+
if stdin_content is not None:
|
148
|
+
context["stdin"] = stdin_content
|
149
|
+
|
150
|
+
return context
|
151
|
+
|
152
|
+
|
100
153
|
class CLIParams(TypedDict, total=False):
|
101
154
|
"""Type-safe CLI parameters."""
|
102
155
|
|
@@ -106,6 +159,9 @@ class CLIParams(TypedDict, total=False):
|
|
106
159
|
dir: List[
|
107
160
|
Tuple[str, str]
|
108
161
|
] # List of (name, dir) tuples from Click's nargs=2
|
162
|
+
patterns: List[
|
163
|
+
Tuple[str, str]
|
164
|
+
] # List of (name, pattern) tuples from Click's nargs=2
|
109
165
|
allowed_dirs: List[str]
|
110
166
|
base_dir: str
|
111
167
|
allowed_dir_file: Optional[str]
|
@@ -182,12 +238,6 @@ ItemType: TypeAlias = Type[BaseModel]
|
|
182
238
|
ValueType: TypeAlias = Type[Any]
|
183
239
|
|
184
240
|
|
185
|
-
def is_container_type(tp: Type[Any]) -> bool:
|
186
|
-
"""Check if a type is a container type (list, dict, etc.)."""
|
187
|
-
origin = get_origin(tp)
|
188
|
-
return origin in (list, dict)
|
189
|
-
|
190
|
-
|
191
241
|
def _create_field(**kwargs: Any) -> FieldInfoType:
|
192
242
|
"""Create a Pydantic Field with the given kwargs."""
|
193
243
|
field: FieldInfoType = Field(**kwargs)
|
@@ -796,7 +846,7 @@ def validate_schema_file(
|
|
796
846
|
if not isinstance(schema, dict):
|
797
847
|
msg = f"Schema in {path} must be a JSON object"
|
798
848
|
logger.error(msg)
|
799
|
-
raise SchemaValidationError(msg,
|
849
|
+
raise SchemaValidationError(msg, context={"path": path})
|
800
850
|
|
801
851
|
# Validate schema structure
|
802
852
|
if "schema" in schema:
|
@@ -806,7 +856,7 @@ def validate_schema_file(
|
|
806
856
|
if not isinstance(inner_schema, dict):
|
807
857
|
msg = f"Inner schema in {path} must be a JSON object"
|
808
858
|
logger.error(msg)
|
809
|
-
raise SchemaValidationError(msg,
|
859
|
+
raise SchemaValidationError(msg, context={"path": path})
|
810
860
|
if verbose:
|
811
861
|
logger.debug("Inner schema validated successfully")
|
812
862
|
logger.debug(
|
@@ -821,7 +871,7 @@ def validate_schema_file(
|
|
821
871
|
if "type" not in schema.get("schema", schema):
|
822
872
|
msg = f"Schema in {path} must specify a type"
|
823
873
|
logger.error(msg)
|
824
|
-
raise SchemaValidationError(msg,
|
874
|
+
raise SchemaValidationError(msg, context={"path": path})
|
825
875
|
|
826
876
|
# Return the full schema including wrapper
|
827
877
|
return schema
|
@@ -845,20 +895,22 @@ def collect_template_files(
|
|
845
895
|
ValueError: If file mappings are invalid or files cannot be accessed
|
846
896
|
"""
|
847
897
|
try:
|
848
|
-
# Get files and
|
898
|
+
# Get files, directories, and patterns from args - they are already tuples from Click's nargs=2
|
849
899
|
files = list(
|
850
900
|
args.get("files", [])
|
851
901
|
) # List of (name, path) tuples from Click
|
852
902
|
dirs = args.get("dir", []) # List of (name, dir) tuples from Click
|
903
|
+
patterns = args.get(
|
904
|
+
"patterns", []
|
905
|
+
) # List of (name, pattern) tuples from Click
|
853
906
|
|
854
|
-
# Collect files from directories
|
907
|
+
# Collect files from directories and patterns
|
855
908
|
dir_files = collect_files(
|
856
|
-
file_mappings=cast(
|
857
|
-
|
858
|
-
|
859
|
-
|
860
|
-
|
861
|
-
), # Cast to correct type
|
909
|
+
file_mappings=cast(List[Tuple[str, Union[str, Path]]], files),
|
910
|
+
dir_mappings=cast(List[Tuple[str, Union[str, Path]]], dirs),
|
911
|
+
pattern_mappings=cast(
|
912
|
+
List[Tuple[str, Union[str, Path]]], patterns
|
913
|
+
),
|
862
914
|
dir_recursive=args.get("recursive", False),
|
863
915
|
security_manager=security_manager,
|
864
916
|
)
|
@@ -872,8 +924,11 @@ def collect_template_files(
|
|
872
924
|
# Let PathSecurityError propagate without wrapping
|
873
925
|
raise
|
874
926
|
except (FileNotFoundError, DirectoryNotFoundError) as e:
|
875
|
-
#
|
876
|
-
|
927
|
+
# Convert FileNotFoundError to OstructFileNotFoundError
|
928
|
+
if isinstance(e, FileNotFoundError):
|
929
|
+
raise OstructFileNotFoundError(str(e))
|
930
|
+
# Let DirectoryNotFoundError propagate
|
931
|
+
raise
|
877
932
|
except Exception as e:
|
878
933
|
# Don't wrap InvalidJSONError
|
879
934
|
if isinstance(e, InvalidJSONError):
|
@@ -975,53 +1030,6 @@ def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
|
|
975
1030
|
return variables
|
976
1031
|
|
977
1032
|
|
978
|
-
def create_template_context(
|
979
|
-
files: Optional[
|
980
|
-
Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
|
981
|
-
] = None,
|
982
|
-
variables: Optional[Dict[str, str]] = None,
|
983
|
-
json_variables: Optional[Dict[str, Any]] = None,
|
984
|
-
security_manager: Optional[SecurityManager] = None,
|
985
|
-
stdin_content: Optional[str] = None,
|
986
|
-
) -> Dict[str, Any]:
|
987
|
-
"""Create template context from direct inputs.
|
988
|
-
|
989
|
-
Args:
|
990
|
-
files: Optional dictionary mapping names to FileInfoList objects
|
991
|
-
variables: Optional dictionary of simple string variables
|
992
|
-
json_variables: Optional dictionary of JSON variables
|
993
|
-
security_manager: Optional security manager for path validation
|
994
|
-
stdin_content: Optional content to use for stdin
|
995
|
-
|
996
|
-
Returns:
|
997
|
-
Template context dictionary
|
998
|
-
|
999
|
-
Raises:
|
1000
|
-
PathSecurityError: If any file paths violate security constraints
|
1001
|
-
VariableError: If variable mappings are invalid
|
1002
|
-
"""
|
1003
|
-
context: Dict[str, Any] = {}
|
1004
|
-
|
1005
|
-
# Add file variables
|
1006
|
-
if files:
|
1007
|
-
for name, file_list in files.items():
|
1008
|
-
context[name] = file_list # Always keep FileInfoList wrapper
|
1009
|
-
|
1010
|
-
# Add simple variables
|
1011
|
-
if variables:
|
1012
|
-
context.update(variables)
|
1013
|
-
|
1014
|
-
# Add JSON variables
|
1015
|
-
if json_variables:
|
1016
|
-
context.update(json_variables)
|
1017
|
-
|
1018
|
-
# Add stdin if provided
|
1019
|
-
if stdin_content is not None:
|
1020
|
-
context["stdin"] = stdin_content
|
1021
|
-
|
1022
|
-
return context
|
1023
|
-
|
1024
|
-
|
1025
1033
|
async def create_template_context_from_args(
|
1026
1034
|
args: CLIParams,
|
1027
1035
|
security_manager: SecurityManager,
|
@@ -1076,8 +1084,11 @@ async def create_template_context_from_args(
|
|
1076
1084
|
# Let PathSecurityError propagate without wrapping
|
1077
1085
|
raise
|
1078
1086
|
except (FileNotFoundError, DirectoryNotFoundError) as e:
|
1079
|
-
#
|
1080
|
-
|
1087
|
+
# Convert FileNotFoundError to OstructFileNotFoundError
|
1088
|
+
if isinstance(e, FileNotFoundError):
|
1089
|
+
raise OstructFileNotFoundError(str(e))
|
1090
|
+
# Let DirectoryNotFoundError propagate
|
1091
|
+
raise
|
1081
1092
|
except Exception as e:
|
1082
1093
|
# Don't wrap InvalidJSONError
|
1083
1094
|
if isinstance(e, InvalidJSONError):
|
@@ -1207,41 +1218,6 @@ def parse_json_var(var_str: str) -> Tuple[str, Any]:
|
|
1207
1218
|
raise
|
1208
1219
|
|
1209
1220
|
|
1210
|
-
def _create_enum_type(values: List[Any], field_name: str) -> Type[Enum]:
|
1211
|
-
"""Create an enum type from a list of values.
|
1212
|
-
|
1213
|
-
Args:
|
1214
|
-
values: List of enum values
|
1215
|
-
field_name: Name of the field for enum type name
|
1216
|
-
|
1217
|
-
Returns:
|
1218
|
-
Created enum type
|
1219
|
-
"""
|
1220
|
-
# Determine the value type
|
1221
|
-
value_types = {type(v) for v in values}
|
1222
|
-
|
1223
|
-
if len(value_types) > 1:
|
1224
|
-
# Mixed types, use string representation
|
1225
|
-
enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
|
1226
|
-
return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
|
1227
|
-
elif value_types == {int}:
|
1228
|
-
# All integer values
|
1229
|
-
enum_dict = {f"VALUE_{v}": v for v in values}
|
1230
|
-
return type(f"{field_name.title()}Enum", (IntEnum,), enum_dict)
|
1231
|
-
elif value_types == {str}:
|
1232
|
-
# All string values
|
1233
|
-
enum_dict = {v.upper().replace(" ", "_"): v for v in values}
|
1234
|
-
if sys.version_info >= (3, 11):
|
1235
|
-
return type(f"{field_name.title()}Enum", (StrEnum,), enum_dict)
|
1236
|
-
else:
|
1237
|
-
# Other types, use string representation
|
1238
|
-
return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
|
1239
|
-
|
1240
|
-
# Default case: treat as string enum
|
1241
|
-
enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
|
1242
|
-
return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
|
1243
|
-
|
1244
|
-
|
1245
1221
|
def handle_error(e: Exception) -> None:
|
1246
1222
|
"""Handle CLI errors and display appropriate messages.
|
1247
1223
|
|
@@ -1413,15 +1389,37 @@ async def stream_structured_output(
|
|
1413
1389
|
):
|
1414
1390
|
yield chunk
|
1415
1391
|
|
1392
|
+
except APIResponseError as e:
|
1393
|
+
if "Invalid schema for response_format" in str(
|
1394
|
+
e
|
1395
|
+
) and 'type: "array"' in str(e):
|
1396
|
+
error_msg = (
|
1397
|
+
"OpenAI API Schema Error: The schema must have a root type of 'object', not 'array'. "
|
1398
|
+
"To fix this:\n"
|
1399
|
+
"1. Wrap your array in an object property, e.g.:\n"
|
1400
|
+
" {\n"
|
1401
|
+
' "type": "object",\n'
|
1402
|
+
' "properties": {\n'
|
1403
|
+
' "items": {\n'
|
1404
|
+
' "type": "array",\n'
|
1405
|
+
' "items": { ... your array items schema ... }\n'
|
1406
|
+
" }\n"
|
1407
|
+
" }\n"
|
1408
|
+
" }\n"
|
1409
|
+
"2. Make sure to update your template to handle the wrapper object."
|
1410
|
+
)
|
1411
|
+
logger.error(error_msg)
|
1412
|
+
raise InvalidResponseFormatError(error_msg)
|
1413
|
+
logger.error(f"API error: {e}")
|
1414
|
+
raise
|
1416
1415
|
except (
|
1417
1416
|
StreamInterruptedError,
|
1418
1417
|
StreamBufferError,
|
1419
1418
|
StreamParseError,
|
1420
|
-
APIResponseError,
|
1421
1419
|
EmptyResponseError,
|
1422
1420
|
InvalidResponseFormatError,
|
1423
1421
|
) as e:
|
1424
|
-
logger.error(
|
1422
|
+
logger.error("Stream error: %s", str(e))
|
1425
1423
|
raise
|
1426
1424
|
finally:
|
1427
1425
|
# Always ensure client is properly closed
|
@@ -1763,8 +1761,7 @@ async def execute_model(
|
|
1763
1761
|
user_prompt=user_prompt,
|
1764
1762
|
output_schema=output_model,
|
1765
1763
|
output_file=args.get("output_file"),
|
1766
|
-
|
1767
|
-
on_log=log_callback, # Pass logging callback separately
|
1764
|
+
on_log=log_callback,
|
1768
1765
|
):
|
1769
1766
|
output_buffer.append(response)
|
1770
1767
|
|
@@ -1930,257 +1927,5 @@ __all__ = [
|
|
1930
1927
|
]
|
1931
1928
|
|
1932
1929
|
|
1933
|
-
def create_dynamic_model(
|
1934
|
-
schema: Dict[str, Any],
|
1935
|
-
base_name: str = "DynamicModel",
|
1936
|
-
show_schema: bool = False,
|
1937
|
-
debug_validation: bool = False,
|
1938
|
-
) -> Type[BaseModel]:
|
1939
|
-
"""Create a Pydantic model from a JSON schema.
|
1940
|
-
|
1941
|
-
Args:
|
1942
|
-
schema: JSON schema to create model from
|
1943
|
-
base_name: Name for the model class
|
1944
|
-
show_schema: Whether to show the generated model schema
|
1945
|
-
debug_validation: Whether to show detailed validation errors
|
1946
|
-
|
1947
|
-
Returns:
|
1948
|
-
Type[BaseModel]: The generated Pydantic model class
|
1949
|
-
|
1950
|
-
Raises:
|
1951
|
-
ModelValidationError: If the schema is invalid
|
1952
|
-
"""
|
1953
|
-
if debug_validation:
|
1954
|
-
logger.info("Creating dynamic model from schema:")
|
1955
|
-
logger.info(json.dumps(schema, indent=2))
|
1956
|
-
|
1957
|
-
try:
|
1958
|
-
# Extract required fields
|
1959
|
-
required: Set[str] = set(schema.get("required", []))
|
1960
|
-
|
1961
|
-
# Handle our wrapper format if present
|
1962
|
-
if "schema" in schema:
|
1963
|
-
if debug_validation:
|
1964
|
-
logger.info("Found schema wrapper, extracting inner schema")
|
1965
|
-
logger.info(
|
1966
|
-
"Original schema: %s", json.dumps(schema, indent=2)
|
1967
|
-
)
|
1968
|
-
inner_schema = schema["schema"]
|
1969
|
-
if not isinstance(inner_schema, dict):
|
1970
|
-
if debug_validation:
|
1971
|
-
logger.info(
|
1972
|
-
"Inner schema must be a dictionary, got %s",
|
1973
|
-
type(inner_schema),
|
1974
|
-
)
|
1975
|
-
raise SchemaValidationError(
|
1976
|
-
"Inner schema must be a dictionary"
|
1977
|
-
)
|
1978
|
-
if debug_validation:
|
1979
|
-
logger.info("Using inner schema:")
|
1980
|
-
logger.info(json.dumps(inner_schema, indent=2))
|
1981
|
-
schema = inner_schema
|
1982
|
-
|
1983
|
-
# Ensure schema has type field
|
1984
|
-
if "type" not in schema:
|
1985
|
-
if debug_validation:
|
1986
|
-
logger.info("Schema missing type field, assuming object type")
|
1987
|
-
schema["type"] = "object"
|
1988
|
-
|
1989
|
-
# For non-object root schemas, create a wrapper model
|
1990
|
-
if schema["type"] != "object":
|
1991
|
-
if debug_validation:
|
1992
|
-
logger.info(
|
1993
|
-
"Converting non-object root schema to object wrapper"
|
1994
|
-
)
|
1995
|
-
schema = {
|
1996
|
-
"type": "object",
|
1997
|
-
"properties": {"value": schema},
|
1998
|
-
"required": ["value"],
|
1999
|
-
}
|
2000
|
-
|
2001
|
-
# Create model configuration
|
2002
|
-
config = ConfigDict(
|
2003
|
-
title=schema.get("title", base_name),
|
2004
|
-
extra=(
|
2005
|
-
"forbid"
|
2006
|
-
if schema.get("additionalProperties") is False
|
2007
|
-
else "allow"
|
2008
|
-
),
|
2009
|
-
validate_default=True,
|
2010
|
-
use_enum_values=True,
|
2011
|
-
arbitrary_types_allowed=True,
|
2012
|
-
json_schema_extra={
|
2013
|
-
k: v
|
2014
|
-
for k, v in schema.items()
|
2015
|
-
if k
|
2016
|
-
not in {
|
2017
|
-
"type",
|
2018
|
-
"properties",
|
2019
|
-
"required",
|
2020
|
-
"title",
|
2021
|
-
"description",
|
2022
|
-
"additionalProperties",
|
2023
|
-
"readOnly",
|
2024
|
-
}
|
2025
|
-
},
|
2026
|
-
)
|
2027
|
-
|
2028
|
-
if debug_validation:
|
2029
|
-
logger.info("Created model configuration:")
|
2030
|
-
logger.info(" Title: %s", config.get("title"))
|
2031
|
-
logger.info(" Extra: %s", config.get("extra"))
|
2032
|
-
logger.info(
|
2033
|
-
" Validate Default: %s", config.get("validate_default")
|
2034
|
-
)
|
2035
|
-
logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
|
2036
|
-
logger.info(
|
2037
|
-
" Arbitrary Types: %s", config.get("arbitrary_types_allowed")
|
2038
|
-
)
|
2039
|
-
logger.info(
|
2040
|
-
" JSON Schema Extra: %s", config.get("json_schema_extra")
|
2041
|
-
)
|
2042
|
-
|
2043
|
-
# Process schema properties into fields
|
2044
|
-
properties = schema.get("properties", {})
|
2045
|
-
required = schema.get("required", [])
|
2046
|
-
|
2047
|
-
field_definitions: Dict[str, Tuple[Type[Any], FieldInfoType]] = {}
|
2048
|
-
for field_name, field_schema in properties.items():
|
2049
|
-
if debug_validation:
|
2050
|
-
logger.info("Processing field %s:", field_name)
|
2051
|
-
logger.info(" Schema: %s", json.dumps(field_schema, indent=2))
|
2052
|
-
|
2053
|
-
try:
|
2054
|
-
python_type, field = _get_type_with_constraints(
|
2055
|
-
field_schema, field_name, base_name
|
2056
|
-
)
|
2057
|
-
|
2058
|
-
# Handle optional fields
|
2059
|
-
if field_name not in required:
|
2060
|
-
if debug_validation:
|
2061
|
-
logger.info(
|
2062
|
-
"Field %s is optional, wrapping in Optional",
|
2063
|
-
field_name,
|
2064
|
-
)
|
2065
|
-
field_type = cast(Type[Any], Optional[python_type])
|
2066
|
-
else:
|
2067
|
-
field_type = python_type
|
2068
|
-
if debug_validation:
|
2069
|
-
logger.info("Field %s is required", field_name)
|
2070
|
-
|
2071
|
-
# Create field definition
|
2072
|
-
field_definitions[field_name] = (field_type, field)
|
2073
|
-
|
2074
|
-
if debug_validation:
|
2075
|
-
logger.info("Successfully created field definition:")
|
2076
|
-
logger.info(" Name: %s", field_name)
|
2077
|
-
logger.info(" Type: %s", str(field_type))
|
2078
|
-
logger.info(" Required: %s", field_name in required)
|
2079
|
-
|
2080
|
-
except (FieldDefinitionError, NestedModelError) as e:
|
2081
|
-
if debug_validation:
|
2082
|
-
logger.error("Error creating field %s:", field_name)
|
2083
|
-
logger.error(" Error type: %s", type(e).__name__)
|
2084
|
-
logger.error(" Error message: %s", str(e))
|
2085
|
-
raise ModelValidationError(base_name, [str(e)])
|
2086
|
-
|
2087
|
-
# Create the model with the fields
|
2088
|
-
field_defs: Dict[str, Any] = {
|
2089
|
-
name: (
|
2090
|
-
(
|
2091
|
-
cast(Type[Any], field_type)
|
2092
|
-
if is_container_type(field_type)
|
2093
|
-
else field_type
|
2094
|
-
),
|
2095
|
-
field,
|
2096
|
-
)
|
2097
|
-
for name, (field_type, field) in field_definitions.items()
|
2098
|
-
}
|
2099
|
-
model: Type[BaseModel] = create_model(
|
2100
|
-
base_name, __config__=config, **field_defs
|
2101
|
-
)
|
2102
|
-
|
2103
|
-
# Set the model config after creation
|
2104
|
-
model.model_config = config
|
2105
|
-
|
2106
|
-
if debug_validation:
|
2107
|
-
logger.info("Successfully created model: %s", model.__name__)
|
2108
|
-
logger.info("Model config: %s", dict(model.model_config))
|
2109
|
-
logger.info(
|
2110
|
-
"Model schema: %s",
|
2111
|
-
json.dumps(model.model_json_schema(), indent=2),
|
2112
|
-
)
|
2113
|
-
|
2114
|
-
# Validate the model's JSON schema
|
2115
|
-
try:
|
2116
|
-
model.model_json_schema()
|
2117
|
-
except ValidationError as e:
|
2118
|
-
if debug_validation:
|
2119
|
-
logger.error("Schema validation failed:")
|
2120
|
-
logger.error(" Error type: %s", type(e).__name__)
|
2121
|
-
logger.error(" Error message: %s", str(e))
|
2122
|
-
validation_errors = (
|
2123
|
-
[str(err) for err in e.errors()]
|
2124
|
-
if hasattr(e, "errors")
|
2125
|
-
else [str(e)]
|
2126
|
-
)
|
2127
|
-
raise ModelValidationError(base_name, validation_errors)
|
2128
|
-
|
2129
|
-
return model
|
2130
|
-
|
2131
|
-
except Exception as e:
|
2132
|
-
if debug_validation:
|
2133
|
-
logger.error("Failed to create model:")
|
2134
|
-
logger.error(" Error type: %s", type(e).__name__)
|
2135
|
-
logger.error(" Error message: %s", str(e))
|
2136
|
-
if hasattr(e, "__cause__"):
|
2137
|
-
logger.error(" Caused by: %s", str(e.__cause__))
|
2138
|
-
if hasattr(e, "__context__"):
|
2139
|
-
logger.error(" Context: %s", str(e.__context__))
|
2140
|
-
if hasattr(e, "__traceback__"):
|
2141
|
-
import traceback
|
2142
|
-
|
2143
|
-
logger.error(
|
2144
|
-
" Traceback:\n%s",
|
2145
|
-
"".join(traceback.format_tb(e.__traceback__)),
|
2146
|
-
)
|
2147
|
-
raise ModelCreationError(
|
2148
|
-
f"Failed to create model '{base_name}': {str(e)}"
|
2149
|
-
)
|
2150
|
-
|
2151
|
-
|
2152
|
-
# Validation functions
|
2153
|
-
def pattern(regex: str) -> Any:
|
2154
|
-
return constr(pattern=regex)
|
2155
|
-
|
2156
|
-
|
2157
|
-
def min_length(length: int) -> Any:
|
2158
|
-
return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
|
2159
|
-
|
2160
|
-
|
2161
|
-
def max_length(length: int) -> Any:
|
2162
|
-
return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
|
2163
|
-
|
2164
|
-
|
2165
|
-
def ge(value: Union[int, float]) -> Any:
|
2166
|
-
return BeforeValidator(lambda v: v if float(v) >= value else None)
|
2167
|
-
|
2168
|
-
|
2169
|
-
def le(value: Union[int, float]) -> Any:
|
2170
|
-
return BeforeValidator(lambda v: v if float(v) <= value else None)
|
2171
|
-
|
2172
|
-
|
2173
|
-
def gt(value: Union[int, float]) -> Any:
|
2174
|
-
return BeforeValidator(lambda v: v if float(v) > value else None)
|
2175
|
-
|
2176
|
-
|
2177
|
-
def lt(value: Union[int, float]) -> Any:
|
2178
|
-
return BeforeValidator(lambda v: v if float(v) < value else None)
|
2179
|
-
|
2180
|
-
|
2181
|
-
def multiple_of(value: Union[int, float]) -> Any:
|
2182
|
-
return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
|
2183
|
-
|
2184
|
-
|
2185
1930
|
if __name__ == "__main__":
|
2186
1931
|
main()
|