pydoptic 0.0.post1.dev38__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.
pydoptic/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pydoptic.base_model import BaseModel, PartialModel, select
2
+ from pydoptic.selector import Select, SelectVal, SelectOpt, SelectArr, SelectOptArr, SelectValue, Prop, PropOpt, PropArr, PropOptArr
3
+ from pydoptic.validate_types import Validator
pydoptic/base_model.py ADDED
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+ from pydoptic.selector import Discrim, Param, Prop, Select, PropSelect, SelectVal, SelectOpt, SelectArr, SelectOptArr, ModelLike, SelectValue, A, B, Selectable
3
+
4
+ from dataclasses import dataclass, field
5
+ from inspect import isclass
6
+ from typing import Any, Callable, Dict, Generic, List, Self, Set, Type, TypeVar, TypedDict, get_args, get_origin, \
7
+ get_type_hints, Mapping, Tuple
8
+
9
+ from pydoptic.validate_types import validate_type, Validator
10
+
11
+ X = TypeVar("X")
12
+ Y = TypeVar("Y")
13
+
14
+ @dataclass(frozen=True)
15
+ class SelectProxy(Generic[B]):
16
+ name: str | None
17
+ data: Dict[str, Any]
18
+ _field_name: str | None = field(default=None, compare=False, repr=False)
19
+
20
+ def __set_name__(self, owner: type, name: str) -> None:
21
+ object.__setattr__(self, '_field_name', name)
22
+
23
+ def __get__(self, instance: Any, owner: type) -> Any:
24
+ """
25
+ A customized field (`x: Prop[...] = select(...)`) already has a real class-attribute value (this proxy)
26
+ from class-body execution, so plain attribute lookup on it would never "fail" and never reach
27
+ `BaseModelMeta.__getattr__`'s fallback trigger. Implementing the descriptor protocol here lets an
28
+ unresolved customized field trigger resolution on first access the same way -- since it's a *non-data*
29
+ descriptor (no `__set__`), it never interferes with normal instance attribute access, and once resolved
30
+ it's replaced in-place by the real `Prop`/`Discrim`, so this only ever runs once per class.
31
+ """
32
+ _resolve_properties(owner)
33
+ assert self._field_name is not None, 'SelectProxy.__get__ called before __set_name__ resolved the field name'
34
+ return getattr(owner, self._field_name)
35
+
36
+ def select(name: str | None = None, **data) -> Any:
37
+ return SelectProxy(name=name, data=data)
38
+
39
+ _MISSING = object()
40
+
41
+ def _find_given_value(cls: Type[Any], name: str) -> Any:
42
+ """
43
+ Look up a class-body-assigned value for `name` by walking the MRO directly via each class's own `__dict__`,
44
+ bypassing the attribute-access protocol (`getattr`/`hasattr`) entirely. This matters specifically because
45
+ `SelectProxy.__get__` triggers resolution on access: calling `getattr` here, from inside the resolution of
46
+ `cls` itself, would recurse right back into resolving the very field being looked up. Returns `_MISSING` if
47
+ `name` isn't set anywhere in the MRO (as opposed to being explicitly set to `None`).
48
+ """
49
+ for base in cls.__mro__:
50
+ if name in base.__dict__:
51
+ return base.__dict__[name]
52
+ return _MISSING
53
+
54
+ def _selector_from_select_proxy(name: str, select_type: Type[Select[Any, Any]], origin: Type[Any], target: Type[Any], proxy: SelectProxy) -> PropSelect[Any, Any]:
55
+ if issubclass(select_type, SelectVal):
56
+ return PropSelect.val(proxy.name or name, origin, target, proxy.data) # type: ignore
57
+ if issubclass(select_type, SelectOpt):
58
+ return PropSelect.opt(proxy.name or name, origin, target, proxy.data) # type: ignore
59
+ if issubclass(select_type, SelectArr):
60
+ return PropSelect.arr(proxy.name or name, origin, target, proxy.data) # type: ignore
61
+ if issubclass(select_type, SelectOptArr):
62
+ return PropSelect.opt_arr(proxy.name or name, origin, target, proxy.data) # type: ignore
63
+
64
+ raise ValueError(f'Unsupported subtype of Select: {select_type.__name__}. Use Prop, PropOpt, PropArr, or PropOptArr')
65
+
66
+
67
+ def _resolve_properties(cls: Type[Any]) -> Dict[str, PropSelect[Any, Any] | Discrim[Any, Any]]:
68
+ """
69
+ Build (and cache, in `BaseModel._properties`) the `Prop`/`Discrim` selectors for `cls`, based on its type hints.
70
+
71
+ Deferred until first use (rather than done in `BaseModelMeta.__new__`) because `get_type_hints` requires that
72
+ every type referenced in an annotation -- including forward references, e.g. a model that refers to another
73
+ model defined later in the same module, or two models that refer to each other -- already be resolvable. That's
74
+ only guaranteed once the whole defining module has finished executing, which first use is (definition never is).
75
+
76
+ Idempotent: safe to call repeatedly (e.g. from both `BaseModel.properties()` and `BaseModelMeta.__getattr__`);
77
+ a class is only ever resolved once.
78
+ """
79
+ if cls in BaseModel._properties:
80
+ return BaseModel._properties[cls]
81
+
82
+ properties: Dict[str, PropSelect[Any, Any] | Discrim[Any, Any]] = {}
83
+ BaseModel._properties[cls] = properties
84
+
85
+ cls_name = cls.__name__
86
+ type_hints = get_type_hints(cls, include_extras=True)
87
+ for name, _type in type_hints.items():
88
+ origin = get_origin(_type)
89
+ type_params = get_args(_type)
90
+ if isclass(origin) and issubclass(origin, PropSelect):
91
+ assert len(type_params) > 1, f'Selector {name} on model {cls_name} is missing one or more of the three required type paramaters: {_type}'
92
+ assert len(type_params) == 2, f'Selector {name} on model {cls_name} has more than three type paramaters: {_type}'
93
+ assert issubclass(cls, type_params[0]), f'Selector {name} on model {cls_name} selects from {type_params[0]} instead of {cls_name}: {_type}'
94
+ target = type_params[1]
95
+ given_selector: Any = _find_given_value(cls, name)
96
+ if given_selector is not _MISSING:
97
+ if given_selector is not None:
98
+ if isinstance(given_selector, SelectProxy):
99
+ if given_selector.name is not None and given_selector.name != name:
100
+ if given_selector.name in type_hints:
101
+ attr_typ = type_hints[given_selector.name]
102
+ if not isclass(attr_typ) and issubclass(attr_typ, target):
103
+ raise ValueError(f'Attribute {given_selector.name} with type {attr_typ.__name__} does not correspond to property {name} with type {target}')
104
+ property = _selector_from_select_proxy(name, origin, cls, target, given_selector)
105
+ elif isinstance(given_selector, PropSelect):
106
+ property = given_selector
107
+ else:
108
+ raise ValueError(f'Invalid selector assigned to {name}: {given_selector}')
109
+ else:
110
+ raise ValueError(f'To configure a selector on {cls_name} use select(), select_opt(), select_arr(), or select_arr()')
111
+ else:
112
+ property = _selector_from_select_proxy(name, origin, cls, target, SelectProxy(None, {}))
113
+ setattr(cls, name, property)
114
+ properties[property.label] = property
115
+ elif isclass(origin) and issubclass(origin, Discrim):
116
+ assert len(type_params) > 1, f'Discriminator {name} on model {cls_name} is missing one or more of the three required type paramaters: {_type}'
117
+ assert len(type_params) == 2, f'Discriminator {name} on model {cls_name} has more than three type paramaters: {_type}'
118
+ assert issubclass(cls, type_params[0]), f'Selector {name} on model {cls_name} selects from {type_params[0]} instead of {cls_name}: {_type}'
119
+ super_class = type_params[0]
120
+ target = type_params[1]
121
+ given_selector = _find_given_value(cls, name)
122
+ if given_selector is not _MISSING:
123
+ if given_selector is not None:
124
+ if isinstance(given_selector, SelectProxy):
125
+ prop = _selector_from_select_proxy(name, Prop, cls, str, given_selector)
126
+ discrim = Discrim(super_class, target, prop, target.__name__)
127
+ elif isinstance(given_selector, Discrim):
128
+ discrim = given_selector
129
+ else:
130
+ raise ValueError(f'Invalid selector assigned to {name}: {given_selector}')
131
+ else:
132
+ raise ValueError(f'To configure a selector on {cls_name} use select(), select_opt(), select_arr(), or select_arr()')
133
+ else:
134
+ prop = _selector_from_select_proxy(name, Prop, cls, str, SelectProxy(None, {}))
135
+ discrim = Discrim(super_class, target, prop, target.__name__)
136
+ setattr(cls, name, discrim)
137
+ properties[discrim.property.label] = discrim
138
+
139
+ return properties
140
+
141
+
142
+ class BaseModelMeta(type):
143
+ def __new__(mcls, class_name, bases, dct: Dict[str, Any]):
144
+ annos: Dict[str, Type[Any]] = dct.get('__annotations__', {})
145
+ slots: List[str] = []
146
+ dct['__slots__'] = slots
147
+ for name, tpe in annos.items():
148
+ if isclass(tpe) and issubclass(tpe, PropSelect):
149
+ if name in dct:
150
+ given_prop = dct[name]
151
+ if isinstance(given_prop, SelectProxy):
152
+ slots.append(given_prop.name or name)
153
+ elif isinstance(given_prop, PropSelect):
154
+ slots.append(given_prop.label)
155
+ else:
156
+ slots.append(name)
157
+ # Call the parent metaclass's __new__ to create the class
158
+ return super().__new__(mcls, class_name, bases, dct)
159
+
160
+ def __getattr__(cls, name_to_get: str):
161
+ """
162
+ Fallback for a `Prop`/`Discrim`-annotated field accessed directly on the class (e.g. `Model.field`) before
163
+ `Model.properties()` has ever been called (which is the common trigger, via `__init__`). Only runs on a
164
+ failed lookup -- i.e. essentially only the first time, ever, for a given class -- since a resolved field
165
+ becomes a real class attribute findable by plain (fast, unintercepted) attribute lookup from then on.
166
+ """
167
+ if cls is BaseModel:
168
+ raise AttributeError(name_to_get)
169
+ _resolve_properties(cls)
170
+ return type.__getattribute__(cls, name_to_get)
171
+
172
+ def _fully_validate(target: Type[M], value: Any, validators: Dict[Type[Any], Validator]) -> M:
173
+ if isinstance(value, target):
174
+ return value
175
+ elif isinstance(value, PartialModel):
176
+ if not issubclass(value.model, target):
177
+ raise ValueError(f'received partial model of {value.model} instead of expected model {target}')
178
+ return target(**value.as_dict(), _allow_extra_args=True, _validators=validators)
179
+ elif isinstance(value, dict):
180
+ return target(**value, _validators=validators)
181
+ else:
182
+ raise ValueError(f'expected type {target.__name__} (or dict) but received: {type(value).__name__}.')
183
+
184
+ class BaseModel(ModelLike, metaclass=BaseModelMeta):
185
+ """
186
+ Base type for a Pydoptic model.
187
+
188
+ A Pydoptic model consists of `PropSelect` class attributes (`Prop`, `PropOpt`, `PropArr`, and `PropOptArr`) which
189
+ (1) determine which instance attributes are supported, and (2) provide a mechanism for accessing and manipulating data
190
+ both within instances and other, potentially incomplete data sources (e.g., `PartialModel` or `dict`)
191
+
192
+ Instances are fully validated.
193
+
194
+ Can include class attribute `validators` to specify how certain types should be validated.
195
+ """
196
+
197
+ # Registry of resolved selectors, keyed by model class. Shared across all `BaseModel` subclasses; populated
198
+ # lazily (see `_resolve_properties`) since resolving a class's selectors requires its forward references to
199
+ # already be resolvable, which is only guaranteed once its defining module has finished executing.
200
+ _properties: Dict[Type[Any], Dict[str, PropSelect[Any, Any] | Discrim[Any, Any]]] = {}
201
+
202
+ # Table for looking up validation functions by type.
203
+ validators: Dict[Type[Any], Validator]
204
+
205
+ @classmethod
206
+ def properties(cls) -> Dict[str, PropSelect[Any, Any] | Discrim[Any, Any]]:
207
+ """
208
+ All the selectors for this subclass
209
+ """
210
+ if cls not in BaseModel._properties:
211
+ _resolve_properties(cls)
212
+ return BaseModel._properties[cls]
213
+
214
+ @classmethod
215
+ def partial(cls, **kwargs) -> PartialModel[Self]:
216
+ """
217
+ Create a partial instance of this model with a subset of properties. Required properties can be
218
+ omitted, but must be otherwise valid. See `PartialModel`.
219
+ """
220
+ return PartialModel(cls, **kwargs)
221
+
222
+ @classmethod
223
+ def construct_partial(cls, *values: Param[Self, Any], **kwargs) -> PartialModel[Self]:
224
+ """
225
+ Create a partial instance of this model using selectors to specify fields. Required properties can be
226
+ omitted, but must be otherwise valid. See `PartialModel`.
227
+ """
228
+ return PartialModel(cls, **{p.label: p.value for p in values}, **kwargs)
229
+
230
+ @classmethod
231
+ def construct(cls, *values: Param[Self, Any], **kwargs) -> Self:
232
+ """
233
+ Create an instance of this model using selectors to specify fields instead of keyword arguments.
234
+ Required properties can be omitted, but must be otherwise valid. See `PartialModel`.
235
+ """
236
+ return cls(**{p.label: p.value for p in values}, **kwargs)
237
+
238
+ def as_partial(self) -> PartialModel[Self]:
239
+ """
240
+ Convert to a `PartialModel`
241
+ """
242
+ return PartialModel(self.__class__, **self.as_dict())
243
+
244
+ def __init__(self, **kwargs):
245
+ """
246
+ Construct a model instance, providing all required properties as keyword arguments. Two specialized arguments
247
+ can also be included:
248
+ _allow_extra_args: don't fail when unrecognized properties are provided (they will be excluded, however).
249
+ _validators: a dict instance to lookup validators by type. Will merge with (and overwrite) the validators class attribute
250
+ """
251
+ allow_extra_args = kwargs.get('_allow_extra_args', False)
252
+ if '_allow_extra_args' in kwargs:
253
+ del kwargs['_allow_extra_args']
254
+ validators = getattr(self.__class__, 'validators', {})
255
+ if '_validators' in kwargs:
256
+ validators.update(kwargs['_validators'])
257
+ del kwargs['_validators']
258
+ for selector in self.__class__.properties().values():
259
+ if isinstance(selector, Discrim):
260
+ if selector.property.label in kwargs and kwargs[selector.property.label] != selector.value:
261
+ raise ValueError(f'Discriminator {selector.property.label} set to illegal value: {kwargs[selector.property.label]}. Must be {selector.value} (will be set automatically if omitted).')
262
+ setattr(self, selector.property.label, selector.value)
263
+ else:
264
+ if selector.label not in kwargs and not selector.is_opt:
265
+ raise ValueError(f'Missing required parameter {selector.label}')
266
+ if selector.label not in kwargs:
267
+ setattr(self, selector.label, None)
268
+ else:
269
+ value = kwargs[selector.label]
270
+ del kwargs[selector.label]
271
+ if value is None and not selector.is_opt:
272
+ raise ValueError(f'Received empty value for required parameter {selector.label}')
273
+ if value is None:
274
+ setattr(self, selector.label, None)
275
+ else:
276
+ if isclass(selector.target) and issubclass(selector.target, BaseModel):
277
+ if selector.is_arr:
278
+ if not isinstance(value, list):
279
+ raise ValueError(f'Received non-array value for array field {selector.label}')
280
+ updated_values = []
281
+ for i, v in enumerate(value):
282
+ try:
283
+ updated_values.append(_fully_validate(selector.target, v, validators))
284
+ except ValueError as ve:
285
+ raise ValueError(f'Property {selector.label} contains invalid element at index {i}: {ve}')
286
+ setattr(self, selector.label, updated_values)
287
+ else:
288
+ try:
289
+ valid_value = _fully_validate(selector.target, value, validators)
290
+ setattr(self, selector.label, valid_value)
291
+ except ValueError as ve:
292
+ raise ValueError(f'Invalid property {selector.label}: {ve}')
293
+ else:
294
+ error_message = validate_type(selector.is_opt, selector.is_arr, selector.target, value, validators)
295
+ if error_message is not None:
296
+ message = f'Invalid property {selector.label}: {error_message}'
297
+ raise ValueError(message)
298
+ setattr(self, selector.label, value)
299
+
300
+ if not allow_extra_args:
301
+ for k, v in kwargs.items():
302
+ if k not in self.__class__.properties():
303
+ raise ValueError(f'Unrecognized parameter {k} provided (value: {v})')
304
+
305
+ def __repr__(self):
306
+ return self.__class__.__name__ + '(' + ', '.join(f'{k}={v}' for k, v in self.as_dict().items()) + ')'
307
+
308
+ def __setattr__(self, name: str, value):
309
+ if name in self.properties():
310
+ prop = self.properties()[name]
311
+ if isinstance(prop, Discrim) and value != prop.value:
312
+ raise AttributeError(f'Setting discriminator value is forbidden')
313
+
314
+ super().__setattr__(name, value)
315
+
316
+ def as_dict(self) -> Mapping[str, Any]:
317
+ mapping: Dict[str, Any] = {}
318
+ for name in self.__class__.properties().keys():
319
+ try:
320
+ value = getattr(self, name)
321
+ mapping[name] = value
322
+ except AttributeError:
323
+ ...
324
+ return mapping
325
+
326
+ def as_dict_full(self) -> Mapping[str, Any]:
327
+ mapping: Dict[str, Any] = {}
328
+ for name in self.__class__.properties().keys():
329
+ try:
330
+ value = getattr(self, name)
331
+ if isinstance(value, ModelLike):
332
+ mapping[name] = value.as_dict_full()
333
+ else:
334
+ mapping[name] = value
335
+ except AttributeError:
336
+ ...
337
+ return mapping
338
+
339
+ def select_partial(self, *selectors: Select[Self, Any]) -> PartialModel[Self]:
340
+ """
341
+ Generate a partial version of this instance, selecting the data to retain with one or more `Select` instances.
342
+ """
343
+ data: Dict[str, Any] = {}
344
+ for selector in selectors:
345
+ selector.copy_to(self, data)
346
+ return self.__class__.partial(**data)
347
+
348
+ def _partly_validate(target: Type[M], value: Any, validators: Dict[Type[Any], Validator]) -> M | PartialModel[M]:
349
+ if isinstance(value, target):
350
+ return value
351
+ elif isinstance(value, PartialModel):
352
+ if not issubclass(value.model, target):
353
+ raise ValueError(f'received partial model of {value.model} instead of expected model {target}')
354
+ return value
355
+ elif isinstance(value, dict):
356
+ return target.partial(**value, _validators=validators)
357
+ else:
358
+ raise ValueError(f'expected type {target.__name__} (or dict) but received: {type(value).__name__}.')
359
+
360
+ M = TypeVar('M', bound=BaseModel)
361
+
362
+ class PartialModel(Generic[M], Selectable[M]):
363
+ """
364
+ An incomplete version of model instances. Validates data without requiring that all required properties are present.
365
+ """
366
+ __slots__ = ['_dict', 'model']
367
+ model: Type[M]
368
+ _dict: Dict[str, Any]
369
+
370
+ def as_model(self, **extra_args) -> M:
371
+ """
372
+ Convert to a complete model, providing any missing properties as keyword arguments.
373
+ """
374
+ return self.model(**self._dict, **extra_args)
375
+
376
+ def construct_as_model(self, values: Dict[PropSelect[Self, Any], Any], **extra_args) -> M:
377
+ """
378
+ Convert to a complete model, providing any missing properties using `PropSelect` instances as lookups.
379
+ """
380
+ return self.model(**{sel.label: val for sel, val in values.items()}, **self._dict, **extra_args)
381
+
382
+ def __init__(self, model: Type[M], **kwargs):
383
+ """
384
+ Construct a partial model from a model class and any desired properties. Validates provided properties without enforcing
385
+ required property types. Allows unrecognized properties (but does not include them).
386
+ """
387
+ object.__setattr__(self, '_dict', {})
388
+ object.__setattr__(self, 'model', model)
389
+ allow_extra_args = kwargs.get('_allow_extra_args', False)
390
+ if '_allow_extra_args' in kwargs:
391
+ del kwargs['_allow_extra_args']
392
+ validators = getattr(model, 'validators', {})
393
+ if '_validators' in kwargs:
394
+ validators.update(kwargs['_validators'])
395
+ del kwargs['_validators']
396
+ for selector in model.properties().values():
397
+ if isinstance(selector, Discrim):
398
+ if selector.property.label in kwargs and kwargs[selector.property.label] != selector.value:
399
+ raise ValueError(f'Discriminator {selector.property.label} set to illegal value: {kwargs[selector.property.label]}. Must be {selector.value} (will be set automatically if omitted).')
400
+ setattr(self, selector.property.label, selector.value)
401
+ else:
402
+ if selector.label in kwargs:
403
+ value = kwargs[selector.label]
404
+ if value is not None:
405
+ if isclass(selector.target) and issubclass(selector.target, BaseModel):
406
+ if selector.is_arr:
407
+ if not isinstance(value, list):
408
+ raise ValueError(f'Received non-array value for array field {selector.label}')
409
+ updated_values = []
410
+ for i, v in enumerate(value):
411
+ try:
412
+ updated_values.append(_partly_validate(selector.target, v, validators))
413
+ except ValueError as ve:
414
+ raise ValueError(f'Property {selector.label} contains invalid element at index {i}: {ve}')
415
+ self._dict[selector.label] = updated_values
416
+ else:
417
+ try:
418
+ valid_value = _partly_validate(selector.target, value, validators)
419
+ self._dict[selector.label] = valid_value
420
+ except ValueError as ve:
421
+ raise ValueError(f'Invalid property {selector.label}: {ve}')
422
+ else:
423
+ error_message = validate_type(selector.is_opt, selector.is_arr, selector.target, value, validators)
424
+ if error_message is not None:
425
+ message = f'Invalid property {selector.label}: {error_message}'
426
+ raise ValueError(message)
427
+ self._dict[selector.label] = value
428
+
429
+ if not allow_extra_args:
430
+ for k, v in kwargs.items():
431
+ if k not in model.properties():
432
+ raise ValueError(f'Unrecognized parameter {k} provided (value: {v})')
433
+
434
+ def __repr__(self):
435
+ return 'Partial' + self.model.__name__ + '(' + ', '.join(f'{k}={v}' for k, v in self._dict.items()) + ')'
436
+
437
+ def __getattr__(self, item):
438
+ try:
439
+ return object.__getattribute__(self, '_dict')[item]
440
+ except KeyError:
441
+ if item == 'model':
442
+ return object.__getattribute__(self, 'model')
443
+ raise AttributeError(item)
444
+
445
+ def __setattr__(self, key, value):
446
+ if key in self.model.properties():
447
+ prop = self.model.properties()[key]
448
+ if isinstance(prop, Discrim) and value != prop.value:
449
+ raise AttributeError(f'Setting discriminator value is forbidden')
450
+
451
+ object.__getattribute__(self, '_dict')[key] = value
452
+
453
+ def as_dict(self) -> Mapping[str, Any]:
454
+ return self._dict
455
+
456
+ def select_partial(self, *selectors: Select[M, Any]) -> PartialModel[M]:
457
+ """
458
+ Generate another partial instance, selecting the data to retain with one or more `Select` instances.
459
+ """
460
+ data: Dict[str, Any] = {}
461
+ for selector in selectors:
462
+ selector.copy_to_safe(self, data)
463
+ return self.model.partial(**data)
pydoptic/py.typed ADDED
File without changes