python-datamodel 0.10.1__cp313-cp313-win32.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/__init__.py +13 -0
- datamodel/abstract.py +383 -0
- datamodel/adaptive/__init__.py +0 -0
- datamodel/adaptive/models.py +598 -0
- datamodel/aliases/__init__.py +26 -0
- datamodel/base.py +180 -0
- datamodel/converters.c +43471 -0
- datamodel/converters.cp313-win32.pyd +0 -0
- datamodel/converters.html +17387 -0
- datamodel/converters.pyx +1489 -0
- datamodel/exceptions.c +13455 -0
- datamodel/exceptions.cp313-win32.pyd +0 -0
- datamodel/exceptions.html +1261 -0
- datamodel/exceptions.pxd +13 -0
- datamodel/exceptions.pyx +50 -0
- datamodel/fields.cp313-win32.pyd +0 -0
- datamodel/fields.cpp +17401 -0
- datamodel/fields.html +3912 -0
- datamodel/fields.pyx +309 -0
- datamodel/functions.cp313-win32.pyd +0 -0
- datamodel/functions.cpp +9068 -0
- datamodel/functions.html +1766 -0
- datamodel/functions.pxd +9 -0
- datamodel/functions.pyx +82 -0
- datamodel/jsonld/__init__.py +45 -0
- datamodel/jsonld/models.py +500 -0
- datamodel/libs/__init__.py +1 -0
- datamodel/libs/mapping.c +15067 -0
- datamodel/libs/mapping.cp313-win32.pyd +0 -0
- datamodel/libs/mapping.html +2618 -0
- datamodel/libs/mapping.pxd +11 -0
- datamodel/libs/mapping.pyx +135 -0
- datamodel/libs/mutables.py +127 -0
- datamodel/models.py +814 -0
- datamodel/parsers/__init__.py +0 -0
- datamodel/parsers/encoders.py +15 -0
- datamodel/parsers/json.cp313-win32.pyd +0 -0
- datamodel/parsers/json.cpp +17004 -0
- datamodel/parsers/json.html +3365 -0
- datamodel/parsers/json.pyx +250 -0
- datamodel/profiler.py +21 -0
- datamodel/py.typed +0 -0
- datamodel/rs_core/Cargo.toml +17 -0
- datamodel/rs_core/src/lib.rs +294 -0
- datamodel/rs_parsers/Cargo.toml +22 -0
- datamodel/rs_parsers/src/lib.rs +571 -0
- datamodel/rs_parsers.cp313-win32.pyd +0 -0
- datamodel/rs_validators/Cargo.toml +17 -0
- datamodel/rs_validators/src/lib.rs +0 -0
- datamodel/typedefs/__init__.py +9 -0
- datamodel/typedefs/singleton.c +9169 -0
- datamodel/typedefs/singleton.cp313-win32.pyd +0 -0
- datamodel/typedefs/singleton.html +629 -0
- datamodel/typedefs/singleton.pxd +9 -0
- datamodel/typedefs/singleton.pyx +24 -0
- datamodel/typedefs/types.c +11716 -0
- datamodel/typedefs/types.cp313-win32.pyd +0 -0
- datamodel/typedefs/types.html +732 -0
- datamodel/typedefs/types.pxd +11 -0
- datamodel/typedefs/types.pyx +39 -0
- datamodel/types.c +7165 -0
- datamodel/types.cp313-win32.pyd +0 -0
- datamodel/types.html +716 -0
- datamodel/types.pyx +100 -0
- datamodel/validation.cp313-win32.pyd +0 -0
- datamodel/validation.cpp +17085 -0
- datamodel/validation.html +4769 -0
- datamodel/validation.pyx +315 -0
- datamodel/version.py +13 -0
- examples/nn/examples.py +311 -0
- examples/nn/stores.py +151 -0
- examples/tests/sp_types.py +294 -0
- examples/tests/speed_dates.py +26 -0
- python_datamodel-0.10.1.dist-info/LICENSE +29 -0
- python_datamodel-0.10.1.dist-info/METADATA +320 -0
- python_datamodel-0.10.1.dist-info/RECORD +78 -0
- python_datamodel-0.10.1.dist-info/WHEEL +5 -0
- python_datamodel-0.10.1.dist-info/top_level.txt +7 -0
datamodel/models.py
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import contextlib
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
from enum import Enum, EnumMeta
|
|
5
|
+
# Dataclass
|
|
6
|
+
import inspect
|
|
7
|
+
from dataclasses import asdict as as_dict, dataclass, make_dataclass, _MISSING_TYPE
|
|
8
|
+
from operator import attrgetter
|
|
9
|
+
from orjson import OPT_INDENT_2
|
|
10
|
+
from datamodel.fields import fields
|
|
11
|
+
from .abstract import ModelMeta, Meta
|
|
12
|
+
from .fields import Field
|
|
13
|
+
from .parsers.encoders import json_encoder
|
|
14
|
+
from .converters import slugify_camelcase
|
|
15
|
+
from .types import JSON_TYPES, Text
|
|
16
|
+
from .functions import is_callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_type_info(_type, name, title):
|
|
20
|
+
if _type.__module__ == 'typing':
|
|
21
|
+
if inspect.isfunction(_type):
|
|
22
|
+
if hasattr(_type, '__supertype__'):
|
|
23
|
+
return _type.__supertype__
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"You're using bare Functions to type hint on {name} for: {title}"
|
|
26
|
+
)
|
|
27
|
+
if _type._name == 'List':
|
|
28
|
+
return 'array'
|
|
29
|
+
if _type._name == 'Dict':
|
|
30
|
+
return 'object'
|
|
31
|
+
try:
|
|
32
|
+
return _type.__args__[0].__name__
|
|
33
|
+
except (AttributeError, ValueError):
|
|
34
|
+
return 'string'
|
|
35
|
+
elif hasattr(_type, '__supertype__'):
|
|
36
|
+
if type(_type) == type(Text): # pylint: disable=C0123 # noqa
|
|
37
|
+
return 'text'
|
|
38
|
+
if isinstance(_type.__supertype__, (str, int)):
|
|
39
|
+
return 'string' if isinstance(_type.__supertype__, str) else 'integer'
|
|
40
|
+
return JSON_TYPES.get(_type, 'string')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_ref_info(_type, field):
|
|
44
|
+
if isinstance(_type, EnumMeta):
|
|
45
|
+
return {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"enum_type": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": list(map(lambda c: c.value, _type))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
elif isinstance(_type, ModelMeta):
|
|
53
|
+
_schema = _type.schema(as_dict=True)
|
|
54
|
+
columns = []
|
|
55
|
+
if 'fk' not in field.metadata:
|
|
56
|
+
ref = _schema.get('$id', f"/{_type.__name__}")
|
|
57
|
+
else:
|
|
58
|
+
columns = field.metadata.get('fk').split("|")
|
|
59
|
+
_id, _value = columns
|
|
60
|
+
ref = {
|
|
61
|
+
"api": field.metadata.get('api', _schema['table']),
|
|
62
|
+
"id": _id,
|
|
63
|
+
"value": _value,
|
|
64
|
+
"$ref": _schema.get('$id', f"/{_type.__name__}")
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"schema": _schema,
|
|
69
|
+
"$ref": ref,
|
|
70
|
+
"columns": columns
|
|
71
|
+
}
|
|
72
|
+
elif 'api' in field.metadata:
|
|
73
|
+
# reference information, no matter the type:
|
|
74
|
+
try:
|
|
75
|
+
columns = field.metadata.get('fk').split("|")
|
|
76
|
+
_id, _value = columns
|
|
77
|
+
_fields = {
|
|
78
|
+
"id": _id,
|
|
79
|
+
"value": _value,
|
|
80
|
+
}
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
_fields = {}
|
|
83
|
+
columns = []
|
|
84
|
+
ref = {
|
|
85
|
+
"api": field.metadata.get('api'),
|
|
86
|
+
**_fields
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"$ref": ref,
|
|
91
|
+
"columns": columns
|
|
92
|
+
}
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ModelMixin:
|
|
97
|
+
"""Interface for shared methods on Model classes.
|
|
98
|
+
"""
|
|
99
|
+
def __unicode__(self):
|
|
100
|
+
return str(__class__)
|
|
101
|
+
|
|
102
|
+
def columns(self):
|
|
103
|
+
return self.__columns__
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def get_columns(cls):
|
|
107
|
+
return cls.__columns__
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def get_column(cls, name: str) -> Field:
|
|
111
|
+
try:
|
|
112
|
+
return cls.__columns__[name]
|
|
113
|
+
except KeyError as ex:
|
|
114
|
+
raise AttributeError(
|
|
115
|
+
f"{cls.__name__} has no column {name}"
|
|
116
|
+
) from ex
|
|
117
|
+
|
|
118
|
+
def has_column(self, name: str) -> bool:
|
|
119
|
+
return name in self.__columns__
|
|
120
|
+
|
|
121
|
+
def list_columns(self) -> list[str]:
|
|
122
|
+
return self.__fields__
|
|
123
|
+
|
|
124
|
+
def get_fields(self):
|
|
125
|
+
return self.__fields__
|
|
126
|
+
|
|
127
|
+
def __contains__(self, key: str) -> bool:
|
|
128
|
+
"""__contains__. Check if key is in the columns of the Model."""
|
|
129
|
+
return key in self.__columns__
|
|
130
|
+
|
|
131
|
+
def __getitem__(self, item: str) -> Any:
|
|
132
|
+
return getattr(self, item)
|
|
133
|
+
|
|
134
|
+
def reset_values(self):
|
|
135
|
+
with contextlib.suppress(AttributeError):
|
|
136
|
+
self.__values__ = {}
|
|
137
|
+
|
|
138
|
+
def old_value(self, name: str) -> Any:
|
|
139
|
+
"""
|
|
140
|
+
old_value.
|
|
141
|
+
Get the old value of an attribute.
|
|
142
|
+
Args:
|
|
143
|
+
name (str): name of the attribute.
|
|
144
|
+
Returns:
|
|
145
|
+
Any: value of the attribute.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
return self.__values__[name]
|
|
149
|
+
except KeyError as ex:
|
|
150
|
+
raise AttributeError(
|
|
151
|
+
f"{self.__class__.__name__} has no attribute {name}"
|
|
152
|
+
) from ex
|
|
153
|
+
|
|
154
|
+
def column(self, name: str) -> Field:
|
|
155
|
+
return self.__columns__[name]
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
f_repr = ", ".join(f"{f.name}={getattr(self, f.name)}" for f in fields(self))
|
|
159
|
+
return f"{self.__class__.__name__}({f_repr})"
|
|
160
|
+
|
|
161
|
+
def pop(self, key: str, default: Any = _MISSING_TYPE) -> Any:
|
|
162
|
+
"""
|
|
163
|
+
A dict-like pop() method.
|
|
164
|
+
Removes the value of `self.key` if it exists, otherwise returns `default`.
|
|
165
|
+
"""
|
|
166
|
+
if key not in self.__columns__:
|
|
167
|
+
if default is not _MISSING_TYPE:
|
|
168
|
+
return default
|
|
169
|
+
raise KeyError(f"{self.__class__.__name__} has no attribute {key}")
|
|
170
|
+
|
|
171
|
+
# return the current value:
|
|
172
|
+
value = getattr(self, key)
|
|
173
|
+
setattr(self, key, None)
|
|
174
|
+
if hasattr(self, '__values__') and key in self.__values__:
|
|
175
|
+
del self.__values__[key]
|
|
176
|
+
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
def remove_nulls(self, obj: Any) -> dict[str, Any]:
|
|
180
|
+
"""Recursively removes any fields with None values from the given object."""
|
|
181
|
+
if isinstance(obj, list):
|
|
182
|
+
return [self.remove_nulls(item) for item in obj]
|
|
183
|
+
elif isinstance(obj, dict):
|
|
184
|
+
return {
|
|
185
|
+
key: self.remove_nulls(value) for key, value in obj.items()
|
|
186
|
+
if value is not None and value != {}
|
|
187
|
+
}
|
|
188
|
+
else:
|
|
189
|
+
return obj
|
|
190
|
+
|
|
191
|
+
def __convert_enums__(self, obj: Any) -> dict[str, Any]:
|
|
192
|
+
"""Recursively converts any Enum values to their value."""
|
|
193
|
+
if isinstance(obj, list):
|
|
194
|
+
return [self.__convert_enums__(item) for item in obj]
|
|
195
|
+
elif isinstance(obj, dict):
|
|
196
|
+
return {
|
|
197
|
+
key: self.__convert_enums__(value) for key, value in obj.items()
|
|
198
|
+
}
|
|
199
|
+
else:
|
|
200
|
+
return obj.value if isinstance(obj, Enum) else obj
|
|
201
|
+
|
|
202
|
+
def to_dict(
|
|
203
|
+
self,
|
|
204
|
+
remove_nulls: bool = False,
|
|
205
|
+
convert_enums: bool = False,
|
|
206
|
+
as_values: bool = False
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
if as_values:
|
|
209
|
+
return self.__collapse_as_values__(remove_nulls, convert_enums, as_values)
|
|
210
|
+
d = as_dict(self, dict_factory=dict)
|
|
211
|
+
if convert_enums:
|
|
212
|
+
d = self.__convert_enums__(d)
|
|
213
|
+
if self.Meta.remove_nulls is True or remove_nulls:
|
|
214
|
+
return self.remove_nulls(d)
|
|
215
|
+
# 4) If as_values => convert sub-models to pk-value
|
|
216
|
+
return d
|
|
217
|
+
|
|
218
|
+
def __collapse_as_values__(
|
|
219
|
+
self,
|
|
220
|
+
remove_nulls: bool = False,
|
|
221
|
+
convert_enums: bool = False,
|
|
222
|
+
as_values: bool = False
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
"""Recursively converts any BaseModel instances to their primary key value."""
|
|
225
|
+
out = {}
|
|
226
|
+
fields = self.columns()
|
|
227
|
+
for name, field in fields.items():
|
|
228
|
+
# datatype = field.type
|
|
229
|
+
value = getattr(self, name)
|
|
230
|
+
if value is None and remove_nulls:
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(value, ModelMixin):
|
|
233
|
+
if as_values:
|
|
234
|
+
out[name] = getattr(value, name)
|
|
235
|
+
else:
|
|
236
|
+
out[name] = value.__collapse_as_values__(
|
|
237
|
+
remove_nulls=remove_nulls,
|
|
238
|
+
convert_enums=convert_enums,
|
|
239
|
+
as_values=as_values
|
|
240
|
+
)
|
|
241
|
+
# if it's a list, might contain submodels or scalars
|
|
242
|
+
elif isinstance(value, list):
|
|
243
|
+
items_out = []
|
|
244
|
+
for item in value:
|
|
245
|
+
if isinstance(item, ModelMixin):
|
|
246
|
+
if as_values:
|
|
247
|
+
items_out.append(getattr(item, name))
|
|
248
|
+
else:
|
|
249
|
+
items_out.append(item.__collapse_as_values__(
|
|
250
|
+
remove_nulls=remove_nulls,
|
|
251
|
+
convert_enums=convert_enums,
|
|
252
|
+
as_values=as_values
|
|
253
|
+
))
|
|
254
|
+
else:
|
|
255
|
+
items_out.append(item)
|
|
256
|
+
out[name] = items_out
|
|
257
|
+
else:
|
|
258
|
+
out[name] = value
|
|
259
|
+
if convert_enums:
|
|
260
|
+
out = self.__convert_enums__(out)
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
def json(self, **kwargs):
|
|
264
|
+
encoder = self.__encoder__(**kwargs)
|
|
265
|
+
return encoder(as_dict(self))
|
|
266
|
+
|
|
267
|
+
to_json = json
|
|
268
|
+
|
|
269
|
+
def is_valid(self) -> bool:
|
|
270
|
+
"""is_valid.
|
|
271
|
+
|
|
272
|
+
returns True when current Model is valid under datatype validations.
|
|
273
|
+
Returns:
|
|
274
|
+
bool: True if current model is valid.
|
|
275
|
+
"""
|
|
276
|
+
return bool(self.__valid__)
|
|
277
|
+
|
|
278
|
+
def get(self, key: str, default=None):
|
|
279
|
+
"""
|
|
280
|
+
A dict-like get() method.
|
|
281
|
+
Returns the value of `self.key` if it exists, otherwise returns `default`.
|
|
282
|
+
"""
|
|
283
|
+
return getattr(self, key) if hasattr(self, key) else default
|
|
284
|
+
|
|
285
|
+
def _get_meta_value(self, key: str, fallback: Any = None, locale: Any = None):
|
|
286
|
+
value = getattr(self.Meta, key, fallback)
|
|
287
|
+
if locale is not None:
|
|
288
|
+
value = locale(value)
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
def _get_meta_values(
|
|
292
|
+
self,
|
|
293
|
+
key: dict,
|
|
294
|
+
fallback: Any = None,
|
|
295
|
+
locale: Any = None
|
|
296
|
+
):
|
|
297
|
+
"""
|
|
298
|
+
_get_meta_values.
|
|
299
|
+
|
|
300
|
+
Translates the entire dictionary of Meta values.
|
|
301
|
+
"""
|
|
302
|
+
values = getattr(self.Meta, key, fallback)
|
|
303
|
+
if locale is not None:
|
|
304
|
+
for key, val in values.items():
|
|
305
|
+
try:
|
|
306
|
+
values[key] = locale(val)
|
|
307
|
+
except (KeyError, TypeError):
|
|
308
|
+
pass
|
|
309
|
+
return values
|
|
310
|
+
|
|
311
|
+
def _get_metadata(self, field, key: str, locale: Any = None):
|
|
312
|
+
value = field.metadata.get(key, None)
|
|
313
|
+
if locale is not None:
|
|
314
|
+
value = locale(value)
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
def _get_field_schema(
|
|
318
|
+
self,
|
|
319
|
+
type_info: str,
|
|
320
|
+
field: object,
|
|
321
|
+
description: str,
|
|
322
|
+
locale: Any = None,
|
|
323
|
+
**kwargs
|
|
324
|
+
) -> dict:
|
|
325
|
+
return {
|
|
326
|
+
"type": type_info,
|
|
327
|
+
"nullable": field.metadata.get('nullable', False),
|
|
328
|
+
"attrs": {
|
|
329
|
+
"placeholder": description,
|
|
330
|
+
"format": field.metadata.get('format', None),
|
|
331
|
+
},
|
|
332
|
+
"readOnly": field.metadata.get('readonly', False),
|
|
333
|
+
**kwargs
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
@classmethod
|
|
337
|
+
def _build_schema_basics(cls, locale: Any = None):
|
|
338
|
+
"""Build basic schema metadata such as title, description, etc."""
|
|
339
|
+
# description:
|
|
340
|
+
description = cls._get_meta_value(
|
|
341
|
+
cls,
|
|
342
|
+
'description',
|
|
343
|
+
fallback=cls.__doc__.strip("\n").strip(),
|
|
344
|
+
locale=locale
|
|
345
|
+
)
|
|
346
|
+
title = cls._get_meta_value(
|
|
347
|
+
cls,
|
|
348
|
+
'title',
|
|
349
|
+
fallback=cls.__name__,
|
|
350
|
+
locale=locale
|
|
351
|
+
)
|
|
352
|
+
try:
|
|
353
|
+
title = slugify_camelcase(title)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
# display_name:
|
|
357
|
+
display_name = cls._get_meta_value(
|
|
358
|
+
cls,
|
|
359
|
+
'display_name',
|
|
360
|
+
fallback=f"{title}_name".lower(),
|
|
361
|
+
locale=locale
|
|
362
|
+
)
|
|
363
|
+
# Table Name:
|
|
364
|
+
table = cls.Meta.name.lower() if cls.Meta.name else title.lower()
|
|
365
|
+
endpoint = cls.Meta.endpoint
|
|
366
|
+
schema = cls.Meta.schema
|
|
367
|
+
return title, description, display_name, table, endpoint, schema
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def _build_settings(cls, locale: Any = None) -> dict:
|
|
371
|
+
"""Build the settings part of the schema."""
|
|
372
|
+
# settings:
|
|
373
|
+
settings = cls._get_meta_values(
|
|
374
|
+
cls,
|
|
375
|
+
'settings',
|
|
376
|
+
fallback={},
|
|
377
|
+
locale=locale
|
|
378
|
+
)
|
|
379
|
+
if not isinstance(settings, dict):
|
|
380
|
+
# Ensure settings is always a dict
|
|
381
|
+
settings = {}
|
|
382
|
+
return {"settings": settings}
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def _build_fields(cls, title: str, locale: Any = None) -> dict:
|
|
386
|
+
"""Build the fields part of the schema."""
|
|
387
|
+
fields = {}
|
|
388
|
+
required = []
|
|
389
|
+
defs = {}
|
|
390
|
+
|
|
391
|
+
# Get the columns of the Model.
|
|
392
|
+
for name, field in cls.get_columns().items():
|
|
393
|
+
field_schema, field_defs, field_required = cls._process_field_schema(
|
|
394
|
+
name, field, locale, title
|
|
395
|
+
)
|
|
396
|
+
fields[name] = field_schema
|
|
397
|
+
if field_required:
|
|
398
|
+
required.append(name)
|
|
399
|
+
if field_defs:
|
|
400
|
+
defs[name] = field_defs.get('schema')
|
|
401
|
+
return fields, required, defs
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def _extract_field_basics(cls, name: str, field: Field, title: str):
|
|
405
|
+
_type = field.type
|
|
406
|
+
type_info = _get_type_info(_type, name, title)
|
|
407
|
+
ref_info = _get_ref_info(_type, field) or {}
|
|
408
|
+
field_defs = {}
|
|
409
|
+
|
|
410
|
+
if 'schema' in ref_info:
|
|
411
|
+
field_defs['schema'] = ref_info.pop('schema', None)
|
|
412
|
+
|
|
413
|
+
return type_info, ref_info, field_defs
|
|
414
|
+
|
|
415
|
+
@classmethod
|
|
416
|
+
def _extract_and_filter_metadata(cls, field: Field, locale: Any):
|
|
417
|
+
"""Extract and filter metadata."""
|
|
418
|
+
_metadata = field.metadata.copy()
|
|
419
|
+
minimum = _metadata.pop('min', None)
|
|
420
|
+
maximum = _metadata.pop('max', None)
|
|
421
|
+
secret = _metadata.pop('secret', None)
|
|
422
|
+
custom_endpoint = _metadata.pop('endpoint', None)
|
|
423
|
+
|
|
424
|
+
field_required = field.metadata.get(
|
|
425
|
+
'required', False
|
|
426
|
+
) or field.metadata.get('primary', False)
|
|
427
|
+
|
|
428
|
+
ui_objects = {
|
|
429
|
+
k.replace('_', ':'): v for k, v in _metadata.items() if k.startswith('ui_')
|
|
430
|
+
}
|
|
431
|
+
schema_extra = _metadata.pop('schema_extra', {})
|
|
432
|
+
|
|
433
|
+
meta_description = cls._get_metadata(
|
|
434
|
+
cls, field, key='description', locale=locale
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
_metadata,
|
|
439
|
+
minimum,
|
|
440
|
+
maximum,
|
|
441
|
+
secret,
|
|
442
|
+
custom_endpoint,
|
|
443
|
+
field_required,
|
|
444
|
+
ui_objects,
|
|
445
|
+
schema_extra,
|
|
446
|
+
meta_description
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
@classmethod
|
|
450
|
+
def _apply_extra_metadata(cls, field_schema: dict, _metadata: dict):
|
|
451
|
+
"""Move non-rejected metadata keys into the 'attrs' dict."""
|
|
452
|
+
_rejected = [
|
|
453
|
+
'required', 'nullable', 'primary', 'readonly',
|
|
454
|
+
'label', 'validator', 'encoder', 'decoder',
|
|
455
|
+
'default_factory', 'type'
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
if _meta := {k: v for k, v in _metadata.items() if k not in _rejected}:
|
|
459
|
+
field_schema["attrs"] = {
|
|
460
|
+
**field_schema["attrs"],
|
|
461
|
+
**_meta
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def _apply_defaults_and_constraints(
|
|
466
|
+
cls,
|
|
467
|
+
field_schema: dict,
|
|
468
|
+
field: Field,
|
|
469
|
+
secret: Any,
|
|
470
|
+
type_info: str,
|
|
471
|
+
minimum: Any,
|
|
472
|
+
maximum: Any
|
|
473
|
+
):
|
|
474
|
+
"""Handle default values, secret fields, and min/max constraints."""
|
|
475
|
+
if field.default:
|
|
476
|
+
if not isinstance(field.default, _MISSING_TYPE) and not callable(field.default):
|
|
477
|
+
d = field.default
|
|
478
|
+
field_schema['default'] = f"fn:{d!r}" if is_callable(d) else f"{d!s}"
|
|
479
|
+
|
|
480
|
+
if secret is not None:
|
|
481
|
+
field_schema['secret'] = secret
|
|
482
|
+
|
|
483
|
+
# Handle length/size constraints
|
|
484
|
+
if type_info == 'string':
|
|
485
|
+
if minimum is not None:
|
|
486
|
+
field_schema['minLength'] = minimum
|
|
487
|
+
if maximum is not None:
|
|
488
|
+
field_schema['maxLength'] = maximum
|
|
489
|
+
else:
|
|
490
|
+
if minimum is not None:
|
|
491
|
+
field_schema['minimum'] = minimum
|
|
492
|
+
if maximum is not None:
|
|
493
|
+
field_schema['maximum'] = maximum
|
|
494
|
+
|
|
495
|
+
@classmethod
|
|
496
|
+
def _process_field_schema(
|
|
497
|
+
cls,
|
|
498
|
+
name: str,
|
|
499
|
+
field: Field,
|
|
500
|
+
locale: Any,
|
|
501
|
+
title: str
|
|
502
|
+
) -> tuple:
|
|
503
|
+
"""Process the schema for a single field."""
|
|
504
|
+
# Get the field type and description.
|
|
505
|
+
|
|
506
|
+
type_info, ref_info, field_defs = cls._extract_field_basics(name, field, title)
|
|
507
|
+
|
|
508
|
+
# Extract and handle metadata
|
|
509
|
+
(
|
|
510
|
+
_metadata,
|
|
511
|
+
minimum,
|
|
512
|
+
maximum,
|
|
513
|
+
secret,
|
|
514
|
+
custom_endpoint,
|
|
515
|
+
field_required,
|
|
516
|
+
ui_objects,
|
|
517
|
+
schema_extra,
|
|
518
|
+
meta_description
|
|
519
|
+
) = cls._extract_and_filter_metadata(
|
|
520
|
+
field, locale
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if 'schema' in ref_info:
|
|
524
|
+
field_defs['schema'] = ref_info.pop('schema', None)
|
|
525
|
+
|
|
526
|
+
# Build the basic field schema
|
|
527
|
+
field_schema = cls._get_field_schema(
|
|
528
|
+
cls,
|
|
529
|
+
type_info,
|
|
530
|
+
field,
|
|
531
|
+
description=meta_description,
|
|
532
|
+
locale=locale,
|
|
533
|
+
**ui_objects,
|
|
534
|
+
**schema_extra,
|
|
535
|
+
**ref_info
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Handle primary/required keys
|
|
539
|
+
if field.metadata.get('primary', False) is True:
|
|
540
|
+
field_schema["primary_key"] = True
|
|
541
|
+
if field_required:
|
|
542
|
+
field_schema["required"] = True
|
|
543
|
+
|
|
544
|
+
# Add label and description if available
|
|
545
|
+
label = cls._get_metadata(cls, field, 'label', locale=locale)
|
|
546
|
+
if label:
|
|
547
|
+
field_schema["label"] = label
|
|
548
|
+
if meta_description:
|
|
549
|
+
field_schema["description"] = meta_description
|
|
550
|
+
|
|
551
|
+
# Add custom endpoint
|
|
552
|
+
if custom_endpoint:
|
|
553
|
+
field_schema["endpoint"] = custom_endpoint
|
|
554
|
+
|
|
555
|
+
# Handle write_only, pattern, visible attributes
|
|
556
|
+
if 'write_only' in field.metadata:
|
|
557
|
+
field_schema["writeOnly"] = _metadata.pop('write_only', False)
|
|
558
|
+
|
|
559
|
+
if 'pattern' in field.metadata:
|
|
560
|
+
field_schema["attrs"]["pattern"] = _metadata.pop('pattern')
|
|
561
|
+
|
|
562
|
+
if field.repr is False:
|
|
563
|
+
field_schema["attrs"]["visible"] = False
|
|
564
|
+
|
|
565
|
+
# Remove some rejected keys and move others into attrs
|
|
566
|
+
cls._apply_extra_metadata(field_schema, _metadata)
|
|
567
|
+
|
|
568
|
+
# Handle default, secret, and constraints
|
|
569
|
+
cls._apply_defaults_and_constraints(
|
|
570
|
+
field_schema,
|
|
571
|
+
field,
|
|
572
|
+
secret,
|
|
573
|
+
type_info,
|
|
574
|
+
minimum,
|
|
575
|
+
maximum
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
return field_schema, field_defs, field_required
|
|
579
|
+
|
|
580
|
+
@classmethod
|
|
581
|
+
def schema(cls, as_dict=False, locale: Any = None):
|
|
582
|
+
"""
|
|
583
|
+
Convert the Model to a JSON-Schema representation.
|
|
584
|
+
|
|
585
|
+
This method generates a JSON-Schema that describes the structure and constraints
|
|
586
|
+
of the Model. It includes information about fields, their types,
|
|
587
|
+
validation rules, and other metadata.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
as_dict (bool, optional): If True,
|
|
591
|
+
returns the schema as a Python dictionary.
|
|
592
|
+
If False, returns the schema as a JSON-encoded string.
|
|
593
|
+
Defaults to False.
|
|
594
|
+
locale (Any, optional):
|
|
595
|
+
The locale to use for internationalization of schema
|
|
596
|
+
elements like descriptions and labels. Defaults to None.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Union[dict, str]:
|
|
600
|
+
The JSON-Schema representation of the Model. If as_dict is True,
|
|
601
|
+
returns a Python dictionary. Otherwise, returns a JSON-encoded string.
|
|
602
|
+
|
|
603
|
+
Note:
|
|
604
|
+
This method caches the computed schema in the __computed_schema__ attribute
|
|
605
|
+
of the class for subsequent calls.
|
|
606
|
+
"""
|
|
607
|
+
# Check if schema is already computed and cached.
|
|
608
|
+
if hasattr(cls, '__computed_schema__'):
|
|
609
|
+
return cls.__computed_schema__ if as_dict else json_encoder(
|
|
610
|
+
cls.__computed_schema__
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Build basic schema attributes (title, description, display_name, etc.)
|
|
614
|
+
title, description, display_name, table, endpoint, schema = cls._build_schema_basics(locale) # pylint: disable=C0301 # noqa
|
|
615
|
+
settings = cls._build_settings(locale)
|
|
616
|
+
endpoint_kwargs = {"endpoint": endpoint} if endpoint else {}
|
|
617
|
+
|
|
618
|
+
# Build the fields part of the schema.
|
|
619
|
+
fields, required, defs = cls._build_fields(title, locale)
|
|
620
|
+
|
|
621
|
+
base_schema = {
|
|
622
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
623
|
+
"$id": f"/schemas/{table}",
|
|
624
|
+
**endpoint_kwargs,
|
|
625
|
+
**settings,
|
|
626
|
+
"additionalProperties": cls.Meta.strict,
|
|
627
|
+
"title": title,
|
|
628
|
+
"description": description,
|
|
629
|
+
"type": "object",
|
|
630
|
+
"table": table,
|
|
631
|
+
"schema": schema,
|
|
632
|
+
"properties": fields,
|
|
633
|
+
"required": required,
|
|
634
|
+
"display_name": display_name,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if defs:
|
|
638
|
+
base_schema["$defs"] = defs
|
|
639
|
+
|
|
640
|
+
# Cache the computed schema for subsequent calls
|
|
641
|
+
cls.__computed_schema__ = base_schema
|
|
642
|
+
|
|
643
|
+
return base_schema if as_dict else json_encoder(base_schema)
|
|
644
|
+
|
|
645
|
+
def as_schema(self, top_level: bool = True) -> dict:
|
|
646
|
+
"""as_schema.
|
|
647
|
+
Convert the Model instance to a JSON-LD schema representation.
|
|
648
|
+
Args:
|
|
649
|
+
top_level (bool, optional): If True, adds the @context to the schema.
|
|
650
|
+
Returns:
|
|
651
|
+
dict: JSON-LD schema representation of the Model instance.
|
|
652
|
+
"""
|
|
653
|
+
data = {}
|
|
654
|
+
# If top_level, add @context
|
|
655
|
+
if top_level:
|
|
656
|
+
data["@context"] = "https://schema.org/"
|
|
657
|
+
|
|
658
|
+
# Determine the schema @type
|
|
659
|
+
schema_type = getattr(self.Meta, 'schema_type', self.__class__.__name__)
|
|
660
|
+
data["@type"] = schema_type
|
|
661
|
+
|
|
662
|
+
for field_name, field_obj in self.__columns__.items():
|
|
663
|
+
# Skip internal or error fields
|
|
664
|
+
if field_name.startswith('__') or field_name == '__errors__':
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
value = getattr(self, field_name)
|
|
668
|
+
if isinstance(value, ModelMixin):
|
|
669
|
+
data[field_name] = value.as_schema(top_level=False)
|
|
670
|
+
else:
|
|
671
|
+
data[field_name] = value
|
|
672
|
+
|
|
673
|
+
return data
|
|
674
|
+
|
|
675
|
+
@classmethod
|
|
676
|
+
def make_model(cls, name: str, schema: str = "public", fields: list = None):
|
|
677
|
+
parent = inspect.getmro(cls)
|
|
678
|
+
obj = make_dataclass(name, fields, bases=(parent[0],))
|
|
679
|
+
m = Meta()
|
|
680
|
+
m.name = name
|
|
681
|
+
m.schema = schema
|
|
682
|
+
obj.Meta = m
|
|
683
|
+
return obj
|
|
684
|
+
|
|
685
|
+
@classmethod
|
|
686
|
+
def from_json(cls, obj: str, **kwargs) -> dataclass:
|
|
687
|
+
try:
|
|
688
|
+
decoder = cls.__encoder__(**kwargs)
|
|
689
|
+
decoded = decoder.loads(obj)
|
|
690
|
+
return cls(**decoded)
|
|
691
|
+
except ValueError as e:
|
|
692
|
+
raise RuntimeError(
|
|
693
|
+
"DataModel: Invalid string (JSON) data for decoding: {e}"
|
|
694
|
+
) from e
|
|
695
|
+
|
|
696
|
+
@classmethod
|
|
697
|
+
def from_dict(cls, obj: dict) -> dataclass:
|
|
698
|
+
try:
|
|
699
|
+
return cls(**obj)
|
|
700
|
+
except ValueError as e:
|
|
701
|
+
raise RuntimeError(
|
|
702
|
+
"DataModel: Invalid Dictionary data for decoding: {e}"
|
|
703
|
+
) from e
|
|
704
|
+
|
|
705
|
+
@classmethod
|
|
706
|
+
def model(cls, dialect: str = "json", **kwargs) -> Any:
|
|
707
|
+
"""model.
|
|
708
|
+
|
|
709
|
+
Return the json-version of current Model.
|
|
710
|
+
Returns:
|
|
711
|
+
str: string (json) version of model.
|
|
712
|
+
"""
|
|
713
|
+
if hasattr(cls, '__computed_model__'):
|
|
714
|
+
return cls.__computed_model__
|
|
715
|
+
result = None
|
|
716
|
+
clsname = cls.__name__
|
|
717
|
+
schema = cls.Meta.schema
|
|
718
|
+
table = cls.Meta.name or clsname.lower()
|
|
719
|
+
columns = cls.columns(cls).items()
|
|
720
|
+
if dialect == 'json':
|
|
721
|
+
cols = {}
|
|
722
|
+
for _, field in columns:
|
|
723
|
+
key = field.name
|
|
724
|
+
_type = field.type
|
|
725
|
+
if _type.__module__ == 'typing':
|
|
726
|
+
# TODO: discover real value of typing
|
|
727
|
+
if _type._name == 'List':
|
|
728
|
+
t = 'array'
|
|
729
|
+
elif _type._name == 'Dict':
|
|
730
|
+
t = 'object'
|
|
731
|
+
else:
|
|
732
|
+
try:
|
|
733
|
+
t = _type.__args__[0]
|
|
734
|
+
t = t.__name__
|
|
735
|
+
except (AttributeError, ValueError):
|
|
736
|
+
t = 'object'
|
|
737
|
+
else:
|
|
738
|
+
try:
|
|
739
|
+
t = JSON_TYPES[_type]
|
|
740
|
+
except KeyError:
|
|
741
|
+
t = 'object'
|
|
742
|
+
cols[key] = {"name": key, "type": t}
|
|
743
|
+
doc = {
|
|
744
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
745
|
+
"$id": f"/schemas/{table}",
|
|
746
|
+
"name": clsname,
|
|
747
|
+
"description": cls.__doc__.strip("\n").strip(),
|
|
748
|
+
"additionalProperties": False,
|
|
749
|
+
"table": table,
|
|
750
|
+
"schema": schema,
|
|
751
|
+
"type": "object",
|
|
752
|
+
"properties": cols,
|
|
753
|
+
}
|
|
754
|
+
encoder = cls.__encoder__(**kwargs)
|
|
755
|
+
result = encoder.dumps(doc, option=OPT_INDENT_2)
|
|
756
|
+
cls.__computed_model__ = result
|
|
757
|
+
return result
|
|
758
|
+
|
|
759
|
+
@classmethod
|
|
760
|
+
def sample(cls) -> dict:
|
|
761
|
+
"""sample.
|
|
762
|
+
|
|
763
|
+
Get a dict (JSON) sample of this datamodel, based on default values.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
dict: _description_
|
|
767
|
+
"""
|
|
768
|
+
columns = cls.get_columns().items()
|
|
769
|
+
_fields = {}
|
|
770
|
+
required = []
|
|
771
|
+
for name, f in columns:
|
|
772
|
+
if f.repr is False:
|
|
773
|
+
continue
|
|
774
|
+
_fields[name] = f.default
|
|
775
|
+
try:
|
|
776
|
+
if f.metadata["required"] is True:
|
|
777
|
+
required.append(name)
|
|
778
|
+
except KeyError:
|
|
779
|
+
pass
|
|
780
|
+
return {
|
|
781
|
+
"properties": _fields,
|
|
782
|
+
"required": required
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
@classmethod
|
|
786
|
+
def from_jsonld(cls, data: Dict[str, Any]) -> "ModelMixin":
|
|
787
|
+
"""
|
|
788
|
+
Create a model instance from a JSON-LD dictionary.
|
|
789
|
+
|
|
790
|
+
Ignores @context and @type; attempts to parse all other top-level fields
|
|
791
|
+
into the model’s constructor. If the JSON-LD has nested objects that
|
|
792
|
+
correspond to other BaseModel fields, you may need additional logic
|
|
793
|
+
to instantiate sub-models.
|
|
794
|
+
"""
|
|
795
|
+
if not isinstance(data, dict):
|
|
796
|
+
raise ValueError("JSON-LD input must be a dictionary.")
|
|
797
|
+
# If present, remove the JSON-LD keys that are not actual model fields
|
|
798
|
+
data.pop("@context", None)
|
|
799
|
+
data.pop("@type", None)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class Model(ModelMixin, metaclass=ModelMeta):
|
|
803
|
+
"""Model.
|
|
804
|
+
|
|
805
|
+
Basic dataclass-based Model.
|
|
806
|
+
"""
|
|
807
|
+
Meta = Meta
|
|
808
|
+
|
|
809
|
+
def __post_init__(self) -> None:
|
|
810
|
+
"""
|
|
811
|
+
Post init method.
|
|
812
|
+
Useful for making Post-validations of Model.
|
|
813
|
+
"""
|
|
814
|
+
self.__initialised__ = True
|