python-datamodel 0.8.13__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/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DataModels.
3
+
4
+ DataModel is a reimplementation of dataclasses with true inheritance and composition.
5
+ """
6
+ from datamodel.fields import Field, Column, fields
7
+ from .models import Model
8
+ from .base import BaseModel
9
+ from .version import (
10
+ __title__, __description__, __version__, __author__, __author_email__
11
+ )
12
+
13
+ __all__ = ('fields', 'Field', 'Column', 'Model', 'BaseModel', )
datamodel/abstract.py ADDED
@@ -0,0 +1,337 @@
1
+ import logging
2
+ from typing import Optional, Any, List, Dict, get_args, get_origin
3
+ from types import GenericAlias
4
+ from collections import OrderedDict
5
+ from collections.abc import Callable
6
+ import types
7
+ from inspect import isclass
8
+ from dataclasses import dataclass
9
+ from .parsers.json import JSONContent
10
+ from .converters import encoders, parse_basic, parse_type
11
+ from .fields import Field
12
+ from .functions import (
13
+ is_dataclass,
14
+ is_primitive
15
+ )
16
+
17
+ class Meta:
18
+ """
19
+ Metadata information about Model.
20
+
21
+ Attributes:
22
+ name: str = "" name of the model
23
+ description: str = "" description of the model
24
+ schema: str = "" schema of the model (optional)
25
+ frozen: bool = False if the model (dataclass) is read-only (frozen state)
26
+ strict: bool = True if the model (dataclass) should raise an error on invalid data.
27
+ remove_null: bool = True if the model should remove null values from the data.
28
+ validate_assignment: bool = True if the model should validate during assignment.
29
+ """
30
+ name: str = ""
31
+ description: str = ""
32
+ schema: str = ""
33
+ frozen: bool = False
34
+ strict: bool = True
35
+ driver: str = None
36
+ credentials: dict = Optional[dict]
37
+ dsn: Optional[str] = None
38
+ datasource: Optional[str] = None
39
+ connection: Optional[Callable] = None
40
+ remove_nulls: bool = False
41
+ endpoint: str
42
+ extra: str = 'forbid' # could be 'allow', 'ignore', or 'forbid'
43
+ validate_assignment: bool = False
44
+ as_objects: bool = False
45
+ no_nesting: bool = False
46
+ alias_function: Optional[Callable] = None
47
+
48
+
49
+ def set_connection(cls, conn: Callable):
50
+ cls.connection = conn
51
+
52
+
53
+ def _dc_method_setattr_(
54
+ self,
55
+ name: str,
56
+ value: Any,
57
+ ) -> None:
58
+ """
59
+ _dc_method_setattr_.
60
+ - Method for overwrite the "setattr" on Dataclasses.
61
+ """
62
+ # Initialize __values__ if it doesn't exist
63
+ if not hasattr(self, '__values__'):
64
+ object.__setattr__(self, '__values__', {})
65
+
66
+ # If the attribute name is already a known field, proceed normally
67
+ if name in self.__fields__:
68
+ # Only store the initial value:
69
+ if name not in self.__values__:
70
+ # Store the initial value in __values__
71
+ self.__values__[name] = value
72
+ if self.Meta.validate_assignment:
73
+ try:
74
+ # re-apply the parse/validation of this field:
75
+ field_category = self.__field_types__.get(name, 'complex')
76
+ field_obj = self.__columns__[name]
77
+ _type = field_obj.type
78
+ _encoder = field_obj.metadata.get('encoder')
79
+ if field_category == 'primitive':
80
+ new_val = parse_basic(_type, value, _encoder)
81
+ elif field_category == 'typing':
82
+ new_val = parse_type(field_obj, _type, value, _encoder)
83
+ elif field_category in ('dataclass', 'class', ):
84
+ new_val = value
85
+ else:
86
+ new_val = parse_type(field_obj, _type, value, _encoder)
87
+ # Assign the new value to the field
88
+ value = new_val
89
+ except Exception as e:
90
+ # Raise or re-raise a TypeError or something meaningful
91
+ raise TypeError(
92
+ f"Cannot assign {value!r} to field {name!r}: {e}"
93
+ ) from e
94
+ object.__setattr__(self, name, value)
95
+ return
96
+
97
+ if self.Meta.frozen is True and name not in self.__fields__:
98
+ raise TypeError(
99
+ f"Cannot add New attribute {name} on {self.modelName}, "
100
+ "This DataClass is frozen (read-only class)"
101
+ )
102
+ else:
103
+ # try to dynamically add a field or store the attribute
104
+ value = None if callable(value) else value
105
+ object.__setattr__(self, name, value)
106
+ if name == '__values__':
107
+ return
108
+ if name not in self.__fields__:
109
+ if self.Meta.strict is True:
110
+ return False
111
+ # If it’s not a known field, consult self.Meta.extra
112
+ extra_policy = self.Meta.extra
113
+ if extra_policy == 'forbid':
114
+ raise TypeError(
115
+ f"Field {name!r} is not allowed on {self.modelName}"
116
+ )
117
+
118
+ if extra_policy == 'ignore':
119
+ # do nothing, skip silently
120
+ return
121
+ try:
122
+ # create a new Field on Model.
123
+ f = Field(required=False, default=value)
124
+ f.name = name
125
+ f.type = type(value)
126
+ self.__columns__[name] = f
127
+ self.__fields__.append(name)
128
+ setattr(self, name, value)
129
+ except Exception as err:
130
+ logging.exception(err, stack_info=True)
131
+ raise
132
+
133
+
134
+ class ModelMeta(type):
135
+ """ModelMeta.
136
+
137
+ Metaclass for DataModels, convert any Model into a dataclass-compatible object.
138
+ """
139
+ __columns__: Dict
140
+ __fields__: List
141
+ __field_types__: List
142
+ __aliases__: Dict
143
+
144
+ def __new__(cls, name, bases, attrs, **kwargs): # noqa
145
+ cols = OrderedDict()
146
+ strict = False
147
+ cls.__field_types__ = {}
148
+ cls.__typing_args__ = {}
149
+ cls.__aliases__ = {}
150
+ _types = {}
151
+ _typing_args = {}
152
+ aliases = {}
153
+
154
+ if "__annotations__" in attrs:
155
+ annotations = attrs.get('__annotations__', {})
156
+ try:
157
+ strict = attrs['Meta'].strict
158
+ except (TypeError, AttributeError, KeyError):
159
+ pass
160
+
161
+ @staticmethod
162
+ def _initialize_fields(attrs, annotations, strict):
163
+ cols = OrderedDict()
164
+ _types_local = {}
165
+ _typing_args = {}
166
+ aliases = {}
167
+ for field, _type in annotations.items():
168
+ if isinstance(_type, Field):
169
+ _type = _type.type
170
+ df = attrs.get(
171
+ field,
172
+ Field(type=_type, required=False, default=None)
173
+ )
174
+ if df is not None and isinstance(df, Field):
175
+ alias = df.metadata.get("alias", None)
176
+ if alias:
177
+ aliases[alias] = field
178
+ if not isinstance(df, Field):
179
+ df = Field(required=False, type=_type, default=df)
180
+ df.name = field
181
+ df.type = _type
182
+ try:
183
+ df._encoder_fn = encoders[_type]
184
+ except (TypeError, KeyError):
185
+ df._encoder_fn = None
186
+
187
+ # Cache reflection info so we DON’T need to call
188
+ # get_origin/get_args repeatedly:
189
+ origin = get_origin(_type)
190
+ args = get_args(_type)
191
+ _default = df.default
192
+ _is_dc = is_dataclass(_type)
193
+ _is_prim = is_primitive(_type)
194
+ _is_alias = isinstance(_type, GenericAlias)
195
+ _is_typing = hasattr(_type, '__module__') and _type.__module__ == 'typing' # noqa
196
+
197
+ # Store the type info in the field object:
198
+ df.is_dc = _is_dc
199
+ df.is_primitive = _is_prim
200
+ df.is_typing = _is_typing
201
+ df.origin = origin
202
+ df.args = args
203
+ df.type_args = getattr(_type, '__args__', None)
204
+
205
+ df._typeinfo_ = {
206
+ "default_callable": callable(_default)
207
+ }
208
+
209
+ # check type of field:
210
+ if _is_prim:
211
+ _type_category = 'primitive'
212
+ elif origin == type:
213
+ _type_category = 'typing'
214
+ elif _is_dc:
215
+ _type_category = 'dataclass'
216
+ elif _is_typing: # noqa
217
+ _type_category = 'typing'
218
+ elif isclass(_type):
219
+ _type_category = 'class'
220
+ elif _is_alias:
221
+ _type_category = 'typing'
222
+ else:
223
+ _type_category = 'complex'
224
+ _types_local[field] = _type_category
225
+ df._type_category = _type_category
226
+
227
+ # Store them in a dict keyed by field name:
228
+ _typing_args[field] = (origin, args)
229
+ # Assign the field object to the attrs so dataclass can pick it up
230
+ attrs[field] = df
231
+ cols[field] = df
232
+ return cols, _types_local, _typing_args, aliases
233
+
234
+ # Initialize the fields
235
+ cols, _types, _typing_args, aliases = _initialize_fields(
236
+ attrs, annotations, strict
237
+ )
238
+ else:
239
+ # if no __annotations__, cols is empty:
240
+ cols = OrderedDict()
241
+
242
+ _columns = cols.keys()
243
+ cls.__slots__ = tuple(_columns)
244
+
245
+ # Pop Meta before creating the class so we can assign it after
246
+ attr_meta = attrs.pop("Meta", None)
247
+ # Create the class
248
+ new_cls = super().__new__(cls, name, bases, attrs, **kwargs)
249
+
250
+ # Attach Meta class
251
+ new_cls.Meta = attr_meta or getattr(new_cls, "Meta", Meta)
252
+ new_cls.__dataclass_fields__ = cols
253
+ new_cls.__typing_args__ = _typing_args
254
+ if not new_cls.Meta:
255
+ new_cls.Meta = Meta
256
+ new_cls.Meta.set_connection = types.MethodType(
257
+ set_connection, new_cls.Meta
258
+ )
259
+ try:
260
+ frozen = new_cls.Meta.frozen
261
+ except AttributeError:
262
+ new_cls.Meta.frozen = False
263
+ frozen = False
264
+
265
+ # mix values from Meta to an existing Meta Class
266
+ new_cls.Meta.__annotations__ = Meta.__annotations__
267
+ for key, _ in Meta.__annotations__.items():
268
+ if not hasattr(new_cls.Meta, key):
269
+ try:
270
+ setattr(new_cls.Meta, key, None)
271
+ except AttributeError as e:
272
+ logging.warning(
273
+ f'Missing Meta Key: {key}, {e}'
274
+ )
275
+
276
+ # If there's a __model_init__ method, call it
277
+ try:
278
+ new_cls.__model_init__(
279
+ new_cls,
280
+ name,
281
+ attrs
282
+ )
283
+ except AttributeError:
284
+ pass
285
+
286
+ # Now that fields are in attrs, decorate the class as a dataclass
287
+ dc = dataclass(
288
+ unsafe_hash=strict,
289
+ repr=False,
290
+ init=True,
291
+ order=False,
292
+ eq=True,
293
+ frozen=frozen
294
+ )(new_cls)
295
+ # Set additional attributes:
296
+ dc.__columns__ = cols
297
+ dc.__fields__ = list(_columns)
298
+ dc.__values__ = {}
299
+ dc.__encoder__ = JSONContent
300
+ dc.__valid__ = False
301
+ dc.__errors__ = None
302
+ dc.__frozen__ = strict
303
+ dc.__initialised__ = False
304
+ dc.__field_types__ = _types
305
+ dc.__aliases__ = aliases
306
+ dc.__typing_args__ = _typing_args
307
+ dc.modelName = dc.__name__
308
+
309
+ # Override __setattr__ method
310
+ setattr(dc, "__setattr__", _dc_method_setattr_)
311
+ return dc
312
+
313
+ def __init__(cls, *args, **kwargs) -> None:
314
+ # Initialized Data Model = True
315
+ cls.__initialised__ = True
316
+ cls.__errors__ = None
317
+ super().__init__(*args, **kwargs)
318
+
319
+ def __call__(cls, *args, **kwargs):
320
+ # rename any kwargs that match an alias ONLY if there are aliases defined.
321
+ alias_func = getattr(cls.Meta, "alias_function", None)
322
+ if callable(alias_func):
323
+ new_kwargs = {}
324
+ for k, v in kwargs.items():
325
+ new_k = alias_func(k)
326
+ new_kwargs[new_k] = v
327
+ kwargs = new_kwargs
328
+ if cls.__aliases__:
329
+ new_kwargs = {}
330
+ for k, v in kwargs.items():
331
+ if k in cls.__aliases__:
332
+ real_field = cls.__aliases__[k]
333
+ new_kwargs[real_field] = v
334
+ else:
335
+ new_kwargs[k] = v
336
+ kwargs = new_kwargs
337
+ return super().__call__(*args, **kwargs)
@@ -0,0 +1,26 @@
1
+ # Aliases Functions:
2
+ import re
3
+
4
+ _RE_FIRST_CAP = re.compile(r"(.)([A-Z][a-z]+)")
5
+ _RE_ALL_CAPS = re.compile(r"([a-z0-9])([A-Z])")
6
+
7
+
8
+ def to_snakecase(name: str) -> str:
9
+ """
10
+ Convert a CamelCase or PascalCase string into snake_case.
11
+ Example: "EmailAddress" -> "email_address"
12
+ """
13
+ # Insert underscores before capital letters, then lower-case
14
+ s1 = _RE_FIRST_CAP.sub(r"\1_\2", name)
15
+ return _RE_ALL_CAPS.sub(r"\1_\2", s1).lower()
16
+
17
+
18
+ def to_pascalcase(s: str) -> str:
19
+ """
20
+ Convert a snake_case string into PascalCase.
21
+ Example:
22
+ store_id -> StoreId
23
+ email_address -> EmailAddress
24
+ """
25
+ parts = s.split("_")
26
+ return "".join(word.capitalize() for word in parts)
datamodel/base.py ADDED
@@ -0,0 +1,182 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, Dict
3
+ # Dataclass
4
+ from dataclasses import (
5
+ _FIELD,
6
+ )
7
+ from html import escape
8
+ from .converters import process_attributes, register_converter
9
+ from .fields import Field
10
+ from .exceptions import ValidationError
11
+ from .abstract import ModelMeta
12
+ from .models import ModelMixin
13
+
14
+
15
+ TYPE_CONVERTERS = {}
16
+
17
+ RendererFn = Callable[["BaseModel", bool], str]
18
+
19
+ HTML_RENDERERS: Dict[str, RendererFn] = {}
20
+
21
+ def register_renderer(schema_type: str):
22
+ """
23
+ Decorator to register a custom renderer function for a given schema_type.
24
+ """
25
+ def decorator(fn: RendererFn):
26
+ HTML_RENDERERS[schema_type] = fn
27
+ return fn
28
+ return decorator
29
+
30
+
31
+ class BaseModel(ModelMixin, metaclass=ModelMeta):
32
+ """
33
+ BaseModel.
34
+ Base Model for all DataModels.
35
+ """
36
+
37
+ def __post_init__(self) -> None:
38
+ """
39
+ Post init method.
40
+ Fill fields with function-factory or calling validations
41
+ """
42
+ # checking if an attribute is already a dataclass:
43
+ columns = list(self.__columns__.items())
44
+
45
+ if errors := process_attributes(self, columns):
46
+ if self.Meta.strict is True:
47
+ raise ValidationError(
48
+ f"""{self.modelName}: There are errors in Model. \
49
+ Hint: please check the "payload" attribute in the exception.""",
50
+ payload=errors
51
+ )
52
+ self.__errors__ = errors
53
+ object.__setattr__(self, "__valid__", False)
54
+ else:
55
+ object.__setattr__(self, "__valid__", True)
56
+
57
+ @classmethod
58
+ def register_converter(
59
+ cls,
60
+ target_type: Any,
61
+ func: Callable,
62
+ field_name: str = None
63
+ ):
64
+ key = (target_type, field_name) if field_name else target_type
65
+ register_converter(key, func)
66
+
67
+ @classmethod
68
+ def add_field(cls, name: str, value: Any = None) -> None:
69
+ if cls.Meta.strict is True:
70
+ raise TypeError(
71
+ f'Cannot create a new field {name} on a Strict Model.'
72
+ )
73
+ if name != '__errors__':
74
+ f = Field(required=False, default=value)
75
+ f.name = name
76
+ f.type = type(value)
77
+ f._field_type = _FIELD
78
+ cls.__columns__[name] = f
79
+ cls.__dataclass_fields__[name] = f
80
+
81
+ def create_field(self, name: str, value: Any) -> None:
82
+ """create_field.
83
+ create a new Field on Model (when strict is False).
84
+ Args:
85
+ name (str): name of the field
86
+ value (Any): value to be assigned.
87
+ Raises:
88
+ TypeError: when try to create a new field on an Strict Model.
89
+ """
90
+ if self.Meta.strict is True:
91
+ raise TypeError(
92
+ f'Cannot create a new field {name} on a Strict Model.'
93
+ )
94
+ if name != '__errors__':
95
+ f = Field(required=False, default=value)
96
+ f.name = name
97
+ f.type = type(value)
98
+ f._field_type = _FIELD
99
+ self.__columns__[name] = f
100
+ self.__dataclass_fields__[name] = f
101
+ setattr(self, name, value)
102
+
103
+ def set(self, name: str, value: Any) -> None:
104
+ """set.
105
+ Alias for Create Field.
106
+ Args:
107
+ name (str): name of the field
108
+ value (Any): value to be assigned.
109
+ """
110
+ if name in self.__columns__:
111
+ setattr(self, name, value)
112
+ elif name != '__errors__' and self.Meta.strict is False:
113
+ self.create_field(name, value)
114
+
115
+ def get_errors(self):
116
+ return self.__errors__
117
+
118
+ def to_html(self, top_level: bool = True) -> str:
119
+ """to_html.
120
+ Convert Model to HTML.
121
+
122
+ Args:
123
+ top_level (bool, optional): If True, adds the @context to the schema.
124
+ """
125
+ # 1) Determine the schema type from self.Meta or fallback to class name
126
+ schema_type = getattr(self.Meta, 'schema_type', self.__class__.__name__)
127
+
128
+ if schema_type in HTML_RENDERERS:
129
+ return HTML_RENDERERS[schema_type](self, top_level)
130
+
131
+ # 2) Container opening. For top-level objects, we specify:
132
+ # - vocab="https://schema.org/"
133
+ # - typeof="Recipe" (or other type)
134
+ # For nested objects, we might omit the 'vocab' attribute
135
+ # or rely on the parent's scope.
136
+ if top_level:
137
+ container_open = f'<div vocab="https://schema.org/" typeof="{escape(schema_type)}">'
138
+ else:
139
+ container_open = f'<div property="{escape(schema_type)}" typeof="{escape(schema_type)}">'
140
+
141
+ # We'll accumulate our HTML pieces here
142
+ pieces = [container_open]
143
+
144
+ # 3) Iterate over each field in this model
145
+ for field_name, value in self.__dict__.items():
146
+ # Skip internal or error fields
147
+ if field_name.startswith('_') or field_name == '__errors__':
148
+ continue
149
+
150
+ # Optionally skip if None
151
+ if value is None:
152
+ continue
153
+
154
+ # 4) If value is a nested model, convert it to HTML as well
155
+ if isinstance(value, BaseModel):
156
+ nested_html = value.to_html(False)
157
+ snippet = f'<div property="{escape(field_name)}">\n{nested_html}\n</div>'
158
+ pieces.append(snippet)
159
+
160
+ elif isinstance(value, list):
161
+ # We might iterate and produce multiple lines
162
+ for item in value:
163
+ if isinstance(item, BaseModel):
164
+ nested_html = item.to_html(False)
165
+ snippet = f'<div property="{escape(field_name)}">\n{nested_html}\n</div>'
166
+ else:
167
+ # If it's a simple scalar, just output a <span>
168
+ # e.g.: <span property="recipeIngredient">3 bananas</span>
169
+ val_escaped = escape(str(item))
170
+ snippet = f'<span property="{escape(field_name)}">{val_escaped}</span>'
171
+ pieces.append(snippet)
172
+ else:
173
+ # For simple scalars (str, int, etc.):
174
+ # We might choose <span> or <meta> based on type.
175
+ # We'll do something simple: a <span>
176
+ val_escaped = escape(str(value))
177
+ snippet = f'<span property="{escape(field_name)}">{val_escaped}</span>'
178
+ pieces.append(snippet)
179
+
180
+ # 4) Close the container
181
+ pieces.append('</div>')
182
+ return "\n".join(pieces)