string-schema 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,662 @@
1
+ """
2
+ Pydantic integration for Simple Schema
3
+
4
+ Contains functions for creating Pydantic models from Simple Schema definitions.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional, Union, Type
8
+ import logging
9
+
10
+ from ..core.fields import SimpleField
11
+
12
+ # Optional pydantic import
13
+ try:
14
+ from pydantic import BaseModel, Field, create_model
15
+ HAS_PYDANTIC = True
16
+ except ImportError:
17
+ HAS_PYDANTIC = False
18
+ BaseModel = None
19
+ Field = None
20
+ create_model = None
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def create_pydantic_model(name: str, fields: Dict[str, Union[str, SimpleField]],
26
+ base_class: Type = None) -> Type:
27
+ """
28
+ Create Pydantic model from Simple Schema field definitions.
29
+
30
+ Args:
31
+ name: Name of the model class
32
+ fields: Dictionary of field definitions
33
+ base_class: Base class to inherit from (default: BaseModel)
34
+
35
+ Returns:
36
+ Dynamically created Pydantic model class
37
+ """
38
+ if not HAS_PYDANTIC:
39
+ raise ImportError("Pydantic is required for create_pydantic_model. Install with: pip install pydantic")
40
+
41
+ if base_class is None:
42
+ base_class = BaseModel
43
+
44
+ pydantic_fields = {}
45
+
46
+ for field_name, field_def in fields.items():
47
+ if isinstance(field_def, str):
48
+ field_def = SimpleField(field_def)
49
+
50
+ python_type, field_info = _simple_field_to_pydantic(field_def)
51
+ pydantic_fields[field_name] = (python_type, field_info)
52
+
53
+ # Create the model with the specified base class
54
+ return create_model(name, __base__=base_class, **pydantic_fields)
55
+
56
+
57
+ def _simple_field_to_pydantic(field: SimpleField) -> tuple:
58
+ """Convert SimpleField to Pydantic field specification"""
59
+ if not HAS_PYDANTIC:
60
+ raise ImportError("Pydantic is required for this function")
61
+
62
+ # Type mapping
63
+ type_mapping = {
64
+ 'string': str,
65
+ 'integer': int,
66
+ 'number': float,
67
+ 'boolean': bool
68
+ }
69
+
70
+ python_type = type_mapping.get(field.field_type, str)
71
+
72
+ # Handle union types
73
+ if field.union_types and len(field.union_types) > 1:
74
+ from typing import Union as TypingUnion
75
+ union_python_types = []
76
+ for union_type in field.union_types:
77
+ if union_type == "null":
78
+ union_python_types.append(type(None))
79
+ else:
80
+ union_python_types.append(type_mapping.get(union_type, str))
81
+ python_type = TypingUnion[tuple(union_python_types)]
82
+
83
+ # Handle optional fields
84
+ if not field.required:
85
+ python_type = Optional[python_type]
86
+
87
+ # Build Field arguments
88
+ field_kwargs = {}
89
+
90
+ if field.description:
91
+ field_kwargs['description'] = field.description
92
+
93
+ if field.default is not None:
94
+ field_kwargs['default'] = field.default
95
+ elif not field.required:
96
+ field_kwargs['default'] = None
97
+
98
+ # Numeric constraints
99
+ if field.min_val is not None:
100
+ field_kwargs['ge'] = field.min_val
101
+ if field.max_val is not None:
102
+ field_kwargs['le'] = field.max_val
103
+
104
+ # String constraints
105
+ if field.min_length is not None:
106
+ field_kwargs['min_length'] = field.min_length
107
+ if field.max_length is not None:
108
+ field_kwargs['max_length'] = field.max_length
109
+
110
+ return python_type, Field(**field_kwargs) if field_kwargs else Field()
111
+
112
+
113
+ # New consistent naming
114
+ def json_schema_to_model(json_schema: Dict[str, Any], name: str) -> Type[BaseModel]:
115
+ """
116
+ Create Pydantic model from JSON Schema dictionary.
117
+
118
+ Args:
119
+ json_schema: JSON Schema dictionary
120
+ name: Name of the model class
121
+
122
+ Returns:
123
+ Dynamically created Pydantic model class
124
+
125
+ Example:
126
+ UserModel = json_schema_to_model(json_schema, 'User')
127
+ """
128
+ return create_pydantic_from_json_schema(json_schema, name)
129
+
130
+
131
+ # Legacy alias for backward compatibility
132
+ def json_schema_to_pydantic(json_schema: Dict[str, Any], name: str) -> Type[BaseModel]:
133
+ """
134
+ Create Pydantic model from JSON Schema dictionary.
135
+
136
+ DEPRECATED: Use json_schema_to_model() instead for consistent naming.
137
+ """
138
+ return json_schema_to_model(json_schema, name)
139
+
140
+
141
+ def create_pydantic_from_json_schema(json_schema: Dict[str, Any], name: str) -> Type[BaseModel]:
142
+ """
143
+ Create Pydantic model from JSON Schema.
144
+
145
+ Args:
146
+ json_schema: JSON Schema dictionary
147
+ name: Name of the model class
148
+
149
+ Returns:
150
+ Dynamically created Pydantic model class
151
+ """
152
+ if json_schema.get('type') != 'object':
153
+ raise ValueError("JSON Schema must be of type 'object' to create Pydantic model")
154
+
155
+ properties = json_schema.get('properties', {})
156
+ required_fields = set(json_schema.get('required', []))
157
+
158
+ pydantic_fields = {}
159
+
160
+ for field_name, field_schema in properties.items():
161
+ python_type, field_info = _json_schema_to_pydantic_field(field_schema, field_name in required_fields, f"{name}{field_name.title()}")
162
+ pydantic_fields[field_name] = (python_type, field_info)
163
+
164
+ return create_model(name, **pydantic_fields)
165
+
166
+
167
+ def _json_schema_to_pydantic_field(field_schema: Dict[str, Any], required: bool = True, parent_name: str = "Nested") -> tuple:
168
+ """Convert JSON Schema field to Pydantic field specification"""
169
+ # Type mapping
170
+ type_mapping = {
171
+ 'string': str,
172
+ 'integer': int,
173
+ 'number': float,
174
+ 'boolean': bool
175
+ }
176
+
177
+ # Handle union types
178
+ if 'anyOf' in field_schema:
179
+ from typing import Union as TypingUnion
180
+ union_types = []
181
+ for union_option in field_schema['anyOf']:
182
+ option_type = type_mapping.get(union_option.get('type', 'string'), str)
183
+ union_types.append(option_type)
184
+ python_type = TypingUnion[tuple(union_types)]
185
+ elif field_schema.get('type') == 'object':
186
+ # Handle nested objects by creating a nested Pydantic model
187
+ nested_model = create_pydantic_from_json_schema(field_schema, f"{parent_name}Nested")
188
+ python_type = nested_model
189
+ elif field_schema.get('type') == 'array':
190
+ # Handle arrays
191
+ items_schema = field_schema.get('items', {})
192
+ if items_schema.get('type') == 'object':
193
+ # Array of objects
194
+ from typing import List
195
+ item_model = create_pydantic_from_json_schema(items_schema, f"{parent_name}Item")
196
+ python_type = List[item_model]
197
+ else:
198
+ # Array of primitives
199
+ from typing import List
200
+ item_type = type_mapping.get(items_schema.get('type', 'string'), str)
201
+ python_type = List[item_type]
202
+ else:
203
+ field_type = field_schema.get('type', 'string')
204
+ python_type = type_mapping.get(field_type, str)
205
+
206
+ # Handle optional fields
207
+ if not required:
208
+ python_type = Optional[python_type]
209
+
210
+ # Build Field arguments
211
+ field_kwargs = {}
212
+
213
+ if 'description' in field_schema:
214
+ field_kwargs['description'] = field_schema['description']
215
+
216
+ if 'default' in field_schema:
217
+ field_kwargs['default'] = field_schema['default']
218
+ elif not required:
219
+ field_kwargs['default'] = None
220
+
221
+ # Numeric constraints
222
+ if 'minimum' in field_schema:
223
+ field_kwargs['ge'] = field_schema['minimum']
224
+ if 'maximum' in field_schema:
225
+ field_kwargs['le'] = field_schema['maximum']
226
+
227
+ # String constraints
228
+ if 'minLength' in field_schema:
229
+ field_kwargs['min_length'] = field_schema['minLength']
230
+ if 'maxLength' in field_schema:
231
+ field_kwargs['max_length'] = field_schema['maxLength']
232
+
233
+ # Format constraints (email, url, etc.)
234
+ if 'format' in field_schema:
235
+ format_type = field_schema['format']
236
+ if format_type == 'email':
237
+ # Use EmailStr for email validation
238
+ try:
239
+ from pydantic import EmailStr
240
+ python_type = EmailStr
241
+ if not required:
242
+ python_type = Optional[EmailStr]
243
+ except ImportError:
244
+ # Fallback to string with pattern validation
245
+ field_kwargs['pattern'] = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
246
+ elif format_type in ['uri', 'url']:
247
+ # Use HttpUrl for URL validation
248
+ try:
249
+ from pydantic import HttpUrl
250
+ python_type = HttpUrl
251
+ if not required:
252
+ python_type = Optional[HttpUrl]
253
+ except ImportError:
254
+ # Fallback to string
255
+ pass
256
+ elif format_type == 'uuid':
257
+ # Use UUID type for UUID validation
258
+ try:
259
+ from uuid import UUID
260
+ python_type = UUID
261
+ if not required:
262
+ python_type = Optional[UUID]
263
+ except ImportError:
264
+ # Fallback to string
265
+ pass
266
+ elif format_type == 'date-time':
267
+ # Use datetime for datetime validation
268
+ try:
269
+ from datetime import datetime
270
+ python_type = datetime
271
+ if not required:
272
+ python_type = Optional[datetime]
273
+ except ImportError:
274
+ # Fallback to string
275
+ pass
276
+
277
+ # Array constraints
278
+ if 'minItems' in field_schema:
279
+ field_kwargs['min_length'] = field_schema['minItems']
280
+ if 'maxItems' in field_schema:
281
+ field_kwargs['max_length'] = field_schema['maxItems']
282
+
283
+ # Enum constraints
284
+ if 'enum' in field_schema:
285
+ # Create a Literal type for enum validation
286
+ from typing import Literal
287
+ enum_values = field_schema['enum']
288
+ if len(enum_values) == 1:
289
+ python_type = Literal[enum_values[0]]
290
+ else:
291
+ python_type = Literal[tuple(enum_values)]
292
+
293
+ # Handle optional enum fields
294
+ if not required:
295
+ python_type = Optional[python_type]
296
+
297
+ return python_type, Field(**field_kwargs) if field_kwargs else Field()
298
+
299
+
300
+ def model_to_simple_fields(model: Type[BaseModel]) -> Dict[str, SimpleField]:
301
+ """
302
+ Convert Pydantic model to Simple Schema field definitions.
303
+
304
+ Args:
305
+ model: Pydantic model class
306
+
307
+ Returns:
308
+ Dictionary of SimpleField objects
309
+ """
310
+ fields = {}
311
+
312
+ for field_name, field_info in model.model_fields.items():
313
+ simple_field = _pydantic_field_to_simple_field(field_info, field_name)
314
+ fields[field_name] = simple_field
315
+
316
+ return fields
317
+
318
+
319
+ def _pydantic_field_to_simple_field(field_info: Any, field_name: str) -> SimpleField:
320
+ """Convert Pydantic field info to SimpleField"""
321
+ # This is a simplified conversion - could be enhanced
322
+
323
+ # Determine field type
324
+ annotation = getattr(field_info, 'annotation', str)
325
+
326
+ if annotation == str:
327
+ field_type = 'string'
328
+ elif annotation == int:
329
+ field_type = 'integer'
330
+ elif annotation == float:
331
+ field_type = 'number'
332
+ elif annotation == bool:
333
+ field_type = 'boolean'
334
+ else:
335
+ # Handle Optional and Union types
336
+ if hasattr(annotation, '__origin__'):
337
+ if annotation.__origin__ is Union:
338
+ # Union type - use first non-None type
339
+ args = annotation.__args__
340
+ non_none_types = [arg for arg in args if arg is not type(None)]
341
+ if non_none_types:
342
+ first_type = non_none_types[0]
343
+ if first_type == str:
344
+ field_type = 'string'
345
+ elif first_type == int:
346
+ field_type = 'integer'
347
+ elif first_type == float:
348
+ field_type = 'number'
349
+ elif first_type == bool:
350
+ field_type = 'boolean'
351
+ else:
352
+ field_type = 'string'
353
+ else:
354
+ field_type = 'string'
355
+ else:
356
+ field_type = 'string'
357
+ else:
358
+ field_type = 'string'
359
+
360
+ # Determine if required
361
+ required = field_info.is_required() if hasattr(field_info, 'is_required') else True
362
+
363
+ # Get default value
364
+ default = getattr(field_info, 'default', None)
365
+ if default is ...: # Ellipsis indicates no default
366
+ default = None
367
+
368
+ # Get description
369
+ description = getattr(field_info, 'description', '')
370
+
371
+ # Create SimpleField
372
+ simple_field = SimpleField(
373
+ field_type=field_type,
374
+ description=description,
375
+ required=required,
376
+ default=default
377
+ )
378
+
379
+ return simple_field
380
+
381
+
382
+ def validate_pydantic_compatibility(fields: Dict[str, SimpleField]) -> Dict[str, Any]:
383
+ """
384
+ Validate that Simple Schema fields are compatible with Pydantic.
385
+
386
+ Args:
387
+ fields: Dictionary of SimpleField objects
388
+
389
+ Returns:
390
+ Validation result dictionary
391
+ """
392
+ result = {
393
+ 'compatible': True,
394
+ 'warnings': [],
395
+ 'errors': []
396
+ }
397
+
398
+ for field_name, field in fields.items():
399
+ # Check for unsupported features
400
+ if field.union_types and len(field.union_types) > 2:
401
+ result['warnings'].append(f"Field '{field_name}' has complex union type - may need manual handling")
402
+
403
+ if field.format_hint and field.format_hint not in ['email', 'url', 'uuid']:
404
+ result['warnings'].append(f"Field '{field_name}' format hint '{field.format_hint}' may not be fully supported")
405
+
406
+ # Check for conflicting constraints
407
+ if field.min_val is not None and field.max_val is not None:
408
+ if field.min_val > field.max_val:
409
+ result['errors'].append(f"Field '{field_name}' has min_val > max_val")
410
+
411
+ if field.min_length is not None and field.max_length is not None:
412
+ if field.min_length > field.max_length:
413
+ result['errors'].append(f"Field '{field_name}' has min_length > max_length")
414
+
415
+ result['compatible'] = len(result['errors']) == 0
416
+ return result
417
+
418
+
419
+ def _string_to_model_with_name(name: str, schema_str: str) -> Type[BaseModel]:
420
+ """
421
+ Create Pydantic model directly from string syntax with explicit name.
422
+
423
+ Internal function - use string_to_model from utilities instead.
424
+
425
+ Args:
426
+ name: Name of the model class
427
+ schema_str: String schema definition (e.g., "name:string, email:email")
428
+
429
+ Returns:
430
+ Dynamically created Pydantic model class
431
+
432
+ Example:
433
+ UserModel = _string_to_model_with_name('User', "name:string, email:email, age:int?")
434
+ user = UserModel(name="John", email="john@example.com")
435
+ """
436
+ if not HAS_PYDANTIC:
437
+ raise ImportError("Pydantic is required for _string_to_model_with_name. Install with: pip install pydantic")
438
+
439
+ # Import here to avoid circular imports
440
+ from ..parsing.string_parser import parse_string_schema
441
+
442
+ # Convert string to JSON Schema, then to Pydantic
443
+ json_schema = parse_string_schema(schema_str)
444
+ return create_pydantic_from_json_schema(json_schema, name)
445
+
446
+
447
+ # Legacy alias for backward compatibility
448
+ def string_to_pydantic(name: str, schema_str: str) -> Type[BaseModel]:
449
+ """
450
+ Create Pydantic model directly from string syntax.
451
+
452
+ DEPRECATED: Use string_to_model() instead for consistent naming.
453
+ """
454
+ return _string_to_model_with_name(name, schema_str)
455
+
456
+
457
+ def string_to_model_code(name: str, schema_str: str) -> str:
458
+ """
459
+ Generate Pydantic model code directly from string syntax.
460
+
461
+ Args:
462
+ name: Name of the model class
463
+ schema_str: String schema definition (e.g., "name:string, email:email")
464
+
465
+ Returns:
466
+ Python code string for the Pydantic model
467
+
468
+ Example:
469
+ code = string_to_model_code('User', "name:string, email:email, age:int?")
470
+ print(code)
471
+ # Output:
472
+ # from pydantic import BaseModel, Field
473
+ # from typing import Optional, Union
474
+ #
475
+ # class User(BaseModel):
476
+ # name: str
477
+ # email: str = Field(format='email')
478
+ # age: Optional[int] = None
479
+ """
480
+ # Import here to avoid circular imports
481
+ from ..parsing.string_parser import parse_string_schema
482
+
483
+ # Convert string to JSON Schema, then to SimpleField objects, then to code
484
+ json_schema = parse_string_schema(schema_str)
485
+
486
+ # Convert JSON Schema back to SimpleField objects for code generation
487
+ # This is a bit roundabout, but maintains compatibility with existing code
488
+ properties = json_schema.get('properties', {})
489
+ required_fields = set(json_schema.get('required', []))
490
+
491
+ fields = {}
492
+ for field_name, field_schema in properties.items():
493
+ # Convert JSON Schema property back to SimpleField
494
+ simple_field = _json_schema_to_simple_field(field_schema, field_name in required_fields)
495
+ fields[field_name] = simple_field
496
+
497
+ return generate_pydantic_code(name, fields)
498
+
499
+
500
+ # Legacy alias for backward compatibility
501
+ def string_to_pydantic_code(name: str, schema_str: str) -> str:
502
+ """
503
+ Generate Pydantic model code directly from string syntax.
504
+
505
+ DEPRECATED: Use string_to_model_code() instead for consistent naming.
506
+ """
507
+ return string_to_model_code(name, schema_str)
508
+
509
+
510
+ # Reverse conversion functions
511
+ def model_to_string(model: Type[BaseModel]) -> str:
512
+ """
513
+ Convert Pydantic model to Simple Schema string syntax.
514
+
515
+ Args:
516
+ model: Pydantic model class
517
+
518
+ Returns:
519
+ String representation in Simple Schema syntax
520
+
521
+ Example:
522
+ UserModel = string_to_model("name:string, email:email, age:int?")
523
+ schema_str = model_to_string(UserModel)
524
+ # Returns: "name:string, email:email, age:int?"
525
+ """
526
+ from .reverse import model_to_string as _model_to_string
527
+ return _model_to_string(model)
528
+
529
+
530
+ def model_to_json_schema(model: Type[BaseModel]) -> Dict[str, Any]:
531
+ """
532
+ Convert Pydantic model to JSON Schema.
533
+
534
+ Args:
535
+ model: Pydantic model class
536
+
537
+ Returns:
538
+ JSON Schema dictionary
539
+
540
+ Example:
541
+ UserModel = string_to_model("name:string, email:email")
542
+ json_schema = model_to_json_schema(UserModel)
543
+ """
544
+ from .reverse import model_to_json_schema as _model_to_json_schema
545
+ return _model_to_json_schema(model)
546
+
547
+
548
+ def _json_schema_to_simple_field(field_schema: Dict[str, Any], required: bool) -> SimpleField:
549
+ """Convert JSON Schema property back to SimpleField for code generation"""
550
+ from ..core.fields import SimpleField
551
+
552
+ field_type = field_schema.get('type', 'string')
553
+ format_hint = field_schema.get('format')
554
+
555
+ # Handle constraints
556
+ kwargs = {
557
+ 'required': required,
558
+ 'description': field_schema.get('description', ''),
559
+ }
560
+
561
+ if 'minimum' in field_schema:
562
+ kwargs['min_val'] = field_schema['minimum']
563
+ if 'maximum' in field_schema:
564
+ kwargs['max_val'] = field_schema['maximum']
565
+ if 'minLength' in field_schema:
566
+ kwargs['min_length'] = field_schema['minLength']
567
+ if 'maxLength' in field_schema:
568
+ kwargs['max_length'] = field_schema['maxLength']
569
+ if 'enum' in field_schema:
570
+ kwargs['choices'] = field_schema['enum']
571
+ if format_hint:
572
+ kwargs['format_hint'] = format_hint
573
+
574
+ return SimpleField(field_type, **kwargs)
575
+
576
+
577
+ def generate_pydantic_code(name: str, fields: Dict[str, SimpleField]) -> str:
578
+ """
579
+ Generate Pydantic model code as a string.
580
+
581
+ Args:
582
+ name: Name of the model class
583
+ fields: Dictionary of SimpleField objects
584
+
585
+ Returns:
586
+ Python code string for the Pydantic model
587
+ """
588
+ lines = [
589
+ "from pydantic import BaseModel, Field",
590
+ "from typing import Optional, Union",
591
+ "",
592
+ f"class {name}(BaseModel):"
593
+ ]
594
+
595
+ if not fields:
596
+ lines.append(" pass")
597
+ return "\n".join(lines)
598
+
599
+ for field_name, field in fields.items():
600
+ field_line = _generate_pydantic_field_code(field_name, field)
601
+ lines.append(f" {field_line}")
602
+
603
+ return "\n".join(lines)
604
+
605
+
606
+ def _generate_pydantic_field_code(field_name: str, field: SimpleField) -> str:
607
+ """Generate code for a single Pydantic field"""
608
+ # Determine Python type
609
+ type_mapping = {
610
+ 'string': 'str',
611
+ 'integer': 'int',
612
+ 'number': 'float',
613
+ 'boolean': 'bool'
614
+ }
615
+
616
+ python_type = type_mapping.get(field.field_type, 'str')
617
+
618
+ # Handle union types
619
+ if field.union_types and len(field.union_types) > 1:
620
+ union_types = []
621
+ for union_type in field.union_types:
622
+ if union_type == 'null':
623
+ union_types.append('None')
624
+ else:
625
+ union_types.append(type_mapping.get(union_type, 'str'))
626
+ python_type = f"Union[{', '.join(union_types)}]"
627
+
628
+ # Handle optional
629
+ if not field.required:
630
+ if 'Union' not in python_type:
631
+ python_type = f"Optional[{python_type}]"
632
+
633
+ # Build field definition
634
+ field_parts = []
635
+
636
+ if field.description:
637
+ field_parts.append(f"description='{field.description}'")
638
+
639
+ if field.default is not None:
640
+ if isinstance(field.default, str):
641
+ field_parts.append(f"default='{field.default}'")
642
+ else:
643
+ field_parts.append(f"default={field.default}")
644
+ elif not field.required:
645
+ field_parts.append("default=None")
646
+
647
+ # Add constraints
648
+ if field.min_val is not None:
649
+ field_parts.append(f"ge={field.min_val}")
650
+ if field.max_val is not None:
651
+ field_parts.append(f"le={field.max_val}")
652
+ if field.min_length is not None:
653
+ field_parts.append(f"min_length={field.min_length}")
654
+ if field.max_length is not None:
655
+ field_parts.append(f"max_length={field.max_length}")
656
+
657
+ if field_parts:
658
+ field_def = f"Field({', '.join(field_parts)})"
659
+ else:
660
+ field_def = "Field()"
661
+
662
+ return f"{field_name}: {python_type} = {field_def}"