morphic 0.1.0__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.
- morphic/__init__.py +7 -0
- morphic/autoenum.py +397 -0
- morphic/registry.py +955 -0
- morphic/typed.py +1327 -0
- morphic-0.1.0.dist-info/METADATA +333 -0
- morphic-0.1.0.dist-info/RECORD +8 -0
- morphic-0.1.0.dist-info/WHEEL +4 -0
- morphic-0.1.0.dist-info/licenses/LICENSE +21 -0
morphic/typed.py
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
"""Enhanced base configuration class with Pydantic-like functionality."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import Field, fields, MISSING
|
|
4
|
+
from typing import Any, ClassVar, Dict, Type, TypeVar, Union, get_args, get_origin, Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", bound="Typed")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Typed:
|
|
12
|
+
"""Base class for all configuration classes with enhanced dict conversion and validation.
|
|
13
|
+
|
|
14
|
+
This class provides Pydantic-like functionality for dataclasses without external dependencies.
|
|
15
|
+
Subclasses automatically become dataclasses - no @dataclass decorator needed!
|
|
16
|
+
Validation is automatically called after instance creation.
|
|
17
|
+
|
|
18
|
+
Features:
|
|
19
|
+
- Automatic dataclass transformation for subclasses
|
|
20
|
+
- Automatic type validation for all field types
|
|
21
|
+
- Automatic nested Typed conversion in constructor
|
|
22
|
+
- Automatic validation after instance creation
|
|
23
|
+
- Automatic type conversion from dictionaries
|
|
24
|
+
- **Default value validation and conversion at class definition time**
|
|
25
|
+
- **Automatic mutable default handling with default_factory**
|
|
26
|
+
- **Hierarchical default value conversion (nested Typeds, lists, dicts)**
|
|
27
|
+
- AutoEnum string conversion with fuzzy matching and aliases (if morphic.AutoEnum is available)
|
|
28
|
+
- Nested object support with validation
|
|
29
|
+
- Serialization/deserialization with filtering options
|
|
30
|
+
- Field caching for performance
|
|
31
|
+
- Copy with modifications
|
|
32
|
+
|
|
33
|
+
Default Value Features:
|
|
34
|
+
- Default values are validated and converted at class definition time
|
|
35
|
+
- Invalid defaults raise clear errors when the class is defined
|
|
36
|
+
- Convertible defaults are automatically transformed (e.g., "25" -> 25 for int fields)
|
|
37
|
+
- Mutable defaults (lists, dicts, Typed objects) are automatically converted to default_factory
|
|
38
|
+
- Hierarchical structures in defaults are recursively converted
|
|
39
|
+
- Supports Optional fields, Union types, and complex nested structures
|
|
40
|
+
|
|
41
|
+
Basic Usage Examples:
|
|
42
|
+
```python
|
|
43
|
+
from morphic import Typed, AutoEnum, alias
|
|
44
|
+
from typing import List, Dict, Optional, Union
|
|
45
|
+
|
|
46
|
+
# Simple dataclass with automatic validation
|
|
47
|
+
class User(Typed):
|
|
48
|
+
name: str
|
|
49
|
+
age: int
|
|
50
|
+
active: bool = True
|
|
51
|
+
|
|
52
|
+
def validate(self):
|
|
53
|
+
if self.age < 0:
|
|
54
|
+
raise ValueError("Age must be non-negative")
|
|
55
|
+
|
|
56
|
+
# Validation happens automatically during creation
|
|
57
|
+
user = User(name="John", age=30)
|
|
58
|
+
print(user.name, user.age, user.active) # John 30 True
|
|
59
|
+
|
|
60
|
+
# Type validation catches mismatches immediately
|
|
61
|
+
try:
|
|
62
|
+
User(name=123, age=30) # Raises TypeError - name must be str
|
|
63
|
+
except TypeError as e:
|
|
64
|
+
print(f"Type error: {e}")
|
|
65
|
+
|
|
66
|
+
# from_dict with automatic type conversion
|
|
67
|
+
user = User.from_dict({"name": "John", "age": "30"}) # "30" -> int(30)
|
|
68
|
+
assert user.age == 30 and isinstance(user.age, int)
|
|
69
|
+
|
|
70
|
+
# Custom validation runs after type validation
|
|
71
|
+
try:
|
|
72
|
+
User(name="John", age=-5) # Raises ValueError from validate()
|
|
73
|
+
except ValueError as e:
|
|
74
|
+
print(f"Validation error: {e}")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Advanced Examples:
|
|
78
|
+
```python
|
|
79
|
+
# Nested Typed objects with automatic conversion
|
|
80
|
+
class Address(Typed):
|
|
81
|
+
street: str
|
|
82
|
+
city: str
|
|
83
|
+
zip_code: str = "00000"
|
|
84
|
+
|
|
85
|
+
class Company(Typed):
|
|
86
|
+
name: str
|
|
87
|
+
address: Address
|
|
88
|
+
employees: List[str] = []
|
|
89
|
+
|
|
90
|
+
# Dict automatically converted to Address object
|
|
91
|
+
company = Company(
|
|
92
|
+
name="Tech Corp",
|
|
93
|
+
address={"street": "123 Main St", "city": "NYC", "zip_code": "10001"}
|
|
94
|
+
)
|
|
95
|
+
assert isinstance(company.address, Address)
|
|
96
|
+
assert company.address.street == "123 Main St"
|
|
97
|
+
|
|
98
|
+
# Works with from_dict too
|
|
99
|
+
company_data = {
|
|
100
|
+
"name": "Tech Corp",
|
|
101
|
+
"address": {"street": "456 Oak Ave", "city": "SF"},
|
|
102
|
+
"employees": ["Alice", "Bob", "Charlie"]
|
|
103
|
+
}
|
|
104
|
+
company2 = Company.from_dict(company_data)
|
|
105
|
+
assert company2.address.zip_code == "00000" # Default value
|
|
106
|
+
|
|
107
|
+
# Complex nested structures
|
|
108
|
+
class Project(Typed):
|
|
109
|
+
name: str
|
|
110
|
+
team_lead: User
|
|
111
|
+
members: List[User]
|
|
112
|
+
settings: Dict[str, Union[str, int]]
|
|
113
|
+
|
|
114
|
+
project = Project.from_dict({
|
|
115
|
+
"name": "Alpha Project",
|
|
116
|
+
"team_lead": {"name": "Alice", "age": 30},
|
|
117
|
+
"members": [
|
|
118
|
+
{"name": "Bob", "age": 25},
|
|
119
|
+
{"name": "Charlie", "age": 28}
|
|
120
|
+
],
|
|
121
|
+
"settings": {"priority": "high", "budget": 50000}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
assert isinstance(project.team_lead, User)
|
|
125
|
+
assert all(isinstance(member, User) for member in project.members)
|
|
126
|
+
assert project.settings["budget"] == 50000
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Default Value Validation Examples:
|
|
130
|
+
```python
|
|
131
|
+
# Basic default value conversion
|
|
132
|
+
class Config(Typed):
|
|
133
|
+
port: int = "8080" # String automatically converted to int
|
|
134
|
+
debug: bool = "true" # String automatically converted to bool
|
|
135
|
+
timeout: float = "30.5" # String automatically converted to float
|
|
136
|
+
|
|
137
|
+
config = Config()
|
|
138
|
+
assert config.port == 8080 # Converted to int
|
|
139
|
+
assert isinstance(config.port, int)
|
|
140
|
+
|
|
141
|
+
# Invalid defaults caught at class definition time
|
|
142
|
+
try:
|
|
143
|
+
class BadConfig(Typed):
|
|
144
|
+
count: int = "not_a_number" # Raises TypeError immediately
|
|
145
|
+
except TypeError as e:
|
|
146
|
+
print(f"Invalid default caught: {e}")
|
|
147
|
+
|
|
148
|
+
# Hierarchical default conversion
|
|
149
|
+
class Contact(Typed):
|
|
150
|
+
name: str
|
|
151
|
+
email: str
|
|
152
|
+
|
|
153
|
+
class ContactList(Typed):
|
|
154
|
+
# Dict converted to Contact object automatically
|
|
155
|
+
primary: Contact = {"name": "Admin", "email": "admin@example.com"}
|
|
156
|
+
|
|
157
|
+
# List of dicts converted to list of Contact objects
|
|
158
|
+
contacts: List[Contact] = [
|
|
159
|
+
{"name": "John", "email": "john@example.com"},
|
|
160
|
+
{"name": "Jane", "email": "jane@example.com"}
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# Dict of dicts converted to dict of Contact objects
|
|
164
|
+
by_role: Dict[str, Contact] = {
|
|
165
|
+
"admin": {"name": "Administrator", "email": "admin@company.com"},
|
|
166
|
+
"user": {"name": "Regular User", "email": "user@company.com"}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# All defaults are properly converted and validated
|
|
170
|
+
contacts = ContactList()
|
|
171
|
+
assert isinstance(contacts.primary, Contact)
|
|
172
|
+
assert isinstance(contacts.contacts[0], Contact)
|
|
173
|
+
assert isinstance(contacts.by_role["admin"], Contact)
|
|
174
|
+
|
|
175
|
+
# Each instance gets its own copy of mutable defaults
|
|
176
|
+
contacts2 = ContactList()
|
|
177
|
+
contacts.contacts.append(Contact(name="New", email="new@example.com"))
|
|
178
|
+
assert len(contacts.contacts) == 3 # Modified
|
|
179
|
+
assert len(contacts2.contacts) == 2 # Unchanged
|
|
180
|
+
|
|
181
|
+
# Optional fields with proper None handling
|
|
182
|
+
class OptionalConfig(Typed):
|
|
183
|
+
name: str
|
|
184
|
+
description: Optional[str] = None # None is valid for Optional types
|
|
185
|
+
settings: Optional[Dict[str, str]] = None
|
|
186
|
+
|
|
187
|
+
config = OptionalConfig(name="test")
|
|
188
|
+
assert config.description is None
|
|
189
|
+
assert config.settings is None
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Error Handling:
|
|
193
|
+
Default value validation provides clear error messages that include:
|
|
194
|
+
- The class name where the error occurred
|
|
195
|
+
- The specific field name with the invalid default
|
|
196
|
+
- The expected type and actual type/value received
|
|
197
|
+
- Whether the error occurred during conversion or validation
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Example error message:
|
|
201
|
+
# TypeError: Invalid default value for field 'port' in class 'Config':
|
|
202
|
+
# Default value for field 'port' in class 'Config' expected type <class 'int'>,
|
|
203
|
+
# got str with value 'invalid_port_number'
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Performance and Best Practices:
|
|
207
|
+
- Default value validation occurs only once at class definition time
|
|
208
|
+
- Converted default values are cached and reused for all instances
|
|
209
|
+
- Mutable defaults are automatically handled to prevent shared state issues
|
|
210
|
+
- Use Optional[T] for fields that can legitimately be None
|
|
211
|
+
- Large or complex default structures are efficiently handled via default_factory
|
|
212
|
+
- Type conversion follows the same rules as from_dict() for consistency
|
|
213
|
+
|
|
214
|
+
Advanced Features:
|
|
215
|
+
- Supports Union types: Union[int, str] defaults try conversion in declaration order
|
|
216
|
+
- Handles deeply nested structures: Dict[str, List[Typed]] with full conversion
|
|
217
|
+
- Integrates with custom validation: default values must pass validate() method
|
|
218
|
+
- Compatible with dataclass field() for advanced default_factory scenarios
|
|
219
|
+
- Works seamlessly with AutoEnum string conversion and aliases
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
# Class-level cache for field information
|
|
223
|
+
_field_cache: ClassVar[Dict[Type, Dict[str, Field]]] = {}
|
|
224
|
+
|
|
225
|
+
def __init_subclass__(cls, **kwargs):
|
|
226
|
+
"""Automatically cache field information for subclasses and apply dataclass transformation."""
|
|
227
|
+
super().__init_subclass__(**kwargs)
|
|
228
|
+
|
|
229
|
+
# Validate and convert default values BEFORE applying dataclass transformation
|
|
230
|
+
# This ensures dataclass uses the converted values
|
|
231
|
+
cls._validate_and_convert_class_defaults()
|
|
232
|
+
|
|
233
|
+
# Automatically apply dataclass transformation if not already applied
|
|
234
|
+
if not hasattr(cls, "__dataclass_fields__"):
|
|
235
|
+
# Import dataclass here to avoid circular imports
|
|
236
|
+
from dataclasses import dataclass
|
|
237
|
+
|
|
238
|
+
# Apply dataclass transformation
|
|
239
|
+
dataclass_cls = dataclass(cls)
|
|
240
|
+
|
|
241
|
+
# Copy dataclass attributes back to the original class
|
|
242
|
+
# This is necessary because dataclass() returns a new class
|
|
243
|
+
cls.__dataclass_fields__ = dataclass_cls.__dataclass_fields__
|
|
244
|
+
cls.__init__ = dataclass_cls.__init__
|
|
245
|
+
cls.__repr__ = dataclass_cls.__repr__
|
|
246
|
+
cls.__eq__ = dataclass_cls.__eq__
|
|
247
|
+
|
|
248
|
+
# Copy any other dataclass-specific attributes that might exist
|
|
249
|
+
for attr_name in dir(dataclass_cls):
|
|
250
|
+
if attr_name.startswith("__dataclass") and not hasattr(cls, attr_name):
|
|
251
|
+
setattr(cls, attr_name, getattr(dataclass_cls, attr_name))
|
|
252
|
+
|
|
253
|
+
# Cache field information and validate default_factory after dataclass transformation
|
|
254
|
+
if hasattr(cls, "__dataclass_fields__"):
|
|
255
|
+
cls._field_cache[cls] = cls.__dataclass_fields__
|
|
256
|
+
cls._validate_default_factories()
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
"""Automatically called after dataclass initialization to run validation."""
|
|
260
|
+
self._convert_field_values()
|
|
261
|
+
self._validate_types()
|
|
262
|
+
self.validate()
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_dict(cls: Type[T], data: Dict[str, Any], *, strict: bool = False) -> T:
|
|
266
|
+
"""Create config instance from dictionary with automatic type conversion.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
data: Dictionary to convert
|
|
270
|
+
strict: If True, raise error on unknown fields
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Instance of the config class
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
TypeError: If data is not a dictionary
|
|
277
|
+
ValueError: If strict=True and unknown fields are present
|
|
278
|
+
"""
|
|
279
|
+
if not isinstance(data, dict):
|
|
280
|
+
raise TypeError(f"Expected dict, got {type(data)}")
|
|
281
|
+
|
|
282
|
+
# Get cached field information
|
|
283
|
+
field_info = cls._get_field_info()
|
|
284
|
+
constructor_inputs = {}
|
|
285
|
+
|
|
286
|
+
for field_name, value in data.items():
|
|
287
|
+
if field_name not in field_info:
|
|
288
|
+
if strict:
|
|
289
|
+
raise ValueError(f"Unknown field '{field_name}' for {cls.__name__}")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
field = field_info[field_name]
|
|
293
|
+
constructor_inputs[field_name] = cls._convert_value(field, value)
|
|
294
|
+
|
|
295
|
+
return cls(**constructor_inputs)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def _get_field_info(cls) -> Dict[str, Field]:
|
|
299
|
+
"""Get field information, using cache when available."""
|
|
300
|
+
if cls not in cls._field_cache:
|
|
301
|
+
cls._field_cache[cls] = {field.name: field for field in fields(cls)}
|
|
302
|
+
return cls._field_cache[cls]
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def _convert_value(cls, field: Field, value: Any) -> Any:
|
|
306
|
+
"""Convert a value to the appropriate type for a field."""
|
|
307
|
+
if value is None:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
field_type = field.type
|
|
311
|
+
|
|
312
|
+
# Handle Union types (e.g., Optional[int] = Union[int, None])
|
|
313
|
+
if get_origin(field_type) is Union:
|
|
314
|
+
union_args = get_args(field_type)
|
|
315
|
+
# Try each type in the union
|
|
316
|
+
for arg_type in union_args:
|
|
317
|
+
if arg_type is type(None):
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
return cls._convert_single_type(arg_type, value)
|
|
321
|
+
except (ValueError, TypeError):
|
|
322
|
+
continue
|
|
323
|
+
# If no conversion worked, return as-is
|
|
324
|
+
return value
|
|
325
|
+
|
|
326
|
+
return cls._convert_single_type(field_type, value)
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def _convert_single_type(cls, target_type: Type, value: Any) -> Any:
|
|
330
|
+
"""Convert value to a single target type."""
|
|
331
|
+
# Handle generic types first before isinstance check
|
|
332
|
+
origin_type = get_origin(target_type)
|
|
333
|
+
if origin_type is not None:
|
|
334
|
+
# Handle List[Typed] or similar list structures
|
|
335
|
+
if origin_type is list:
|
|
336
|
+
type_args = get_args(target_type)
|
|
337
|
+
if type_args and isinstance(value, (list, tuple)):
|
|
338
|
+
element_type = type_args[0]
|
|
339
|
+
# Convert each element if it's a Typed type
|
|
340
|
+
if cls._is_Typed_type(element_type):
|
|
341
|
+
return [cls._convert_single_type(element_type, item) for item in value]
|
|
342
|
+
# For non-Typed types, try basic conversion
|
|
343
|
+
else:
|
|
344
|
+
return [cls._convert_single_type(element_type, item) for item in value]
|
|
345
|
+
return value
|
|
346
|
+
|
|
347
|
+
# Handle Dict[str, Typed] or similar dict structures
|
|
348
|
+
elif origin_type is dict:
|
|
349
|
+
type_args = get_args(target_type)
|
|
350
|
+
if len(type_args) >= 2 and isinstance(value, dict):
|
|
351
|
+
key_type, value_type = type_args[0], type_args[1]
|
|
352
|
+
# Convert dict values
|
|
353
|
+
converted_dict = {}
|
|
354
|
+
for k, v in value.items():
|
|
355
|
+
converted_key = cls._convert_single_type(key_type, k)
|
|
356
|
+
converted_value = cls._convert_single_type(value_type, v)
|
|
357
|
+
converted_dict[converted_key] = converted_value
|
|
358
|
+
return converted_dict
|
|
359
|
+
return value
|
|
360
|
+
|
|
361
|
+
# For other generic types, return as-is
|
|
362
|
+
return value
|
|
363
|
+
|
|
364
|
+
# If already the right type, return as-is (only for non-generic types)
|
|
365
|
+
try:
|
|
366
|
+
if isinstance(value, target_type):
|
|
367
|
+
return value
|
|
368
|
+
except TypeError:
|
|
369
|
+
# Some types (like subscripted generics) can't be used with isinstance
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# Handle AutoEnum conversion (if available in morphic)
|
|
374
|
+
if hasattr(target_type, "__bases__"):
|
|
375
|
+
try:
|
|
376
|
+
# Try to import from morphic package
|
|
377
|
+
from .autoenum import AutoEnum
|
|
378
|
+
|
|
379
|
+
if any(
|
|
380
|
+
issubclass(base, AutoEnum) for base in target_type.__bases__ if isinstance(base, type)
|
|
381
|
+
):
|
|
382
|
+
if isinstance(value, str):
|
|
383
|
+
# Use from_str method for better conversion with fuzzy matching
|
|
384
|
+
return target_type.from_str(value)
|
|
385
|
+
return value
|
|
386
|
+
except ImportError:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
# Handle standard Python enum types
|
|
390
|
+
try:
|
|
391
|
+
import enum
|
|
392
|
+
if issubclass(target_type, enum.Enum):
|
|
393
|
+
if isinstance(value, str):
|
|
394
|
+
return target_type(value)
|
|
395
|
+
return value
|
|
396
|
+
except (TypeError, ImportError):
|
|
397
|
+
# Not an enum or enum not available, continue with other checks
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
# Handle other enum types by looking for common enum characteristics
|
|
401
|
+
if (
|
|
402
|
+
hasattr(target_type, "_value_")
|
|
403
|
+
or hasattr(target_type, "value")
|
|
404
|
+
or any(hasattr(base, "_value_") for base in target_type.__bases__ if isinstance(base, type))
|
|
405
|
+
):
|
|
406
|
+
if isinstance(value, str):
|
|
407
|
+
return target_type(value)
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
# Handle nested Typed objects
|
|
411
|
+
if hasattr(target_type, "__bases__") and any(
|
|
412
|
+
issubclass(base, Typed) for base in target_type.__bases__ if isinstance(base, type)
|
|
413
|
+
):
|
|
414
|
+
if isinstance(value, dict):
|
|
415
|
+
return target_type.from_dict(value)
|
|
416
|
+
return value
|
|
417
|
+
|
|
418
|
+
# Handle basic type conversions
|
|
419
|
+
if target_type in (int, float, str, bool):
|
|
420
|
+
try:
|
|
421
|
+
return target_type(value)
|
|
422
|
+
except (ValueError, TypeError):
|
|
423
|
+
# If conversion fails, return as-is and let dataclass validation handle it
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
# For complex types, return as-is and let dataclass handle it
|
|
427
|
+
return value
|
|
428
|
+
|
|
429
|
+
def to_dict(self, *, exclude_none: bool = False, exclude_defaults: bool = False) -> Dict[str, Any]:
|
|
430
|
+
"""Convert instance to dictionary.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
exclude_none: If True, exclude fields with None values
|
|
434
|
+
exclude_defaults: If True, exclude fields with default values
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Dictionary representation of the instance
|
|
438
|
+
"""
|
|
439
|
+
result = {}
|
|
440
|
+
field_info = self._get_field_info()
|
|
441
|
+
|
|
442
|
+
for field_name, field in field_info.items():
|
|
443
|
+
value = getattr(self, field_name)
|
|
444
|
+
|
|
445
|
+
if exclude_none and value is None:
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
if exclude_defaults and self._is_default_value(field, value):
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
# Convert nested Typed objects
|
|
452
|
+
if hasattr(value, "to_dict"):
|
|
453
|
+
result[field_name] = value.to_dict(
|
|
454
|
+
exclude_none=exclude_none, exclude_defaults=exclude_defaults
|
|
455
|
+
)
|
|
456
|
+
# Handle lists that might contain Typed objects
|
|
457
|
+
elif isinstance(value, list):
|
|
458
|
+
converted_list = []
|
|
459
|
+
for item in value:
|
|
460
|
+
if hasattr(item, "to_dict"):
|
|
461
|
+
converted_list.append(item.to_dict(exclude_none=exclude_none, exclude_defaults=exclude_defaults))
|
|
462
|
+
elif hasattr(item, "value"):
|
|
463
|
+
# Handle enums in lists
|
|
464
|
+
try:
|
|
465
|
+
from .autoenum import AutoEnum
|
|
466
|
+
if isinstance(item, AutoEnum):
|
|
467
|
+
converted_list.append(str(item))
|
|
468
|
+
else:
|
|
469
|
+
converted_list.append(item.value)
|
|
470
|
+
except ImportError:
|
|
471
|
+
converted_list.append(item.value if hasattr(item, "value") else str(item))
|
|
472
|
+
else:
|
|
473
|
+
converted_list.append(item)
|
|
474
|
+
result[field_name] = converted_list
|
|
475
|
+
# Handle dictionaries that might contain Typed objects
|
|
476
|
+
elif isinstance(value, dict):
|
|
477
|
+
converted_dict = {}
|
|
478
|
+
for k, v in value.items():
|
|
479
|
+
if hasattr(v, "to_dict"):
|
|
480
|
+
converted_dict[k] = v.to_dict(exclude_none=exclude_none, exclude_defaults=exclude_defaults)
|
|
481
|
+
elif hasattr(v, "value"):
|
|
482
|
+
# Handle enums in dict values
|
|
483
|
+
try:
|
|
484
|
+
from .autoenum import AutoEnum
|
|
485
|
+
if isinstance(v, AutoEnum):
|
|
486
|
+
converted_dict[k] = str(v)
|
|
487
|
+
else:
|
|
488
|
+
converted_dict[k] = v.value
|
|
489
|
+
except ImportError:
|
|
490
|
+
converted_dict[k] = v.value if hasattr(v, "value") else str(v)
|
|
491
|
+
else:
|
|
492
|
+
converted_dict[k] = v
|
|
493
|
+
result[field_name] = converted_dict
|
|
494
|
+
# Convert enums to their value (AutoEnum and other enums)
|
|
495
|
+
elif hasattr(value, "value"):
|
|
496
|
+
try:
|
|
497
|
+
# Try to import from morphic package
|
|
498
|
+
from .autoenum import AutoEnum
|
|
499
|
+
|
|
500
|
+
if isinstance(value, AutoEnum):
|
|
501
|
+
# AutoEnum stores the name as the value, just use str() representation
|
|
502
|
+
result[field_name] = str(value)
|
|
503
|
+
else:
|
|
504
|
+
result[field_name] = value.value
|
|
505
|
+
except ImportError:
|
|
506
|
+
result[field_name] = value.value if hasattr(value, "value") else str(value)
|
|
507
|
+
else:
|
|
508
|
+
result[field_name] = value
|
|
509
|
+
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
def _is_default_value(self, field: Field, value: Any) -> bool:
|
|
513
|
+
"""Check if a value is the default value for a field."""
|
|
514
|
+
if field.default is not MISSING:
|
|
515
|
+
return value == field.default
|
|
516
|
+
elif field.default_factory is not MISSING:
|
|
517
|
+
return value == field.default_factory()
|
|
518
|
+
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
def copy(self: T, **changes) -> T:
|
|
522
|
+
"""Create a copy of this instance with optional field changes.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
**changes: Field changes to apply to the copy
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
New instance with changes applied
|
|
529
|
+
"""
|
|
530
|
+
current_dict = self.to_dict()
|
|
531
|
+
current_dict.update(changes)
|
|
532
|
+
return self.__class__.from_dict(current_dict)
|
|
533
|
+
|
|
534
|
+
def validate(self) -> None:
|
|
535
|
+
"""Override in subclasses to add custom validation logic.
|
|
536
|
+
|
|
537
|
+
This method is called automatically after instance creation.
|
|
538
|
+
"""
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
def _convert_field_values(self) -> None:
|
|
542
|
+
"""Convert field values to appropriate types before validation.
|
|
543
|
+
|
|
544
|
+
This enables automatic conversion of dictionaries to nested Typed objects
|
|
545
|
+
and basic type conversion (like string to int) in the regular constructor.
|
|
546
|
+
This makes the constructor behavior consistent with from_dict().
|
|
547
|
+
"""
|
|
548
|
+
field_info = self._get_field_info()
|
|
549
|
+
|
|
550
|
+
for field_name, field in field_info.items():
|
|
551
|
+
current_value = getattr(self, field_name)
|
|
552
|
+
|
|
553
|
+
# Use full conversion logic (same as from_dict)
|
|
554
|
+
converted_value = self._convert_value(field, current_value)
|
|
555
|
+
|
|
556
|
+
# Update the field value if it was converted
|
|
557
|
+
if converted_value is not current_value:
|
|
558
|
+
setattr(self, field_name, converted_value)
|
|
559
|
+
|
|
560
|
+
@classmethod
|
|
561
|
+
def _convert_value_strict(cls, field: Field, value: Any) -> Any:
|
|
562
|
+
"""Convert a value with strict rules (only nested Typeds and enums).
|
|
563
|
+
|
|
564
|
+
This is used in the constructor to maintain strict type validation while
|
|
565
|
+
still allowing dict-to-Typed conversion for nested objects.
|
|
566
|
+
"""
|
|
567
|
+
if value is None:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
field_type = field.type
|
|
571
|
+
|
|
572
|
+
# Handle Union types (e.g., Optional[Typed])
|
|
573
|
+
if get_origin(field_type) is Union:
|
|
574
|
+
union_args = get_args(field_type)
|
|
575
|
+
# Try each type in the union
|
|
576
|
+
for arg_type in union_args:
|
|
577
|
+
if arg_type is type(None):
|
|
578
|
+
continue
|
|
579
|
+
try:
|
|
580
|
+
return cls._convert_single_type_strict(arg_type, value)
|
|
581
|
+
except (ValueError, TypeError):
|
|
582
|
+
continue
|
|
583
|
+
# If no conversion worked, return as-is
|
|
584
|
+
return value
|
|
585
|
+
|
|
586
|
+
return cls._convert_single_type_strict(field_type, value)
|
|
587
|
+
|
|
588
|
+
@classmethod
|
|
589
|
+
def _convert_single_type_strict(cls, target_type: Type, value: Any) -> Any:
|
|
590
|
+
"""Convert value to a single target type with strict rules.
|
|
591
|
+
|
|
592
|
+
Only converts nested Typed objects and enums, not basic types.
|
|
593
|
+
Also handles hierarchical structures like List[Typed] and Dict[str, Typed].
|
|
594
|
+
"""
|
|
595
|
+
# Handle generic types (e.g., List[Typed], Dict[str, Typed])
|
|
596
|
+
origin_type = get_origin(target_type)
|
|
597
|
+
if origin_type is not None:
|
|
598
|
+
# Handle List[Typed] or similar list structures
|
|
599
|
+
if origin_type is list:
|
|
600
|
+
type_args = get_args(target_type)
|
|
601
|
+
if type_args and isinstance(value, list):
|
|
602
|
+
element_type = type_args[0]
|
|
603
|
+
# Convert each element if it's a Typed type
|
|
604
|
+
if cls._is_Typed_type(element_type) and all(isinstance(item, dict) for item in value):
|
|
605
|
+
return [element_type(**item) for item in value]
|
|
606
|
+
# Also handle nested conversions for existing Typed instances
|
|
607
|
+
elif cls._is_Typed_type(element_type):
|
|
608
|
+
converted_items = []
|
|
609
|
+
for item in value:
|
|
610
|
+
if isinstance(item, dict):
|
|
611
|
+
converted_items.append(element_type(**item))
|
|
612
|
+
else:
|
|
613
|
+
converted_items.append(item)
|
|
614
|
+
return converted_items
|
|
615
|
+
return value
|
|
616
|
+
|
|
617
|
+
# Handle Dict[str, Typed] or similar dict structures
|
|
618
|
+
elif origin_type is dict:
|
|
619
|
+
type_args = get_args(target_type)
|
|
620
|
+
if len(type_args) >= 2 and isinstance(value, dict):
|
|
621
|
+
value_type = type_args[1] # Second type arg is the value type
|
|
622
|
+
# Convert dict values if they're Typed types
|
|
623
|
+
if cls._is_Typed_type(value_type):
|
|
624
|
+
converted_dict = {}
|
|
625
|
+
for k, v in value.items():
|
|
626
|
+
if isinstance(v, dict):
|
|
627
|
+
converted_dict[k] = value_type(**v)
|
|
628
|
+
else:
|
|
629
|
+
converted_dict[k] = v
|
|
630
|
+
return converted_dict
|
|
631
|
+
return value
|
|
632
|
+
|
|
633
|
+
# For other generic types, don't try to convert - return as-is
|
|
634
|
+
# Validation will handle checking the container type
|
|
635
|
+
return value
|
|
636
|
+
|
|
637
|
+
# Handle direct type match
|
|
638
|
+
try:
|
|
639
|
+
if isinstance(value, target_type):
|
|
640
|
+
return value
|
|
641
|
+
except TypeError:
|
|
642
|
+
# Some types (like complex generics) can't be used with isinstance
|
|
643
|
+
# Return as-is and let validation handle it
|
|
644
|
+
return value
|
|
645
|
+
|
|
646
|
+
# Handle AutoEnum conversion (if available in morphic)
|
|
647
|
+
if hasattr(target_type, "__bases__"):
|
|
648
|
+
try:
|
|
649
|
+
# Try to import from morphic package
|
|
650
|
+
from .autoenum import AutoEnum
|
|
651
|
+
|
|
652
|
+
if any(
|
|
653
|
+
issubclass(base, AutoEnum) for base in target_type.__bases__ if isinstance(base, type)
|
|
654
|
+
):
|
|
655
|
+
if isinstance(value, str):
|
|
656
|
+
# Try conversion, but don't raise errors - let validation handle it
|
|
657
|
+
try:
|
|
658
|
+
return target_type.from_str(value)
|
|
659
|
+
except ValueError:
|
|
660
|
+
# Invalid enum value - return as-is for validation to catch
|
|
661
|
+
return value
|
|
662
|
+
return value
|
|
663
|
+
except ImportError:
|
|
664
|
+
pass
|
|
665
|
+
|
|
666
|
+
# Handle other enum types by looking for common enum characteristics
|
|
667
|
+
if (
|
|
668
|
+
hasattr(target_type, "_value_")
|
|
669
|
+
or hasattr(target_type, "value")
|
|
670
|
+
or any(hasattr(base, "_value_") for base in target_type.__bases__ if isinstance(base, type))
|
|
671
|
+
):
|
|
672
|
+
if isinstance(value, str):
|
|
673
|
+
try:
|
|
674
|
+
return target_type(value)
|
|
675
|
+
except ValueError:
|
|
676
|
+
# Invalid enum value - return as-is for validation to catch
|
|
677
|
+
return value
|
|
678
|
+
return value
|
|
679
|
+
|
|
680
|
+
# Handle nested Typed objects
|
|
681
|
+
if hasattr(target_type, "__bases__") and any(
|
|
682
|
+
issubclass(base, Typed) for base in target_type.__bases__ if isinstance(base, type)
|
|
683
|
+
):
|
|
684
|
+
if isinstance(value, dict):
|
|
685
|
+
# Create nested object directly to maintain strict validation
|
|
686
|
+
# The nested object's own validation will catch type errors
|
|
687
|
+
return target_type(**value)
|
|
688
|
+
return value
|
|
689
|
+
|
|
690
|
+
# Do NOT convert basic types (int, float, str, bool) - maintain strict validation
|
|
691
|
+
# Return value as-is and let validation catch type mismatches
|
|
692
|
+
return value
|
|
693
|
+
|
|
694
|
+
@classmethod
|
|
695
|
+
def _is_Typed_type(cls, target_type: Type) -> bool:
|
|
696
|
+
"""Check if a type is a Typed subclass.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
target_type: The type to check
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
True if target_type is a subclass of Typed, False otherwise
|
|
703
|
+
|
|
704
|
+
Note:
|
|
705
|
+
This method safely handles types that may not be classes or may
|
|
706
|
+
not support isinstance/issubclass operations.
|
|
707
|
+
"""
|
|
708
|
+
if not hasattr(target_type, "__bases__"):
|
|
709
|
+
return False
|
|
710
|
+
try:
|
|
711
|
+
return any(
|
|
712
|
+
issubclass(base, Typed) for base in target_type.__bases__ if isinstance(base, type)
|
|
713
|
+
)
|
|
714
|
+
except TypeError:
|
|
715
|
+
return False
|
|
716
|
+
|
|
717
|
+
@classmethod
|
|
718
|
+
def _validate_and_convert_class_defaults(cls) -> None:
|
|
719
|
+
"""Validate and convert default values at class definition time.
|
|
720
|
+
|
|
721
|
+
This method is called during class creation (in __init_subclass__) to:
|
|
722
|
+
1. Convert default values to appropriate types (e.g., "25" -> 25 for int fields)
|
|
723
|
+
2. Handle hierarchical defaults (convert dicts to Typed objects)
|
|
724
|
+
3. Convert mutable defaults to default_factory to prevent shared mutable state
|
|
725
|
+
4. Validate that converted defaults comply with their type annotations
|
|
726
|
+
5. Provide clear error messages for invalid defaults
|
|
727
|
+
|
|
728
|
+
The validation happens before dataclass transformation to ensure that
|
|
729
|
+
dataclass receives properly typed default values.
|
|
730
|
+
|
|
731
|
+
Raises:
|
|
732
|
+
TypeError: If a default value cannot be converted or is invalid for its type
|
|
733
|
+
|
|
734
|
+
Examples:
|
|
735
|
+
```python
|
|
736
|
+
class Config(Typed):
|
|
737
|
+
port: int = "8080" # Converted to int(8080)
|
|
738
|
+
users: List[User] = [{"name": "admin"}] # Converted to default_factory
|
|
739
|
+
|
|
740
|
+
# Raises TypeError at class definition:
|
|
741
|
+
class BadConfig(Typed):
|
|
742
|
+
count: int = "invalid" # Cannot convert to int
|
|
743
|
+
```
|
|
744
|
+
"""
|
|
745
|
+
# Get type hints directly from the class
|
|
746
|
+
if not hasattr(cls, '__annotations__'):
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
annotations = cls.__annotations__
|
|
750
|
+
for field_name, field_type in annotations.items():
|
|
751
|
+
# Check if there's a class attribute with a default value
|
|
752
|
+
if hasattr(cls, field_name):
|
|
753
|
+
default_value = getattr(cls, field_name)
|
|
754
|
+
|
|
755
|
+
# Skip if this looks like a Field object or method
|
|
756
|
+
if hasattr(default_value, '__call__') or str(type(default_value)).startswith('<class \'dataclasses.'):
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
# Create a mock field object for conversion
|
|
761
|
+
mock_field = type('MockField', (), {'type': field_type})()
|
|
762
|
+
|
|
763
|
+
# Try to convert the default value
|
|
764
|
+
converted_default = cls._convert_value(mock_field, default_value)
|
|
765
|
+
|
|
766
|
+
# Handle mutable defaults - convert to default_factory
|
|
767
|
+
# Include Typed objects as they are also mutable
|
|
768
|
+
is_mutable = isinstance(converted_default, (list, dict, set)) or (
|
|
769
|
+
hasattr(converted_default, '__dict__') and
|
|
770
|
+
hasattr(converted_default.__class__, '__bases__') and
|
|
771
|
+
any(issubclass(base, Typed) for base in converted_default.__class__.__bases__ if isinstance(base, type))
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if is_mutable:
|
|
775
|
+
# Import field here to avoid circular imports
|
|
776
|
+
from dataclasses import field
|
|
777
|
+
|
|
778
|
+
# Create a factory function that returns a copy of the converted default
|
|
779
|
+
def make_factory(value):
|
|
780
|
+
def factory():
|
|
781
|
+
if isinstance(value, list):
|
|
782
|
+
return value.copy()
|
|
783
|
+
elif isinstance(value, dict):
|
|
784
|
+
return value.copy()
|
|
785
|
+
elif isinstance(value, set):
|
|
786
|
+
return value.copy()
|
|
787
|
+
elif hasattr(value, 'copy'):
|
|
788
|
+
# For Typed objects that might have a copy method
|
|
789
|
+
try:
|
|
790
|
+
return value.copy()
|
|
791
|
+
except (AttributeError, TypeError):
|
|
792
|
+
# If copy fails, create a new instance from dict
|
|
793
|
+
return value.__class__.from_dict(value.to_dict())
|
|
794
|
+
else:
|
|
795
|
+
# For other Typed objects, create new instance
|
|
796
|
+
if hasattr(value, 'to_dict') and hasattr(value.__class__, 'from_dict'):
|
|
797
|
+
return value.__class__.from_dict(value.to_dict())
|
|
798
|
+
return value
|
|
799
|
+
return factory
|
|
800
|
+
|
|
801
|
+
# Replace the class attribute with a field() using default_factory
|
|
802
|
+
setattr(cls, field_name, field(default_factory=make_factory(converted_default)))
|
|
803
|
+
else:
|
|
804
|
+
# Update the class attribute with the converted value for immutable types
|
|
805
|
+
if converted_default is not default_value:
|
|
806
|
+
setattr(cls, field_name, converted_default)
|
|
807
|
+
|
|
808
|
+
# Basic type validation - create temp instance for validation methods
|
|
809
|
+
temp_instance = object.__new__(cls)
|
|
810
|
+
temp_instance._Typed__dict = {} # Initialize to avoid AttributeError
|
|
811
|
+
|
|
812
|
+
# Special handling for None values with Optional types
|
|
813
|
+
if converted_default is None and temp_instance._type_allows_none(field_type):
|
|
814
|
+
# None is valid for Optional types, skip validation
|
|
815
|
+
pass
|
|
816
|
+
elif not temp_instance._is_value_valid_for_type(converted_default, field_type):
|
|
817
|
+
raise TypeError(
|
|
818
|
+
f"Default value for field '{field_name}' in class '{cls.__name__}' "
|
|
819
|
+
f"expected type {field_type}, got {type(converted_default).__name__} "
|
|
820
|
+
f"with value {converted_default!r}"
|
|
821
|
+
)
|
|
822
|
+
except Exception as e:
|
|
823
|
+
# Re-raise with more context
|
|
824
|
+
raise TypeError(
|
|
825
|
+
f"Invalid default value for field '{field_name}' in class '{cls.__name__}': {e}"
|
|
826
|
+
) from e
|
|
827
|
+
|
|
828
|
+
@classmethod
|
|
829
|
+
def _validate_default_factories(cls) -> None:
|
|
830
|
+
"""Validate default_factory callables after dataclass transformation.
|
|
831
|
+
|
|
832
|
+
This method ensures that all default_factory values are callable.
|
|
833
|
+
It's called after dataclass transformation because some default_factory
|
|
834
|
+
values may be created automatically during mutable default conversion.
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
TypeError: If a default_factory is not callable
|
|
838
|
+
|
|
839
|
+
Note:
|
|
840
|
+
This validation cannot check the return type of default_factory
|
|
841
|
+
functions since they are called at instance creation time, not
|
|
842
|
+
class definition time.
|
|
843
|
+
"""
|
|
844
|
+
if cls not in cls._field_cache:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
field_info = cls._field_cache[cls]
|
|
848
|
+
for field_name, field in field_info.items():
|
|
849
|
+
# Check default_factory values
|
|
850
|
+
if field.default_factory is not MISSING:
|
|
851
|
+
if not callable(field.default_factory):
|
|
852
|
+
raise TypeError(
|
|
853
|
+
f"default_factory for field '{field_name}' in class '{cls.__name__}' "
|
|
854
|
+
f"must be callable, got {type(field.default_factory).__name__}"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _validate_types(self) -> None:
|
|
858
|
+
"""Validate that all field values match their type annotations."""
|
|
859
|
+
field_info = self._get_field_info()
|
|
860
|
+
|
|
861
|
+
for field_name, field in field_info.items():
|
|
862
|
+
value = getattr(self, field_name)
|
|
863
|
+
field_type = field.type
|
|
864
|
+
|
|
865
|
+
# Skip validation for None values if the field type allows None
|
|
866
|
+
if value is None:
|
|
867
|
+
if self._type_allows_none(field_type):
|
|
868
|
+
continue
|
|
869
|
+
else:
|
|
870
|
+
raise TypeError(f"Field '{field_name}' cannot be None, expected {field_type}")
|
|
871
|
+
|
|
872
|
+
# Validate the value against the field type
|
|
873
|
+
if not self._is_value_valid_for_type(value, field_type):
|
|
874
|
+
raise TypeError(
|
|
875
|
+
f"Field '{field_name}' expected type {field_type}, got {type(value).__name__} with value {value!r}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def _type_allows_none(self, field_type: Type) -> bool:
|
|
879
|
+
"""Check if a type annotation allows None values."""
|
|
880
|
+
# Handle Union types (e.g., Optional[int] = Union[int, None])
|
|
881
|
+
if get_origin(field_type) is Union:
|
|
882
|
+
union_args = get_args(field_type)
|
|
883
|
+
return type(None) in union_args
|
|
884
|
+
|
|
885
|
+
return False
|
|
886
|
+
|
|
887
|
+
def _is_value_valid_for_type(self, value: Any, field_type: Type) -> bool:
|
|
888
|
+
"""Check if a value is valid for the given type annotation."""
|
|
889
|
+
# Handle Union types (e.g., Optional[int] = Union[int, None])
|
|
890
|
+
if get_origin(field_type) is Union:
|
|
891
|
+
union_args = get_args(field_type)
|
|
892
|
+
# Value is valid if it matches any type in the union (except None, handled separately)
|
|
893
|
+
for arg_type in union_args:
|
|
894
|
+
if arg_type is type(None):
|
|
895
|
+
continue
|
|
896
|
+
if self._is_value_valid_for_single_type(value, arg_type):
|
|
897
|
+
return True
|
|
898
|
+
return False
|
|
899
|
+
|
|
900
|
+
return self._is_value_valid_for_single_type(value, field_type)
|
|
901
|
+
|
|
902
|
+
def _is_value_valid_for_single_type(self, value: Any, target_type: Type) -> bool:
|
|
903
|
+
"""Check if a value is valid for a single target type."""
|
|
904
|
+
# Handle generic types (e.g., List[str], Dict[str, int])
|
|
905
|
+
origin_type = get_origin(target_type)
|
|
906
|
+
if origin_type is not None:
|
|
907
|
+
# For generic types, check if value is instance of the origin type
|
|
908
|
+
# We don't check the type parameters for simplicity - just the container type
|
|
909
|
+
try:
|
|
910
|
+
return isinstance(value, origin_type)
|
|
911
|
+
except TypeError:
|
|
912
|
+
# Some types might not work with isinstance, fallback to basic checks
|
|
913
|
+
return False
|
|
914
|
+
|
|
915
|
+
# Handle direct type match
|
|
916
|
+
try:
|
|
917
|
+
if isinstance(value, target_type):
|
|
918
|
+
return True
|
|
919
|
+
except TypeError:
|
|
920
|
+
# Some types (like complex generics) can't be used with isinstance
|
|
921
|
+
# In this case, we'll be permissive and allow the value
|
|
922
|
+
return True
|
|
923
|
+
|
|
924
|
+
# Handle AutoEnum types (if available in morphic)
|
|
925
|
+
if hasattr(target_type, "__bases__"):
|
|
926
|
+
try:
|
|
927
|
+
# Try to import from morphic package
|
|
928
|
+
from .autoenum import AutoEnum
|
|
929
|
+
|
|
930
|
+
if any(
|
|
931
|
+
issubclass(base, AutoEnum) for base in target_type.__bases__ if isinstance(base, type)
|
|
932
|
+
):
|
|
933
|
+
return isinstance(value, target_type)
|
|
934
|
+
except ImportError:
|
|
935
|
+
pass
|
|
936
|
+
|
|
937
|
+
# Handle other enum types
|
|
938
|
+
if (
|
|
939
|
+
hasattr(target_type, "_value_")
|
|
940
|
+
or hasattr(target_type, "value")
|
|
941
|
+
or any(hasattr(base, "_value_") for base in target_type.__bases__ if isinstance(base, type))
|
|
942
|
+
):
|
|
943
|
+
return isinstance(value, target_type)
|
|
944
|
+
|
|
945
|
+
# Handle nested Typed objects
|
|
946
|
+
if hasattr(target_type, "__bases__") and any(
|
|
947
|
+
issubclass(base, Typed) for base in target_type.__bases__ if isinstance(base, type)
|
|
948
|
+
):
|
|
949
|
+
return isinstance(value, target_type)
|
|
950
|
+
|
|
951
|
+
# For basic types, only allow exact type matches for strict validation
|
|
952
|
+
# This means str won't auto-convert to int, etc.
|
|
953
|
+
try:
|
|
954
|
+
return isinstance(value, target_type)
|
|
955
|
+
except TypeError:
|
|
956
|
+
# If isinstance fails, be permissive
|
|
957
|
+
return True
|
|
958
|
+
|
|
959
|
+
def __repr__(self) -> str:
|
|
960
|
+
"""Enhanced repr that shows all fields clearly."""
|
|
961
|
+
field_info = self._get_field_info()
|
|
962
|
+
field_strs = []
|
|
963
|
+
|
|
964
|
+
for field_name in field_info:
|
|
965
|
+
value = getattr(self, field_name)
|
|
966
|
+
field_strs.append(f"{field_name}={value!r}")
|
|
967
|
+
|
|
968
|
+
return f"{self.__class__.__name__}({', '.join(field_strs)})"
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
class ValidationError(ValueError):
|
|
972
|
+
"""Exception raised when function argument validation fails."""
|
|
973
|
+
pass
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def validate(
|
|
977
|
+
func: Callable = None,
|
|
978
|
+
/,
|
|
979
|
+
*,
|
|
980
|
+
validate_return: bool = False
|
|
981
|
+
) -> Callable:
|
|
982
|
+
"""Decorator that validates function arguments using type annotations.
|
|
983
|
+
|
|
984
|
+
This decorator provides Pydantic-like validation for function arguments,
|
|
985
|
+
using the same type conversion and validation system as Typed.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
func: The function to decorate (when used as @validate)
|
|
989
|
+
validate_return: Whether to validate the return value. Default: False
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
Decorated function with argument validation
|
|
993
|
+
|
|
994
|
+
Raises:
|
|
995
|
+
ValidationError: When function arguments don't match their type annotations
|
|
996
|
+
|
|
997
|
+
Examples:
|
|
998
|
+
```python
|
|
999
|
+
from morphic import Typed, validate
|
|
1000
|
+
|
|
1001
|
+
# Basic usage
|
|
1002
|
+
@validate
|
|
1003
|
+
def add_numbers(a: int, b: int) -> int:
|
|
1004
|
+
return a + b
|
|
1005
|
+
|
|
1006
|
+
result = add_numbers("5", "10") # Strings converted to ints: 15
|
|
1007
|
+
|
|
1008
|
+
# With return validation
|
|
1009
|
+
@validate(validate_return=True)
|
|
1010
|
+
def process_data(data: Any, count: int = 10) -> str:
|
|
1011
|
+
return f"Processed {count} items: {data}"
|
|
1012
|
+
|
|
1013
|
+
# With return validation
|
|
1014
|
+
@validate(validate_return=True)
|
|
1015
|
+
def get_user_name(user_id: int) -> str:
|
|
1016
|
+
return f"user_{user_id}" # Return value validated as str
|
|
1017
|
+
|
|
1018
|
+
# With Typed types
|
|
1019
|
+
class User(Typed):
|
|
1020
|
+
name: str
|
|
1021
|
+
age: int
|
|
1022
|
+
|
|
1023
|
+
@validate
|
|
1024
|
+
def create_user(user_data: User) -> User:
|
|
1025
|
+
return user_data
|
|
1026
|
+
|
|
1027
|
+
# Dict automatically converted to User object
|
|
1028
|
+
user = create_user({"name": "John", "age": 30})
|
|
1029
|
+
assert isinstance(user, User)
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Configuration:
|
|
1033
|
+
This decorator always uses the following configuration:
|
|
1034
|
+
- arbitrary_types_allowed: True - allows any type annotations
|
|
1035
|
+
- validate_default: True - validates default parameter values at decoration time
|
|
1036
|
+
|
|
1037
|
+
Features:
|
|
1038
|
+
- Automatic type conversion (e.g., "5" -> 5 for int parameters)
|
|
1039
|
+
- Typed object creation from dictionaries
|
|
1040
|
+
- AutoEnum string conversion with fuzzy matching
|
|
1041
|
+
- List and dict conversion for nested structures
|
|
1042
|
+
- Union type support (tries each type in order)
|
|
1043
|
+
- Optional parameter validation
|
|
1044
|
+
- Default value validation (if validate_default=True)
|
|
1045
|
+
- Return value validation (if validate_return=True)
|
|
1046
|
+
- Preserves original function signature and metadata
|
|
1047
|
+
- Works with both sync and async functions
|
|
1048
|
+
|
|
1049
|
+
Performance Notes:
|
|
1050
|
+
- Validation overhead occurs on every function call
|
|
1051
|
+
- Type conversion is cached for repeated calls with same types
|
|
1052
|
+
- Original function accessible via decorated_func.raw_function
|
|
1053
|
+
"""
|
|
1054
|
+
# Fixed configuration with pydantic-compatible settings
|
|
1055
|
+
config = {
|
|
1056
|
+
'arbitrary_types_allowed': True,
|
|
1057
|
+
'validate_default': True
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
def decorator(f: Callable) -> Callable:
|
|
1061
|
+
# Get function signature for parameter validation
|
|
1062
|
+
sig = inspect.signature(f)
|
|
1063
|
+
|
|
1064
|
+
# Validate default values (always enabled)
|
|
1065
|
+
_validate_function_defaults(f, sig)
|
|
1066
|
+
|
|
1067
|
+
@wraps(f)
|
|
1068
|
+
def wrapper(*args, **kwargs):
|
|
1069
|
+
# Bind arguments to parameters
|
|
1070
|
+
try:
|
|
1071
|
+
bound_args = sig.bind(*args, **kwargs)
|
|
1072
|
+
bound_args.apply_defaults()
|
|
1073
|
+
except TypeError as e:
|
|
1074
|
+
raise ValidationError(f"Invalid function arguments: {e}") from e
|
|
1075
|
+
|
|
1076
|
+
# Validate and convert each argument
|
|
1077
|
+
validated_args = {}
|
|
1078
|
+
for param_name, value in bound_args.arguments.items():
|
|
1079
|
+
param = sig.parameters[param_name]
|
|
1080
|
+
|
|
1081
|
+
# Skip validation for *args and **kwargs parameters
|
|
1082
|
+
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
|
1083
|
+
validated_args[param_name] = value
|
|
1084
|
+
continue
|
|
1085
|
+
|
|
1086
|
+
# Skip if no type annotation
|
|
1087
|
+
if param.annotation == inspect.Parameter.empty:
|
|
1088
|
+
validated_args[param_name] = value
|
|
1089
|
+
continue
|
|
1090
|
+
|
|
1091
|
+
# Create a mock field for the Typed conversion system
|
|
1092
|
+
mock_field = type('MockField', (), {'type': param.annotation})()
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
# Use Typed's type conversion system
|
|
1096
|
+
converted_value = Typed._convert_value(mock_field, value)
|
|
1097
|
+
|
|
1098
|
+
# Validate the converted value (always using arbitrary_types_allowed=True)
|
|
1099
|
+
if not _is_value_valid_for_annotation(converted_value, param.annotation):
|
|
1100
|
+
raise ValidationError(
|
|
1101
|
+
f"Argument '{param_name}' expected type {param.annotation}, "
|
|
1102
|
+
f"got {type(converted_value).__name__} with value {converted_value!r}"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
validated_args[param_name] = converted_value
|
|
1106
|
+
|
|
1107
|
+
except Exception as e:
|
|
1108
|
+
if isinstance(e, ValidationError):
|
|
1109
|
+
raise
|
|
1110
|
+
raise ValidationError(
|
|
1111
|
+
f"Failed to validate argument '{param_name}': {e}"
|
|
1112
|
+
) from e
|
|
1113
|
+
|
|
1114
|
+
# Call the original function
|
|
1115
|
+
result = f(**validated_args)
|
|
1116
|
+
|
|
1117
|
+
# Validate return value if requested
|
|
1118
|
+
if validate_return and sig.return_annotation != inspect.Parameter.empty:
|
|
1119
|
+
try:
|
|
1120
|
+
# For return validation, we're stricter - don't do automatic conversion
|
|
1121
|
+
# Just validate that the return value matches the expected type
|
|
1122
|
+
if not _is_value_valid_for_annotation(result, sig.return_annotation):
|
|
1123
|
+
raise ValidationError(
|
|
1124
|
+
f"Return value expected type {sig.return_annotation}, "
|
|
1125
|
+
f"got {type(result).__name__} with value {result!r}"
|
|
1126
|
+
)
|
|
1127
|
+
except Exception as e:
|
|
1128
|
+
if isinstance(e, ValidationError):
|
|
1129
|
+
raise
|
|
1130
|
+
raise ValidationError(f"Failed to validate return value: {e}") from e
|
|
1131
|
+
|
|
1132
|
+
return result
|
|
1133
|
+
|
|
1134
|
+
# Store original function for access
|
|
1135
|
+
wrapper.raw_function = f
|
|
1136
|
+
wrapper.__signature__ = sig
|
|
1137
|
+
|
|
1138
|
+
return wrapper
|
|
1139
|
+
|
|
1140
|
+
# Handle both @validate and @validate(...) usage
|
|
1141
|
+
if func is None:
|
|
1142
|
+
return decorator
|
|
1143
|
+
else:
|
|
1144
|
+
return decorator(func)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _validate_function_defaults(func: Callable, sig: inspect.Signature) -> None:
|
|
1148
|
+
"""Validate default parameter values against their type annotations."""
|
|
1149
|
+
for param_name, param in sig.parameters.items():
|
|
1150
|
+
# Skip if no default value or no annotation
|
|
1151
|
+
if param.default == inspect.Parameter.empty or param.annotation == inspect.Parameter.empty:
|
|
1152
|
+
continue
|
|
1153
|
+
|
|
1154
|
+
# Skip *args and **kwargs
|
|
1155
|
+
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
|
1156
|
+
continue
|
|
1157
|
+
|
|
1158
|
+
try:
|
|
1159
|
+
# Create mock field for validation
|
|
1160
|
+
mock_field = type('MockField', (), {'type': param.annotation})()
|
|
1161
|
+
|
|
1162
|
+
# Use stricter validation for default parameters
|
|
1163
|
+
converted_default = _convert_and_validate_default(mock_field, param.default, param.annotation)
|
|
1164
|
+
|
|
1165
|
+
# Additional validation check
|
|
1166
|
+
if not _is_value_valid_for_annotation(converted_default, param.annotation):
|
|
1167
|
+
raise ValidationError(
|
|
1168
|
+
f"Default value for parameter '{param_name}' in function '{func.__name__}' "
|
|
1169
|
+
f"expected type {param.annotation}, got {type(converted_default).__name__} "
|
|
1170
|
+
f"with value {converted_default!r}"
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
except Exception as e:
|
|
1174
|
+
if isinstance(e, ValidationError):
|
|
1175
|
+
raise
|
|
1176
|
+
raise ValidationError(
|
|
1177
|
+
f"Invalid default value for parameter '{param_name}' in function '{func.__name__}': {e}"
|
|
1178
|
+
) from e
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _convert_and_validate_default(mock_field: Any, value: Any, annotation: Type) -> Any:
|
|
1182
|
+
"""Convert and validate default parameter values with strict validation.
|
|
1183
|
+
|
|
1184
|
+
This function is stricter than Typed._convert_value and will raise
|
|
1185
|
+
ValidationError for any conversion that fails, ensuring default values
|
|
1186
|
+
are properly validated at decoration time.
|
|
1187
|
+
"""
|
|
1188
|
+
if value is None:
|
|
1189
|
+
# Handle None for Optional types
|
|
1190
|
+
if get_origin(annotation) is Union:
|
|
1191
|
+
union_args = get_args(annotation)
|
|
1192
|
+
if type(None) in union_args:
|
|
1193
|
+
return None
|
|
1194
|
+
else:
|
|
1195
|
+
raise ValidationError(f"None not allowed for non-Optional type {annotation}")
|
|
1196
|
+
else:
|
|
1197
|
+
raise ValidationError(f"None not allowed for type {annotation}")
|
|
1198
|
+
|
|
1199
|
+
# Handle Union types
|
|
1200
|
+
if get_origin(annotation) is Union:
|
|
1201
|
+
union_args = get_args(annotation)
|
|
1202
|
+
last_error = None
|
|
1203
|
+
# Try each type in the union
|
|
1204
|
+
for arg_type in union_args:
|
|
1205
|
+
if arg_type is type(None):
|
|
1206
|
+
continue
|
|
1207
|
+
try:
|
|
1208
|
+
return _convert_and_validate_default_single_type(value, arg_type)
|
|
1209
|
+
except (ValueError, TypeError, ValidationError) as e:
|
|
1210
|
+
last_error = e
|
|
1211
|
+
continue
|
|
1212
|
+
# If no conversion worked, raise the last error
|
|
1213
|
+
raise ValidationError(f"Could not convert {value!r} to any type in {annotation}") from last_error
|
|
1214
|
+
|
|
1215
|
+
return _convert_and_validate_default_single_type(value, annotation)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _convert_and_validate_default_single_type(value: Any, target_type: Type) -> Any:
|
|
1219
|
+
"""Convert value to a single target type with strict validation for defaults."""
|
|
1220
|
+
# Handle generic types first
|
|
1221
|
+
origin_type = get_origin(target_type)
|
|
1222
|
+
if origin_type is not None:
|
|
1223
|
+
# Handle List[Type]
|
|
1224
|
+
if origin_type is list:
|
|
1225
|
+
if not isinstance(value, (list, tuple)):
|
|
1226
|
+
raise ValidationError(f"Expected list for {target_type}, got {type(value).__name__}")
|
|
1227
|
+
|
|
1228
|
+
type_args = get_args(target_type)
|
|
1229
|
+
if type_args:
|
|
1230
|
+
element_type = type_args[0]
|
|
1231
|
+
converted_items = []
|
|
1232
|
+
for i, item in enumerate(value):
|
|
1233
|
+
try:
|
|
1234
|
+
converted_item = _convert_and_validate_default_single_type(item, element_type)
|
|
1235
|
+
converted_items.append(converted_item)
|
|
1236
|
+
except Exception as e:
|
|
1237
|
+
raise ValidationError(f"Invalid list element at index {i}: {e}") from e
|
|
1238
|
+
return converted_items
|
|
1239
|
+
return list(value)
|
|
1240
|
+
|
|
1241
|
+
# Handle Dict[KeyType, ValueType]
|
|
1242
|
+
elif origin_type is dict:
|
|
1243
|
+
if not isinstance(value, dict):
|
|
1244
|
+
raise ValidationError(f"Expected dict for {target_type}, got {type(value).__name__}")
|
|
1245
|
+
|
|
1246
|
+
type_args = get_args(target_type)
|
|
1247
|
+
if len(type_args) >= 2:
|
|
1248
|
+
key_type, value_type = type_args[0], type_args[1]
|
|
1249
|
+
converted_dict = {}
|
|
1250
|
+
for k, v in value.items():
|
|
1251
|
+
try:
|
|
1252
|
+
converted_key = _convert_and_validate_default_single_type(k, key_type)
|
|
1253
|
+
converted_value = _convert_and_validate_default_single_type(v, value_type)
|
|
1254
|
+
converted_dict[converted_key] = converted_value
|
|
1255
|
+
except Exception as e:
|
|
1256
|
+
raise ValidationError(f"Invalid dict entry {k!r}: {e}") from e
|
|
1257
|
+
return converted_dict
|
|
1258
|
+
return dict(value)
|
|
1259
|
+
|
|
1260
|
+
# For other generic types, return as-is
|
|
1261
|
+
return value
|
|
1262
|
+
|
|
1263
|
+
# If already the right type, return as-is
|
|
1264
|
+
try:
|
|
1265
|
+
if isinstance(value, target_type):
|
|
1266
|
+
return value
|
|
1267
|
+
except TypeError:
|
|
1268
|
+
# Some types can't be used with isinstance
|
|
1269
|
+
pass
|
|
1270
|
+
|
|
1271
|
+
# Handle Typed types
|
|
1272
|
+
if hasattr(target_type, "__bases__") and any(
|
|
1273
|
+
issubclass(base, Typed) for base in target_type.__bases__ if isinstance(base, type)
|
|
1274
|
+
):
|
|
1275
|
+
if isinstance(value, dict):
|
|
1276
|
+
try:
|
|
1277
|
+
return target_type.from_dict(value)
|
|
1278
|
+
except Exception as e:
|
|
1279
|
+
raise ValidationError(f"Could not create {target_type.__name__} from dict: {e}") from e
|
|
1280
|
+
return value
|
|
1281
|
+
|
|
1282
|
+
# Handle basic type conversions with strict validation
|
|
1283
|
+
if target_type in (int, float, str, bool):
|
|
1284
|
+
try:
|
|
1285
|
+
if target_type is bool and isinstance(value, str):
|
|
1286
|
+
# Handle string to bool conversion more strictly
|
|
1287
|
+
lower_val = value.lower()
|
|
1288
|
+
if lower_val in ('true', '1', 'yes', 'on'):
|
|
1289
|
+
return True
|
|
1290
|
+
elif lower_val in ('false', '0', 'no', 'off', ''):
|
|
1291
|
+
return False
|
|
1292
|
+
else:
|
|
1293
|
+
raise ValueError(f"Cannot convert '{value}' to bool")
|
|
1294
|
+
else:
|
|
1295
|
+
converted = target_type(value)
|
|
1296
|
+
# Additional validation for string to number conversion
|
|
1297
|
+
if target_type in (int, float) and isinstance(value, str):
|
|
1298
|
+
# Make sure the conversion actually makes sense
|
|
1299
|
+
if str(converted) != str(value).strip():
|
|
1300
|
+
# Allow for float precision differences
|
|
1301
|
+
if target_type is float:
|
|
1302
|
+
try:
|
|
1303
|
+
if abs(float(value) - converted) > 1e-10:
|
|
1304
|
+
raise ValueError(f"Conversion changed value: '{value}' -> {converted}")
|
|
1305
|
+
except (ValueError, TypeError):
|
|
1306
|
+
raise ValueError(f"Cannot convert '{value}' to {target_type.__name__}")
|
|
1307
|
+
return converted
|
|
1308
|
+
except (ValueError, TypeError) as e:
|
|
1309
|
+
raise ValidationError(f"Cannot convert {value!r} to {target_type.__name__}: {e}") from e
|
|
1310
|
+
|
|
1311
|
+
# For complex types we can't handle, return as-is and let validation catch issues
|
|
1312
|
+
return value
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _is_value_valid_for_annotation(value: Any, annotation: Type) -> bool:
|
|
1316
|
+
"""Check if a value is valid for a type annotation (always with arbitrary_types_allowed=True)."""
|
|
1317
|
+
# Handle None for Optional types
|
|
1318
|
+
if value is None:
|
|
1319
|
+
if get_origin(annotation) is Union:
|
|
1320
|
+
union_args = get_args(annotation)
|
|
1321
|
+
return type(None) in union_args
|
|
1322
|
+
return False
|
|
1323
|
+
|
|
1324
|
+
# Use Typed's validation logic (with arbitrary types allowed)
|
|
1325
|
+
temp_instance = object.__new__(Typed)
|
|
1326
|
+
temp_instance._Typed__dict = {} # Initialize to avoid AttributeError
|
|
1327
|
+
return temp_instance._is_value_valid_for_type(value, annotation)
|