python-datamodel 0.6.28__cp313-cp313-win_amd64.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.
datamodel/base.py ADDED
@@ -0,0 +1,639 @@
1
+ from typing import Any, Optional
2
+ import inspect
3
+ import logging
4
+ # Dataclass
5
+ from dataclasses import (
6
+ _FIELD,
7
+ dataclass,
8
+ make_dataclass,
9
+ _MISSING_TYPE
10
+ )
11
+ from enum import EnumMeta
12
+ from uuid import UUID
13
+ from orjson import OPT_INDENT_2
14
+ from .converters import parse_basic, parse_type, slugify_camelcase
15
+ from .fields import Field
16
+ from .types import JSON_TYPES, Text
17
+ from .validation import (
18
+ _validation,
19
+ is_callable,
20
+ is_empty,
21
+ is_dataclass,
22
+ is_primitive
23
+ )
24
+ from .exceptions import ValidationError
25
+ from .parsers.encoders import json_encoder
26
+ from .abstract import ModelMeta, Meta
27
+ from .models import ModelMixin
28
+
29
+
30
+ def _get_type_info(_type, name, title):
31
+ if _type.__module__ == 'typing':
32
+ if inspect.isfunction(_type):
33
+ if hasattr(_type, '__supertype__'):
34
+ return _type.__supertype__
35
+ raise ValueError(
36
+ f"You're using bare Functions to type hint on {name} for: {title}"
37
+ )
38
+ if _type._name == 'List':
39
+ return 'array'
40
+ if _type._name == 'Dict':
41
+ return 'object'
42
+ try:
43
+ return _type.__args__[0].__name__
44
+ except (AttributeError, ValueError):
45
+ return 'string'
46
+ elif hasattr(_type, '__supertype__'):
47
+ if type(_type) == type(Text):
48
+ return 'text'
49
+ if isinstance(_type.__supertype__, (str, int)):
50
+ return 'string' if isinstance(_type.__supertype__, str) else 'integer'
51
+ return JSON_TYPES.get(_type, 'string')
52
+
53
+
54
+ def _get_ref_info(_type, field):
55
+ if isinstance(_type, EnumMeta):
56
+ return {
57
+ "type": "array",
58
+ "enum_type": {
59
+ "type": "string",
60
+ "enum": list(map(lambda c: c.value, _type))
61
+ }
62
+ }
63
+ elif isinstance(_type, ModelMeta):
64
+ _schema = _type.schema(as_dict=True)
65
+ columns = []
66
+ if 'fk' not in field.metadata:
67
+ ref = _schema.get('$id', f"/{_type.__name__}")
68
+ else:
69
+ columns = field.metadata.get('fk').split("|")
70
+ _id, _value = columns
71
+ ref = {
72
+ "api": field.metadata.get('api', _schema['table']),
73
+ "id": _id,
74
+ "value": _value,
75
+ "$ref": _schema.get('$id', f"/{_type.__name__}")
76
+ }
77
+ return {
78
+ "type": "object",
79
+ "schema": _schema,
80
+ "$ref": ref,
81
+ "columns": columns
82
+ }
83
+ elif 'api' in field.metadata:
84
+ # reference information, no matter the type:
85
+ try:
86
+ columns = field.metadata.get('fk').split("|")
87
+ _id, _value = columns
88
+ _fields = {
89
+ "id": _id,
90
+ "value": _value,
91
+ }
92
+ except (TypeError, ValueError):
93
+ _fields = {}
94
+ columns = []
95
+ ref = {
96
+ "api": field.metadata.get('api'),
97
+ **_fields
98
+ }
99
+ return {
100
+ "type": "object",
101
+ "$ref": ref,
102
+ "columns": columns
103
+ }
104
+ return None
105
+
106
+
107
+ class BaseModel(ModelMixin, metaclass=ModelMeta):
108
+ """
109
+ BaseModel.
110
+ Base Model for all DataModels.
111
+ """
112
+ Meta = Meta
113
+
114
+ def __post_init__(self) -> None:
115
+ """
116
+ Post init method.
117
+ Fill fields with function-factory or calling validations
118
+ """
119
+ # checking if an attribute is already a dataclass:
120
+ errors = {}
121
+ for name, f in self.__columns__.items():
122
+ try:
123
+ value = getattr(self, name)
124
+ if (error := self._process_field_(name, value, f)):
125
+ errors[name] = error
126
+ except RuntimeError as err:
127
+ logging.exception(err)
128
+ if errors:
129
+ if self.Meta.strict is True:
130
+ raise ValidationError(
131
+ f"""{self.modelName}: There are errors in Model. \
132
+ Hint: please check the "payload" attribute in the exception.""",
133
+ payload=errors
134
+ )
135
+ self.__errors__ = errors
136
+ object.__setattr__(self, "__valid__", False)
137
+ else:
138
+ object.__setattr__(self, "__valid__", True)
139
+
140
+ def _handle_default_value(self, value, f, name) -> Any:
141
+ # Calculate default value
142
+ if is_callable(value):
143
+ if value.__module__ != 'typing':
144
+ try:
145
+ new_val = value()
146
+ except TypeError:
147
+ try:
148
+ new_val = f.default()
149
+ except TypeError:
150
+ new_val = None
151
+ setattr(self, name, new_val)
152
+ elif is_callable(f.default) and value is None:
153
+ # Set the default value first
154
+ try:
155
+ new_val = f.default()
156
+ except (AttributeError, RuntimeError):
157
+ new_val = None
158
+ setattr(self, name, new_val)
159
+ value = new_val # Return the new value
160
+ elif not isinstance(f.default, _MISSING_TYPE) and value is None:
161
+ setattr(self, name, f.default)
162
+ value = f.default
163
+ return value
164
+
165
+ def _handle_dataclass_type(self, value, _type):
166
+ try:
167
+ if hasattr(self.Meta, 'no_nesting'):
168
+ return value
169
+ if value is None or is_dataclass(value):
170
+ return value
171
+ if isinstance(value, dict):
172
+ return _type(**value)
173
+ if isinstance(value, list):
174
+ return _type(*value)
175
+ return value if isinstance(value, (int, str, UUID)) else _type(value)
176
+ except Exception as exc:
177
+ raise ValueError(
178
+ f"Invalid value for {_type}: {value}, error: {exc}"
179
+ )
180
+
181
+ def _handle_list_of_dataclasses(self, value, _type):
182
+ try:
183
+ sub_type = _type.__args__[0]
184
+ if is_dataclass(sub_type):
185
+ return [
186
+ sub_type(**item) if isinstance(item, dict) else item for item in value
187
+ ]
188
+ except AttributeError:
189
+ pass
190
+ return value
191
+
192
+ def _process_field_(
193
+ self,
194
+ name: str, value: Any, f: Field
195
+ ) -> Optional[dict[Any, Any]]:
196
+ _type = f.type
197
+ _encoder = f.metadata.get('encoder')
198
+ new_val = value
199
+ if is_empty(value):
200
+ new_val = f.default_factory if isinstance(f.default, (_MISSING_TYPE)) else f.default
201
+ setattr(self, name, new_val)
202
+
203
+ if f.default is not None:
204
+ value = self._handle_default_value(value, f, name)
205
+
206
+ if is_primitive(_type):
207
+ try:
208
+ if value is not None:
209
+ new_val = parse_basic(f.type, value, _encoder)
210
+ return self._validation_(name, new_val, f, _type)
211
+ except (TypeError, ValueError) as ex:
212
+ raise ValueError(
213
+ f"Wrong Type for {f.name}: {f.type}, error: {ex}"
214
+ ) from ex
215
+ elif inspect.isclass(_type) and _type.__module__ == 'typing':
216
+ new_val = parse_type(_type, value, _encoder)
217
+ return self._validation_(name, new_val, f, _type)
218
+ elif isinstance(value, list) and hasattr(_type, '__args__'):
219
+ new_val = self._handle_list_of_dataclasses(value, _type)
220
+ return self._validation_(name, new_val, f, _type)
221
+ elif is_dataclass(_type):
222
+ new_val = self._handle_dataclass_type(value, _type)
223
+ return self._validation_(name, new_val, f, _type)
224
+ else:
225
+ try:
226
+ new_val = parse_type(f.type, value, _encoder)
227
+ except (TypeError, ValueError) as ex:
228
+ raise ValueError(
229
+ f"Wrong Type for {f.name}: {f.type}, error: {ex}"
230
+ ) from ex
231
+ # Then validate the value
232
+ return self._validation_(name, new_val, f, _type)
233
+
234
+ def _field_checks_(self, f: Field, name: str, value: Any) -> None:
235
+ # Validate Primary Key
236
+ try:
237
+ if f.metadata['primary'] is True:
238
+ if 'db_default' in f.metadata:
239
+ pass
240
+ else:
241
+ raise ValueError(
242
+ f"::{self.modelName}:: Missing Primary Key *{name}*"
243
+ )
244
+ except KeyError:
245
+ pass
246
+ # Validate Required
247
+ try:
248
+ if f.metadata["required"] is True and self.Meta.strict is True:
249
+ if 'db_default' in f.metadata:
250
+ return
251
+ if value is not None:
252
+ return # If default value is set, no need to raise an error
253
+ raise ValueError(
254
+ f"::{self.modelName}:: Missing Required Field *{name}*"
255
+ )
256
+ except KeyError:
257
+ return
258
+ # Nullable:
259
+ try:
260
+ if f.metadata["nullable"] is False and self.Meta.strict is True:
261
+ raise ValueError(
262
+ f"::{self.modelName}:: *{name}* Cannot be null."
263
+ )
264
+ except KeyError:
265
+ return
266
+ return
267
+
268
+ def _validation_(
269
+ self,
270
+ name: str,
271
+ value: Any,
272
+ f: Field, _type: Any
273
+ ) -> Optional[dict[Any, Any]]:
274
+ """
275
+ _validation_.
276
+ TODO: cover validations as length, not_null, required, max, min, etc
277
+ """
278
+ val_type = type(value)
279
+ # Set the current Value
280
+ setattr(self, name, value)
281
+
282
+ if val_type == type or value == _type or is_empty(value):
283
+ try:
284
+ self._field_checks_(f, name, value)
285
+ return None
286
+ except (ValueError, TypeError):
287
+ raise
288
+ else:
289
+ # capturing other errors from validator:
290
+ return _validation(f, name, value, _type, val_type)
291
+
292
+ @classmethod
293
+ def add_field(cls, name: str, value: Any = None) -> None:
294
+ if cls.Meta.strict is True:
295
+ raise TypeError(
296
+ f'Cannot create a new field {name} on a Strict Model.'
297
+ )
298
+ if name != '__errors__':
299
+ f = Field(required=False, default=value)
300
+ f.name = name
301
+ f.type = type(value)
302
+ f._field_type = _FIELD
303
+ cls.__columns__[name] = f
304
+ cls.__dataclass_fields__[name] = f
305
+
306
+ def create_field(self, name: str, value: Any) -> None:
307
+ """create_field.
308
+ create a new Field on Model (when strict is False).
309
+ Args:
310
+ name (str): name of the field
311
+ value (Any): value to be assigned.
312
+ Raises:
313
+ TypeError: when try to create a new field on an Strict Model.
314
+ """
315
+ if self.Meta.strict is True:
316
+ raise TypeError(
317
+ f'Cannot create a new field {name} on a Strict Model.'
318
+ )
319
+ if name != '__errors__':
320
+ f = Field(required=False, default=value)
321
+ f.name = name
322
+ f.type = type(value)
323
+ f._field_type = _FIELD
324
+ self.__columns__[name] = f
325
+ self.__dataclass_fields__[name] = f
326
+ setattr(self, name, value)
327
+
328
+ def set(self, name: str, value: Any) -> None:
329
+ """set.
330
+ Alias for Create Field.
331
+ Args:
332
+ name (str): name of the field
333
+ value (Any): value to be assigned.
334
+ """
335
+ if name not in self.__columns__:
336
+ if name != '__errors__' and self.Meta.strict is False:
337
+ self.create_field(name, value)
338
+ else:
339
+ setattr(self, name, value)
340
+
341
+ def get_errors(self):
342
+ return self.__errors__
343
+
344
+ @classmethod
345
+ def make_model(cls, name: str, schema: str = "public", fields: list = None):
346
+ parent = inspect.getmro(cls)
347
+ obj = make_dataclass(name, fields, bases=(parent[0],))
348
+ m = Meta()
349
+ m.name = name
350
+ m.schema = schema
351
+ m.app_label = schema
352
+ obj.Meta = m
353
+ return obj
354
+
355
+ @classmethod
356
+ def from_json(cls, obj: str, **kwargs) -> dataclass:
357
+ try:
358
+ decoder = cls.__encoder__(**kwargs)
359
+ decoded = decoder.loads(obj)
360
+ return cls(**decoded)
361
+ except ValueError as e:
362
+ raise RuntimeError(
363
+ "DataModel: Invalid string (JSON) data for decoding: {e}"
364
+ ) from e
365
+
366
+ @classmethod
367
+ def from_dict(cls, obj: dict) -> dataclass:
368
+ try:
369
+ return cls(**obj)
370
+ except ValueError as e:
371
+ raise RuntimeError(
372
+ "DataModel: Invalid Dictionary data for decoding: {e}"
373
+ ) from e
374
+
375
+ @classmethod
376
+ def model(cls, dialect: str = "json", **kwargs) -> Any:
377
+ """model.
378
+
379
+ Return the json-version of current Model.
380
+ Returns:
381
+ str: string (json) version of model.
382
+ """
383
+ result = None
384
+ clsname = cls.__name__
385
+ schema = cls.Meta.schema
386
+ table = cls.Meta.name if cls.Meta.name else clsname.lower()
387
+ columns = cls.columns(cls).items()
388
+ if dialect == 'json':
389
+ cols = {}
390
+ for _, field in columns:
391
+ key = field.name
392
+ _type = field.type
393
+ if _type.__module__ == 'typing':
394
+ # TODO: discover real value of typing
395
+ if _type._name == 'List':
396
+ t = 'array'
397
+ elif _type._name == 'Dict':
398
+ t = 'object'
399
+ else:
400
+ try:
401
+ t = _type.__args__[0]
402
+ t = t.__name__
403
+ except (AttributeError, ValueError):
404
+ t = 'object'
405
+ else:
406
+ try:
407
+ t = JSON_TYPES[_type]
408
+ except KeyError:
409
+ t = 'object'
410
+ cols[key] = {"name": key, "type": t}
411
+ doc = {
412
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
413
+ "$id": f"/schemas/{table}",
414
+ "name": clsname,
415
+ "description": cls.__doc__.strip("\n").strip(),
416
+ "additionalProperties": False,
417
+ "table": table,
418
+ "schema": schema,
419
+ "type": "object",
420
+ "properties": cols,
421
+ }
422
+ encoder = cls.__encoder__(**kwargs)
423
+ result = encoder.dumps(doc, option=OPT_INDENT_2)
424
+ return result
425
+
426
+ @classmethod
427
+ def sample(cls) -> dict:
428
+ """sample.
429
+
430
+ Get a dict (JSON) sample of this datamodel, based on default values.
431
+
432
+ Returns:
433
+ dict: _description_
434
+ """
435
+ columns = cls.get_columns().items()
436
+ _fields = {}
437
+ required = []
438
+ for name, f in columns:
439
+ if f.repr is False:
440
+ continue
441
+ _fields[name] = f.default
442
+ try:
443
+ if f.metadata["required"] is True:
444
+ required.append(name)
445
+ except KeyError:
446
+ pass
447
+ return {
448
+ "properties": _fields,
449
+ "required": required
450
+ }
451
+
452
+ def _get_meta_value(self, key: str, fallback: Any = None, locale: Any = None):
453
+ value = getattr(self.Meta, key, fallback)
454
+ if locale is not None:
455
+ value = locale(value)
456
+ return value
457
+
458
+ def _get_metadata(self, field, key: str, locale: Any = None):
459
+ value = field.metadata.get(key, None)
460
+ if locale is not None:
461
+ value = locale(value)
462
+ return value
463
+
464
+ def _get_field_schema(
465
+ self,
466
+ type_info: str,
467
+ field: object,
468
+ description: str,
469
+ locale: Any = None,
470
+ **kwargs
471
+ ) -> dict:
472
+ return {
473
+ "type": type_info,
474
+ "nullable": field.metadata.get('nullable', False),
475
+ "attrs": {
476
+ "placeholder": description,
477
+ "format": field.metadata.get('format', None),
478
+ },
479
+ "readOnly": field.metadata.get('readonly', False),
480
+ **kwargs
481
+ }
482
+
483
+ @classmethod
484
+ def schema(cls, as_dict=False, locale: Any = None):
485
+ """Convert Model to JSON-Schema.
486
+
487
+ Args:
488
+ as_dict (bool, optional): if false, Returns JSON-schema as a JSON object.
489
+ Defaults to False.
490
+
491
+ Returns:
492
+ _type_: JSON-Schema version of Model.
493
+ """
494
+ # description:
495
+ description = cls._get_meta_value(
496
+ cls,
497
+ 'description',
498
+ fallback=cls.__doc__.strip("\n").strip(),
499
+ locale=locale
500
+ )
501
+ title = cls._get_meta_value(
502
+ cls,
503
+ 'title',
504
+ fallback=cls.__name__,
505
+ locale=locale
506
+ )
507
+ try:
508
+ title = slugify_camelcase(title)
509
+ except Exception:
510
+ pass
511
+
512
+ # Table Name:
513
+ table = cls.Meta.name.lower() if cls.Meta.name else title.lower()
514
+ endpoint = cls.Meta.endpoint
515
+ schema = cls.Meta.schema
516
+ columns = cls.get_columns().items()
517
+
518
+ fields = {}
519
+ required = []
520
+ defs = {}
521
+
522
+ # settings:
523
+ settings = cls._get_meta_value(
524
+ cls,
525
+ 'settings',
526
+ fallback={},
527
+ locale=None
528
+ )
529
+ try:
530
+ settings = {
531
+ "settings": settings
532
+ }
533
+ except TypeError:
534
+ settings = {}
535
+
536
+ for name, field in columns:
537
+ _type = field.type
538
+ type_info = _get_type_info(_type, name, title)
539
+ ref_info = _get_ref_info(_type, field)
540
+ if ref_info:
541
+ defs[name] = ref_info.pop('schema', None)
542
+ else:
543
+ ref_info = {}
544
+
545
+ minimum = field.metadata.get('min', None)
546
+ maximum = field.metadata.get('max', None)
547
+ secret = field.metadata.get('secret', None)
548
+ # custom endpoint for every field:
549
+ custom_endpoint = field.metadata.get('endpoint', None)
550
+
551
+ if field.metadata.get('required', False) or field.metadata.get('primary', False):
552
+ required.append(name)
553
+
554
+ # UI objects:
555
+ ui_objects = {
556
+ k.replace('_', ':'): v for k, v in field.metadata.items() if k.startswith('ui_')
557
+ }
558
+ # schema_extra:
559
+ schema_extra = field.metadata.get('schema_extra', {})
560
+ meta_description = cls._get_metadata(
561
+ cls,
562
+ field,
563
+ key='description',
564
+ locale=locale
565
+ )
566
+ fields[name] = cls._get_field_schema(
567
+ cls,
568
+ type_info,
569
+ field,
570
+ description=meta_description,
571
+ locale=locale,
572
+ **ui_objects,
573
+ **schema_extra,
574
+ **ref_info
575
+ )
576
+ label = cls._get_metadata(cls, field, 'label', locale=locale)
577
+ if label:
578
+ fields[name]["label"] = label
579
+ if meta_description:
580
+ fields[name]["description"] = meta_description
581
+ if custom_endpoint:
582
+ fields[name]["endpoint"] = custom_endpoint
583
+
584
+ if 'write_only' in field.metadata:
585
+ fields[name]["writeOnly"] = field.metadata.get('write_only', False)
586
+
587
+ if 'pattern' in field.metadata:
588
+ fields[name]["attrs"]["pattern"] = field.metadata['pattern']
589
+
590
+ if field.repr is False:
591
+ fields[name]["attrs"]["visible"] = False
592
+
593
+ if field.default:
594
+ d = field.default
595
+ if is_callable(d):
596
+ fields[name]['default'] = f"fn:{d!r}"
597
+ else:
598
+ fields[name]['default'] = f"{d!s}"
599
+
600
+ if secret is not None:
601
+ fields[name]['secret'] = secret
602
+
603
+ if type_info == 'string':
604
+ if minimum:
605
+ fields[name]['minLength'] = minimum
606
+ if maximum:
607
+ fields[name]['maxLength'] = maximum
608
+ else:
609
+ if minimum:
610
+ fields[name]['minimum'] = minimum
611
+ if maximum:
612
+ fields[name]['maximum'] = maximum
613
+
614
+ endpoint_kwargs = {}
615
+ if endpoint:
616
+ endpoint_kwargs["endpoint"] = endpoint
617
+
618
+ base_schema = {
619
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
620
+ "$id": f"/schemas/{table}",
621
+ **endpoint_kwargs,
622
+ **settings,
623
+ "additionalProperties": cls.Meta.strict,
624
+ "title": title,
625
+ "description": description,
626
+ "type": "object",
627
+ "table": table,
628
+ "schema": schema,
629
+ "properties": fields,
630
+ "required": required,
631
+ }
632
+
633
+ if defs:
634
+ base_schema["$defs"] = defs
635
+
636
+ if as_dict is True:
637
+ return base_schema
638
+ else:
639
+ return json_encoder(base_schema)