ostruct-cli 0.6.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 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
- from enum import StrEnum
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
 
@@ -185,12 +238,6 @@ ItemType: TypeAlias = Type[BaseModel]
185
238
  ValueType: TypeAlias = Type[Any]
186
239
 
187
240
 
188
- def is_container_type(tp: Type[Any]) -> bool:
189
- """Check if a type is a container type (list, dict, etc.)."""
190
- origin = get_origin(tp)
191
- return origin in (list, dict)
192
-
193
-
194
241
  def _create_field(**kwargs: Any) -> FieldInfoType:
195
242
  """Create a Pydantic Field with the given kwargs."""
196
243
  field: FieldInfoType = Field(**kwargs)
@@ -877,8 +924,11 @@ def collect_template_files(
877
924
  # Let PathSecurityError propagate without wrapping
878
925
  raise
879
926
  except (FileNotFoundError, DirectoryNotFoundError) as e:
880
- # Wrap file-related errors
881
- raise ValueError(f"File access error: {e}")
927
+ # Convert FileNotFoundError to OstructFileNotFoundError
928
+ if isinstance(e, FileNotFoundError):
929
+ raise OstructFileNotFoundError(str(e))
930
+ # Let DirectoryNotFoundError propagate
931
+ raise
882
932
  except Exception as e:
883
933
  # Don't wrap InvalidJSONError
884
934
  if isinstance(e, InvalidJSONError):
@@ -980,38 +1030,6 @@ def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
980
1030
  return variables
981
1031
 
982
1032
 
983
- def create_template_context(
984
- files: Optional[
985
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
986
- ] = None,
987
- variables: Optional[Dict[str, str]] = None,
988
- json_variables: Optional[Dict[str, Any]] = None,
989
- security_manager: Optional[SecurityManager] = None,
990
- stdin_content: Optional[str] = None,
991
- ) -> Dict[str, Any]:
992
- """Create template context from files and variables."""
993
- context: Dict[str, Any] = {}
994
-
995
- # Add file variables
996
- if files:
997
- for name, file_list in files.items():
998
- context[name] = file_list # Always keep FileInfoList wrapper
999
-
1000
- # Add simple variables
1001
- if variables:
1002
- context.update(variables)
1003
-
1004
- # Add JSON variables
1005
- if json_variables:
1006
- context.update(json_variables)
1007
-
1008
- # Add stdin if provided
1009
- if stdin_content is not None:
1010
- context["stdin"] = stdin_content
1011
-
1012
- return context
1013
-
1014
-
1015
1033
  async def create_template_context_from_args(
1016
1034
  args: CLIParams,
1017
1035
  security_manager: SecurityManager,
@@ -1066,8 +1084,11 @@ async def create_template_context_from_args(
1066
1084
  # Let PathSecurityError propagate without wrapping
1067
1085
  raise
1068
1086
  except (FileNotFoundError, DirectoryNotFoundError) as e:
1069
- # Wrap file-related errors
1070
- raise ValueError(f"File access error: {e}")
1087
+ # Convert FileNotFoundError to OstructFileNotFoundError
1088
+ if isinstance(e, FileNotFoundError):
1089
+ raise OstructFileNotFoundError(str(e))
1090
+ # Let DirectoryNotFoundError propagate
1091
+ raise
1071
1092
  except Exception as e:
1072
1093
  # Don't wrap InvalidJSONError
1073
1094
  if isinstance(e, InvalidJSONError):
@@ -1197,41 +1218,6 @@ def parse_json_var(var_str: str) -> Tuple[str, Any]:
1197
1218
  raise
1198
1219
 
1199
1220
 
1200
- def _create_enum_type(values: List[Any], field_name: str) -> Type[Enum]:
1201
- """Create an enum type from a list of values.
1202
-
1203
- Args:
1204
- values: List of enum values
1205
- field_name: Name of the field for enum type name
1206
-
1207
- Returns:
1208
- Created enum type
1209
- """
1210
- # Determine the value type
1211
- value_types = {type(v) for v in values}
1212
-
1213
- if len(value_types) > 1:
1214
- # Mixed types, use string representation
1215
- enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
1216
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1217
- elif value_types == {int}:
1218
- # All integer values
1219
- enum_dict = {f"VALUE_{v}": v for v in values}
1220
- return type(f"{field_name.title()}Enum", (IntEnum,), enum_dict)
1221
- elif value_types == {str}:
1222
- # All string values
1223
- enum_dict = {v.upper().replace(" ", "_"): v for v in values}
1224
- if sys.version_info >= (3, 11):
1225
- return type(f"{field_name.title()}Enum", (StrEnum,), enum_dict)
1226
- else:
1227
- # Other types, use string representation
1228
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1229
-
1230
- # Default case: treat as string enum
1231
- enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
1232
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1233
-
1234
-
1235
1221
  def handle_error(e: Exception) -> None:
1236
1222
  """Handle CLI errors and display appropriate messages.
1237
1223
 
@@ -1433,7 +1419,7 @@ async def stream_structured_output(
1433
1419
  EmptyResponseError,
1434
1420
  InvalidResponseFormatError,
1435
1421
  ) as e:
1436
- logger.error(f"Stream error: {e}")
1422
+ logger.error("Stream error: %s", str(e))
1437
1423
  raise
1438
1424
  finally:
1439
1425
  # Always ensure client is properly closed
@@ -1941,254 +1927,5 @@ __all__ = [
1941
1927
  ]
1942
1928
 
1943
1929
 
1944
- def create_dynamic_model(
1945
- schema: Dict[str, Any],
1946
- base_name: str = "DynamicModel",
1947
- show_schema: bool = False,
1948
- debug_validation: bool = False,
1949
- ) -> Type[BaseModel]:
1950
- """Create a Pydantic model from a JSON schema.
1951
-
1952
- Args:
1953
- schema: JSON schema to create model from
1954
- base_name: Name for the model class
1955
- show_schema: Whether to show the generated model schema
1956
- debug_validation: Whether to show detailed validation errors
1957
-
1958
- Returns:
1959
- Type[BaseModel]: The generated Pydantic model class
1960
-
1961
- Raises:
1962
- ModelValidationError: If the schema is invalid
1963
- SchemaValidationError: If the schema violates OpenAI requirements
1964
- """
1965
- if debug_validation:
1966
- logger.info("Creating dynamic model from schema:")
1967
- logger.info(json.dumps(schema, indent=2))
1968
-
1969
- try:
1970
- # Handle our wrapper format if present
1971
- if "schema" in schema:
1972
- if debug_validation:
1973
- logger.info("Found schema wrapper, extracting inner schema")
1974
- logger.info(
1975
- "Original schema: %s", json.dumps(schema, indent=2)
1976
- )
1977
- inner_schema = schema["schema"]
1978
- if not isinstance(inner_schema, dict):
1979
- if debug_validation:
1980
- logger.info(
1981
- "Inner schema must be a dictionary, got %s",
1982
- type(inner_schema),
1983
- )
1984
- raise SchemaValidationError(
1985
- "Inner schema must be a dictionary"
1986
- )
1987
- if debug_validation:
1988
- logger.info("Using inner schema:")
1989
- logger.info(json.dumps(inner_schema, indent=2))
1990
- schema = inner_schema
1991
-
1992
- # Validate against OpenAI requirements
1993
- from .schema_validation import validate_openai_schema
1994
-
1995
- validate_openai_schema(schema)
1996
-
1997
- # Create model configuration
1998
- config = ConfigDict(
1999
- title=schema.get("title", base_name),
2000
- extra="forbid", # OpenAI requires additionalProperties: false
2001
- validate_default=True,
2002
- use_enum_values=True,
2003
- arbitrary_types_allowed=True,
2004
- json_schema_extra={
2005
- k: v
2006
- for k, v in schema.items()
2007
- if k
2008
- not in {
2009
- "type",
2010
- "properties",
2011
- "required",
2012
- "title",
2013
- "description",
2014
- "additionalProperties",
2015
- "readOnly",
2016
- }
2017
- },
2018
- )
2019
-
2020
- if debug_validation:
2021
- logger.info("Created model configuration:")
2022
- logger.info(" Title: %s", config.get("title"))
2023
- logger.info(" Extra: %s", config.get("extra"))
2024
- logger.info(
2025
- " Validate Default: %s", config.get("validate_default")
2026
- )
2027
- logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
2028
- logger.info(
2029
- " Arbitrary Types: %s", config.get("arbitrary_types_allowed")
2030
- )
2031
- logger.info(
2032
- " JSON Schema Extra: %s", config.get("json_schema_extra")
2033
- )
2034
-
2035
- # Process schema properties into fields
2036
- properties = schema.get("properties", {})
2037
- required = schema.get("required", [])
2038
-
2039
- field_definitions: Dict[str, Tuple[Type[Any], FieldInfoType]] = {}
2040
- for field_name, field_schema in properties.items():
2041
- if debug_validation:
2042
- logger.info("Processing field %s:", field_name)
2043
- logger.info(" Schema: %s", json.dumps(field_schema, indent=2))
2044
-
2045
- try:
2046
- python_type, field = _get_type_with_constraints(
2047
- field_schema, field_name, base_name
2048
- )
2049
-
2050
- # Handle optional fields
2051
- if field_name not in required:
2052
- if debug_validation:
2053
- logger.info(
2054
- "Field %s is optional, wrapping in Optional",
2055
- field_name,
2056
- )
2057
- field_type = cast(Type[Any], Optional[python_type])
2058
- else:
2059
- field_type = python_type
2060
- if debug_validation:
2061
- logger.info("Field %s is required", field_name)
2062
-
2063
- # Create field definition
2064
- field_definitions[field_name] = (field_type, field)
2065
-
2066
- if debug_validation:
2067
- logger.info("Successfully created field definition:")
2068
- logger.info(" Name: %s", field_name)
2069
- logger.info(" Type: %s", str(field_type))
2070
- logger.info(" Required: %s", field_name in required)
2071
-
2072
- except (FieldDefinitionError, NestedModelError) as e:
2073
- if debug_validation:
2074
- logger.error("Error creating field %s:", field_name)
2075
- logger.error(" Error type: %s", type(e).__name__)
2076
- logger.error(" Error message: %s", str(e))
2077
- raise ModelValidationError(base_name, [str(e)])
2078
-
2079
- # Create the model with the fields
2080
- field_defs: Dict[str, Any] = {
2081
- name: (
2082
- (
2083
- cast(Type[Any], field_type)
2084
- if is_container_type(field_type)
2085
- else field_type
2086
- ),
2087
- field,
2088
- )
2089
- for name, (field_type, field) in field_definitions.items()
2090
- }
2091
- model: Type[BaseModel] = create_model(
2092
- base_name, __config__=config, **field_defs
2093
- )
2094
-
2095
- # Set the model config after creation
2096
- model.model_config = config
2097
-
2098
- if debug_validation:
2099
- logger.info("Successfully created model: %s", model.__name__)
2100
- logger.info("Model config: %s", dict(model.model_config))
2101
- logger.info(
2102
- "Model schema: %s",
2103
- json.dumps(model.model_json_schema(), indent=2),
2104
- )
2105
-
2106
- # Validate the model's JSON schema
2107
- try:
2108
- model.model_json_schema()
2109
- except ValidationError as e:
2110
- validation_errors = (
2111
- [str(err) for err in e.errors()]
2112
- if hasattr(e, "errors")
2113
- else [str(e)]
2114
- )
2115
- if debug_validation:
2116
- logger.error("Schema validation failed:")
2117
- logger.error(" Error type: %s", type(e).__name__)
2118
- logger.error(" Error message: %s", str(e))
2119
- raise ModelValidationError(base_name, validation_errors)
2120
-
2121
- return model
2122
-
2123
- except SchemaValidationError as e:
2124
- # Always log basic error info
2125
- logger.error("Schema validation error: %s", str(e))
2126
-
2127
- # Log additional debug info if requested
2128
- if debug_validation:
2129
- logger.error(" Error type: %s", type(e).__name__)
2130
- logger.error(" Error details: %s", str(e))
2131
- # Always raise schema validation errors directly
2132
- raise
2133
-
2134
- except Exception as e:
2135
- # Always log basic error info
2136
- logger.error("Model creation error: %s", str(e))
2137
-
2138
- # Log additional debug info if requested
2139
- if debug_validation:
2140
- logger.error(" Error type: %s", type(e).__name__)
2141
- logger.error(" Error details: %s", str(e))
2142
- if hasattr(e, "__cause__"):
2143
- logger.error(" Caused by: %s", str(e.__cause__))
2144
- if hasattr(e, "__context__"):
2145
- logger.error(" Context: %s", str(e.__context__))
2146
- if hasattr(e, "__traceback__"):
2147
- import traceback
2148
-
2149
- logger.error(
2150
- " Traceback:\n%s",
2151
- "".join(traceback.format_tb(e.__traceback__)),
2152
- )
2153
- # Always wrap other errors as ModelCreationError
2154
- raise ModelCreationError(
2155
- f"Failed to create model {base_name}",
2156
- context={"error": str(e)},
2157
- ) from e
2158
-
2159
-
2160
- # Validation functions
2161
- def pattern(regex: str) -> Any:
2162
- return constr(pattern=regex)
2163
-
2164
-
2165
- def min_length(length: int) -> Any:
2166
- return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
2167
-
2168
-
2169
- def max_length(length: int) -> Any:
2170
- return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
2171
-
2172
-
2173
- def ge(value: Union[int, float]) -> Any:
2174
- return BeforeValidator(lambda v: v if float(v) >= value else None)
2175
-
2176
-
2177
- def le(value: Union[int, float]) -> Any:
2178
- return BeforeValidator(lambda v: v if float(v) <= value else None)
2179
-
2180
-
2181
- def gt(value: Union[int, float]) -> Any:
2182
- return BeforeValidator(lambda v: v if float(v) > value else None)
2183
-
2184
-
2185
- def lt(value: Union[int, float]) -> Any:
2186
- return BeforeValidator(lambda v: v if float(v) < value else None)
2187
-
2188
-
2189
- def multiple_of(value: Union[int, float]) -> Any:
2190
- return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
2191
-
2192
-
2193
1930
  if __name__ == "__main__":
2194
1931
  main()
@@ -0,0 +1,507 @@
1
+ """Model creation utilities for the CLI."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from datetime import date, datetime, time
7
+ from enum import Enum, IntEnum
8
+ from typing import (
9
+ Any,
10
+ Dict,
11
+ List,
12
+ Optional,
13
+ Tuple,
14
+ Type,
15
+ Union,
16
+ cast,
17
+ get_origin,
18
+ )
19
+
20
+ if sys.version_info >= (3, 11):
21
+ from enum import StrEnum
22
+
23
+ from pydantic import (
24
+ AnyUrl,
25
+ BaseModel,
26
+ ConfigDict,
27
+ EmailStr,
28
+ Field,
29
+ ValidationError,
30
+ create_model,
31
+ )
32
+ from pydantic.fields import FieldInfo
33
+ from pydantic.functional_validators import BeforeValidator
34
+ from pydantic.types import constr
35
+
36
+ from .errors import (
37
+ FieldDefinitionError,
38
+ ModelCreationError,
39
+ ModelValidationError,
40
+ NestedModelError,
41
+ SchemaValidationError,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Type aliases
47
+ FieldType = Type[
48
+ Any
49
+ ] # Changed from Type[Any] to allow both concrete types and generics
50
+ FieldDefinition = Tuple[Any, FieldInfo] # Changed to Any to handle generics
51
+
52
+
53
+ def _create_enum_type(values: List[Any], field_name: str) -> Type[Enum]:
54
+ """Create an enum type from a list of values.
55
+
56
+ Args:
57
+ values: List of enum values
58
+ field_name: Name of the field for enum type name
59
+
60
+ Returns:
61
+ Created enum type
62
+ """
63
+ # Determine the value type
64
+ value_types = {type(v) for v in values}
65
+
66
+ if len(value_types) > 1:
67
+ # Mixed types, use string representation
68
+ enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
69
+ return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
70
+ elif value_types == {int}:
71
+ # All integer values
72
+ enum_dict = {f"VALUE_{v}": v for v in values}
73
+ return type(f"{field_name.title()}Enum", (IntEnum,), enum_dict)
74
+ elif value_types == {str}:
75
+ # All string values
76
+ enum_dict = {v.upper().replace(" ", "_"): v for v in values}
77
+ if sys.version_info >= (3, 11):
78
+ return type(f"{field_name.title()}Enum", (StrEnum,), enum_dict)
79
+ else:
80
+ # Other types, use string representation
81
+ return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
82
+
83
+ # Default case: treat as string enum
84
+ enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
85
+ return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
86
+
87
+
88
+ def is_container_type(tp: Type[Any]) -> bool:
89
+ """Check if a type is a container type (List, Dict, etc).
90
+
91
+ Args:
92
+ tp: Type to check
93
+
94
+ Returns:
95
+ bool: True if type is a container type
96
+ """
97
+ origin = get_origin(tp)
98
+ return origin is not None and origin in (list, dict, List, Dict)
99
+
100
+
101
+ # Validation functions
102
+ def pattern(regex: str) -> Any:
103
+ return constr(pattern=regex)
104
+
105
+
106
+ def min_length(length: int) -> Any:
107
+ return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
108
+
109
+
110
+ def max_length(length: int) -> Any:
111
+ return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
112
+
113
+
114
+ def ge(value: Union[int, float]) -> Any:
115
+ return BeforeValidator(lambda v: v if float(v) >= value else None)
116
+
117
+
118
+ def le(value: Union[int, float]) -> Any:
119
+ return BeforeValidator(lambda v: v if float(v) <= value else None)
120
+
121
+
122
+ def gt(value: Union[int, float]) -> Any:
123
+ return BeforeValidator(lambda v: v if float(v) > value else None)
124
+
125
+
126
+ def lt(value: Union[int, float]) -> Any:
127
+ return BeforeValidator(lambda v: v if float(v) < value else None)
128
+
129
+
130
+ def multiple_of(value: Union[int, float]) -> Any:
131
+ return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
132
+
133
+
134
+ def _get_type_with_constraints(
135
+ field_schema: Dict[str, Any], field_name: str, base_name: str
136
+ ) -> FieldDefinition:
137
+ """Get type with constraints from field schema.
138
+
139
+ Args:
140
+ field_schema: Field schema dict
141
+ field_name: Name of the field
142
+ base_name: Base name for nested models
143
+
144
+ Returns:
145
+ Tuple of (type, field)
146
+ """
147
+ field_kwargs: Dict[str, Any] = {}
148
+
149
+ # Add common field metadata
150
+ if "title" in field_schema:
151
+ field_kwargs["title"] = field_schema["title"]
152
+ if "description" in field_schema:
153
+ field_kwargs["description"] = field_schema["description"]
154
+ if "default" in field_schema:
155
+ field_kwargs["default"] = field_schema["default"]
156
+ if "readOnly" in field_schema:
157
+ field_kwargs["frozen"] = field_schema["readOnly"]
158
+
159
+ field_type = field_schema.get("type")
160
+
161
+ # Handle array type
162
+ if field_type == "array":
163
+ items_schema = field_schema.get("items", {})
164
+ if not items_schema:
165
+ return (List[Any], Field(**field_kwargs)) # Direct generic type
166
+
167
+ # Create nested model for object items
168
+ if (
169
+ isinstance(items_schema, dict)
170
+ and items_schema.get("type") == "object"
171
+ ):
172
+ array_item_model = create_dynamic_model(
173
+ items_schema,
174
+ base_name=f"{base_name}_{field_name}_Item",
175
+ show_schema=False,
176
+ debug_validation=False,
177
+ )
178
+ return (List[array_item_model], Field(**field_kwargs)) # type: ignore[valid-type]
179
+
180
+ # For non-object items, use the type directly
181
+ item_type = items_schema.get("type", "string")
182
+ if item_type == "string":
183
+ return (List[str], Field(**field_kwargs))
184
+ elif item_type == "integer":
185
+ return (List[int], Field(**field_kwargs))
186
+ elif item_type == "number":
187
+ return (List[float], Field(**field_kwargs))
188
+ elif item_type == "boolean":
189
+ return (List[bool], Field(**field_kwargs))
190
+ else:
191
+ return (List[Any], Field(**field_kwargs))
192
+
193
+ # Handle object type
194
+ if field_type == "object":
195
+ # Create nested model with explicit type annotation
196
+ object_model = create_dynamic_model(
197
+ field_schema,
198
+ base_name=f"{base_name}_{field_name}",
199
+ show_schema=False,
200
+ debug_validation=False,
201
+ )
202
+ return (object_model, Field(**field_kwargs))
203
+
204
+ # Handle additionalProperties
205
+ if "additionalProperties" in field_schema and isinstance(
206
+ field_schema["additionalProperties"], dict
207
+ ):
208
+ # Create nested model with explicit type annotation
209
+ dict_value_model = create_dynamic_model(
210
+ field_schema["additionalProperties"],
211
+ base_name=f"{base_name}_{field_name}_Value",
212
+ show_schema=False,
213
+ debug_validation=False,
214
+ )
215
+ dict_type: Type[Dict[str, Any]] = Dict[str, dict_value_model] # type: ignore[valid-type]
216
+ return (dict_type, Field(**field_kwargs))
217
+
218
+ # Handle other types
219
+ if field_type == "string":
220
+ field_type_cls: Type[Any] = str
221
+
222
+ # Add string-specific constraints to field_kwargs
223
+ if "pattern" in field_schema:
224
+ field_kwargs["pattern"] = field_schema["pattern"]
225
+ if "minLength" in field_schema:
226
+ field_kwargs["min_length"] = field_schema["minLength"]
227
+ if "maxLength" in field_schema:
228
+ field_kwargs["max_length"] = field_schema["maxLength"]
229
+
230
+ # Handle special string formats
231
+ if "format" in field_schema:
232
+ if field_schema["format"] == "date-time":
233
+ field_type_cls = datetime
234
+ elif field_schema["format"] == "date":
235
+ field_type_cls = date
236
+ elif field_schema["format"] == "time":
237
+ field_type_cls = time
238
+ elif field_schema["format"] == "email":
239
+ field_type_cls = EmailStr
240
+ elif field_schema["format"] == "uri":
241
+ field_type_cls = AnyUrl
242
+
243
+ return (field_type_cls, Field(**field_kwargs))
244
+
245
+ if field_type == "number":
246
+ field_type_cls = float
247
+
248
+ # Add number-specific constraints to field_kwargs
249
+ if "minimum" in field_schema:
250
+ field_kwargs["ge"] = field_schema["minimum"]
251
+ if "maximum" in field_schema:
252
+ field_kwargs["le"] = field_schema["maximum"]
253
+ if "exclusiveMinimum" in field_schema:
254
+ field_kwargs["gt"] = field_schema["exclusiveMinimum"]
255
+ if "exclusiveMaximum" in field_schema:
256
+ field_kwargs["lt"] = field_schema["exclusiveMaximum"]
257
+ if "multipleOf" in field_schema:
258
+ field_kwargs["multiple_of"] = field_schema["multipleOf"]
259
+
260
+ return (field_type_cls, Field(**field_kwargs))
261
+
262
+ if field_type == "integer":
263
+ field_type_cls = int
264
+
265
+ # Add integer-specific constraints to field_kwargs
266
+ if "minimum" in field_schema:
267
+ field_kwargs["ge"] = field_schema["minimum"]
268
+ if "maximum" in field_schema:
269
+ field_kwargs["le"] = field_schema["maximum"]
270
+ if "exclusiveMinimum" in field_schema:
271
+ field_kwargs["gt"] = field_schema["exclusiveMinimum"]
272
+ if "exclusiveMaximum" in field_schema:
273
+ field_kwargs["lt"] = field_schema["exclusiveMaximum"]
274
+ if "multipleOf" in field_schema:
275
+ field_kwargs["multiple_of"] = field_schema["multipleOf"]
276
+
277
+ return (field_type_cls, Field(**field_kwargs))
278
+
279
+ if field_type == "boolean":
280
+ return (bool, Field(**field_kwargs))
281
+
282
+ if field_type == "null":
283
+ return (type(None), Field(**field_kwargs))
284
+
285
+ # Handle enum
286
+ if "enum" in field_schema:
287
+ enum_type = _create_enum_type(field_schema["enum"], field_name)
288
+ return (cast(Type[Any], enum_type), Field(**field_kwargs))
289
+
290
+ # Default to Any for unknown types
291
+ return (Any, Field(**field_kwargs))
292
+
293
+
294
+ def create_dynamic_model(
295
+ schema: Dict[str, Any],
296
+ base_name: str = "DynamicModel",
297
+ show_schema: bool = False,
298
+ debug_validation: bool = False,
299
+ ) -> Type[BaseModel]:
300
+ """Create a Pydantic model from a JSON schema.
301
+
302
+ Args:
303
+ schema: JSON schema to create model from
304
+ base_name: Name for the model class
305
+ show_schema: Whether to show the generated model schema
306
+ debug_validation: Whether to show detailed validation errors
307
+
308
+ Returns:
309
+ Type[BaseModel]: The generated Pydantic model class
310
+
311
+ Raises:
312
+ ModelValidationError: If the schema is invalid
313
+ SchemaValidationError: If the schema violates OpenAI requirements
314
+ """
315
+ if debug_validation:
316
+ logger.info("Creating dynamic model from schema:")
317
+ logger.info(json.dumps(schema, indent=2))
318
+
319
+ try:
320
+ # Handle our wrapper format if present
321
+ if "schema" in schema:
322
+ if debug_validation:
323
+ logger.info("Found schema wrapper, extracting inner schema")
324
+ logger.info(
325
+ "Original schema: %s", json.dumps(schema, indent=2)
326
+ )
327
+ inner_schema = schema["schema"]
328
+ if not isinstance(inner_schema, dict):
329
+ if debug_validation:
330
+ logger.info(
331
+ "Inner schema must be a dictionary, got %s",
332
+ type(inner_schema),
333
+ )
334
+ raise SchemaValidationError(
335
+ "Inner schema must be a dictionary"
336
+ )
337
+ if debug_validation:
338
+ logger.info("Using inner schema:")
339
+ logger.info(json.dumps(inner_schema, indent=2))
340
+ schema = inner_schema
341
+
342
+ # Validate against OpenAI requirements
343
+ from .schema_validation import validate_openai_schema
344
+
345
+ validate_openai_schema(schema)
346
+
347
+ # Create model configuration
348
+ config = ConfigDict(
349
+ title=schema.get("title", base_name),
350
+ extra="forbid", # OpenAI requires additionalProperties: false
351
+ validate_default=True,
352
+ use_enum_values=True,
353
+ arbitrary_types_allowed=True,
354
+ json_schema_extra={
355
+ k: v
356
+ for k, v in schema.items()
357
+ if k
358
+ not in {
359
+ "type",
360
+ "properties",
361
+ "required",
362
+ "title",
363
+ "description",
364
+ "additionalProperties",
365
+ "readOnly",
366
+ }
367
+ },
368
+ )
369
+
370
+ if debug_validation:
371
+ logger.info("Created model configuration:")
372
+ logger.info(" Title: %s", config.get("title"))
373
+ logger.info(" Extra: %s", config.get("extra"))
374
+ logger.info(
375
+ " Validate Default: %s", config.get("validate_default")
376
+ )
377
+ logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
378
+ logger.info(
379
+ " Arbitrary Types: %s", config.get("arbitrary_types_allowed")
380
+ )
381
+ logger.info(
382
+ " JSON Schema Extra: %s", config.get("json_schema_extra")
383
+ )
384
+
385
+ # Process schema properties into fields
386
+ properties = schema.get("properties", {})
387
+ required = schema.get("required", [])
388
+
389
+ field_definitions: Dict[str, Tuple[Type[Any], FieldInfo]] = {}
390
+ for field_name, field_schema in properties.items():
391
+ if debug_validation:
392
+ logger.info("Processing field %s:", field_name)
393
+ logger.info(" Schema: %s", json.dumps(field_schema, indent=2))
394
+
395
+ try:
396
+ python_type, field = _get_type_with_constraints(
397
+ field_schema, field_name, base_name
398
+ )
399
+
400
+ # Handle optional fields
401
+ if field_name not in required:
402
+ if debug_validation:
403
+ logger.info(
404
+ "Field %s is optional, wrapping in Optional",
405
+ field_name,
406
+ )
407
+ field_type = cast(Type[Any], Optional[python_type])
408
+ else:
409
+ field_type = python_type
410
+ if debug_validation:
411
+ logger.info("Field %s is required", field_name)
412
+
413
+ # Create field definition
414
+ field_definitions[field_name] = (field_type, field)
415
+
416
+ if debug_validation:
417
+ logger.info("Successfully created field definition:")
418
+ logger.info(" Name: %s", field_name)
419
+ logger.info(" Type: %s", str(field_type))
420
+ logger.info(" Required: %s", field_name in required)
421
+
422
+ except (FieldDefinitionError, NestedModelError) as e:
423
+ if debug_validation:
424
+ logger.error("Error creating field %s:", field_name)
425
+ logger.error(" Error type: %s", type(e).__name__)
426
+ logger.error(" Error message: %s", str(e))
427
+ raise ModelValidationError(base_name, [str(e)])
428
+
429
+ # Create the model with the fields
430
+ field_defs: Dict[str, Any] = {
431
+ name: (
432
+ (
433
+ cast(Type[Any], field_type)
434
+ if is_container_type(field_type)
435
+ else field_type
436
+ ),
437
+ field,
438
+ )
439
+ for name, (field_type, field) in field_definitions.items()
440
+ }
441
+ model: Type[BaseModel] = create_model(
442
+ base_name, __config__=config, **field_defs
443
+ )
444
+
445
+ # Set the model config after creation
446
+ model.model_config = config
447
+
448
+ if debug_validation:
449
+ logger.info("Successfully created model: %s", model.__name__)
450
+ logger.info("Model config: %s", dict(model.model_config))
451
+ logger.info(
452
+ "Model schema: %s",
453
+ json.dumps(model.model_json_schema(), indent=2),
454
+ )
455
+
456
+ # Validate the model's JSON schema
457
+ try:
458
+ model.model_json_schema()
459
+ except ValidationError as e:
460
+ validation_errors = (
461
+ [str(err) for err in e.errors()]
462
+ if hasattr(e, "errors")
463
+ else [str(e)]
464
+ )
465
+ if debug_validation:
466
+ logger.error("Schema validation failed:")
467
+ logger.error(" Error type: %s", type(e).__name__)
468
+ logger.error(" Error message: %s", str(e))
469
+ raise ModelValidationError(base_name, validation_errors)
470
+
471
+ return model
472
+
473
+ except SchemaValidationError as e:
474
+ # Always log basic error info
475
+ logger.error("Schema validation error: %s", str(e))
476
+
477
+ # Log additional debug info if requested
478
+ if debug_validation:
479
+ logger.error(" Error type: %s", type(e).__name__)
480
+ logger.error(" Error details: %s", str(e))
481
+ # Always raise schema validation errors directly
482
+ raise
483
+
484
+ except Exception as e:
485
+ # Always log basic error info
486
+ logger.error("Model creation error: %s", str(e))
487
+
488
+ # Log additional debug info if requested
489
+ if debug_validation:
490
+ logger.error(" Error type: %s", type(e).__name__)
491
+ logger.error(" Error details: %s", str(e))
492
+ if hasattr(e, "__cause__"):
493
+ logger.error(" Caused by: %s", str(e.__cause__))
494
+ if hasattr(e, "__context__"):
495
+ logger.error(" Context: %s", str(e.__context__))
496
+ if hasattr(e, "__traceback__"):
497
+ import traceback
498
+
499
+ logger.error(
500
+ " Traceback:\n%s",
501
+ "".join(traceback.format_tb(e.__traceback__)),
502
+ )
503
+ # Always wrap other errors as ModelCreationError
504
+ raise ModelCreationError(
505
+ f"Failed to create model {base_name}",
506
+ context={"error": str(e)},
507
+ ) from e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ostruct-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: CLI for OpenAI Structured Output
5
5
  Author: Yaniv Golan
6
6
  Author-email: yaniv@golan.name
@@ -2,13 +2,14 @@ ostruct/__init__.py,sha256=X6zo6V7ZNMv731Wi388aTVQngD1410ExGwGx4J6lpyo,187
2
2
  ostruct/cli/__init__.py,sha256=sYHKT6o1kFy1acbXejzAvVm8Cy8U91Yf1l4DlzquHKg,409
3
3
  ostruct/cli/base_errors.py,sha256=S1cQxoiALbXKPxzgLo6XdSWpzPRb7RKz0QARmu9Zt4g,5987
4
4
  ostruct/cli/cache_manager.py,sha256=ej3KrRfkKKZ_lEp2JswjbJ5bW2ncsvna9NeJu81cqqs,5192
5
- ostruct/cli/cli.py,sha256=wfO5Z8PPoP8eUn5CfhxrjrdMzfbvr4ryo_tsRST0LlU,74588
5
+ ostruct/cli/cli.py,sha256=lagB4j8G1hg2NmAYvWEarA24qYuY2w-cuRWiqUzoWik,65105
6
6
  ostruct/cli/click_options.py,sha256=WbRJdB9sO63ChN3fnCP7XWs73DHKl0C1ervfwL11am0,11371
7
7
  ostruct/cli/errors.py,sha256=zJdJ-AyzjCE8glVKbJGAcB-Mz1J1SlzTDJDmhqAVFYc,14930
8
8
  ostruct/cli/exit_codes.py,sha256=uNjvQeUGwU1mlUJYIDrExAn7YlwOXZo603yLAwpqIwk,338
9
9
  ostruct/cli/file_info.py,sha256=ilpT8IuckfhadLF1QQAPLXJp7p8kVpffDEEJ2erHPZU,14485
10
10
  ostruct/cli/file_list.py,sha256=jLuCd1ardoAXX8FNwPgIqEM-ixzr1xP5ZSqXo2lmrj0,11270
11
11
  ostruct/cli/file_utils.py,sha256=J3-6fbEGQ7KD_bU81pAxueHLv9XV0X7f8FSMt_0AJGQ,22537
12
+ ostruct/cli/model_creation.py,sha256=TmqJVdnZOYtTctNihOlxWIbyAfX-zfxehP9rp2t6P2c,17586
12
13
  ostruct/cli/path_utils.py,sha256=j44q1OoLkqMErgK-qEuhuIZ1VyzqRIvNgxR1et9PoXA,4813
13
14
  ostruct/cli/progress.py,sha256=rj9nVEco5UeZORMbzd7mFJpFGJjbH9KbBFh5oTE5Anw,3415
14
15
  ostruct/cli/schema_validation.py,sha256=ohEuxJ0KF93qphj0JSZDnrxDn0C2ZU37g-U2JY03onM,8154
@@ -36,8 +37,8 @@ ostruct/cli/token_utils.py,sha256=r4KPEO3Sec18Q6mU0aClK6XGShvusgUggXEQgEPPlaA,13
36
37
  ostruct/cli/utils.py,sha256=1UCl4rHjBWKR5EKugvlVGHiHjO3XXmqvkgeAUSyIPDU,831
37
38
  ostruct/cli/validators.py,sha256=BYFZeebCPZObTUjO1TaAMpsD6h7ROkYAFn9C7uf1Q68,2992
38
39
  ostruct/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
- ostruct_cli-0.6.0.dist-info/LICENSE,sha256=QUOY6QCYVxAiH8vdrUTDqe3i9hQ5bcNczppDSVpLTjk,1068
40
- ostruct_cli-0.6.0.dist-info/METADATA,sha256=Zrq8a-EvLhnZdOQBBlYvONWKo61XmdAR8934_OtHUa4,10426
41
- ostruct_cli-0.6.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
42
- ostruct_cli-0.6.0.dist-info/entry_points.txt,sha256=NFq9IuqHVTem0j9zKjV8C1si_zGcP1RL6Wbvt9fUDXw,48
43
- ostruct_cli-0.6.0.dist-info/RECORD,,
40
+ ostruct_cli-0.6.1.dist-info/LICENSE,sha256=QUOY6QCYVxAiH8vdrUTDqe3i9hQ5bcNczppDSVpLTjk,1068
41
+ ostruct_cli-0.6.1.dist-info/METADATA,sha256=2D0_QCNb3xN2Y_K1pMB5WmZBcU8KkN2rqS9qwZMa-pc,10426
42
+ ostruct_cli-0.6.1.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
43
+ ostruct_cli-0.6.1.dist-info/entry_points.txt,sha256=NFq9IuqHVTem0j9zKjV8C1si_zGcP1RL6Wbvt9fUDXw,48
44
+ ostruct_cli-0.6.1.dist-info/RECORD,,