mdmodels 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdmodels/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) 2024 Jan Range
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ # #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ # #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ # -----------------------------------------------------------------------------
22
+ import nest_asyncio
23
+ from mdmodels_core import Templates # noqa
24
+
25
+ from .datamodel import DataModel
26
+
27
+
28
+ def patch_nest_asyncio():
29
+ nest_asyncio.apply()
30
+
31
+
32
+ __all__ = [
33
+ "DataModel",
34
+ "Templates",
35
+ ]
@@ -0,0 +1,130 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) 2024 Jan Range
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ # #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ # #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ # -----------------------------------------------------------------------------
22
+ import builtins
23
+ from typing import Union, get_origin, get_args, Annotated
24
+ import warnings
25
+
26
+ import forge
27
+
28
+ from forge import FParameter, sign, FSignature
29
+
30
+ from mdmodels import DataModel
31
+ from mdmodels.units.unit_definition import UnitDefinition, BaseUnit
32
+ from mdmodels.utils import extract_dtype
33
+
34
+
35
+ def apply_adder_methods(cls: type[DataModel]):
36
+ """
37
+ Apply adder methods to the given DataModel class.
38
+
39
+ Args:
40
+ cls (type[DataModel]): The DataModel class to which adder methods will be applied.
41
+ """
42
+ for name, field in cls.model_fields.items():
43
+ if get_origin(field.annotation) is not list:
44
+ continue
45
+
46
+ method_name = f"add_to_{name}"
47
+ underlying_type = get_args(field.annotation)
48
+
49
+ if len(underlying_type) != 1 or get_origin(underlying_type[0]) is Union:
50
+ warnings.warn(
51
+ f"Only one type is supported for adder methods. {cls.__name__}.{name} has multiple types. Skipping.",
52
+ )
53
+ continue
54
+ else:
55
+ underlying_type = underlying_type[0]
56
+
57
+ if underlying_type in [UnitDefinition, BaseUnit] or is_builtin_type(
58
+ underlying_type
59
+ ):
60
+ continue
61
+
62
+ add_method = _create_add_method(underlying_type, name)
63
+ setattr(cls, method_name, add_method)
64
+
65
+
66
+ def is_builtin_type(obj):
67
+ """
68
+ Check if the given object is a built-in type.
69
+
70
+ Args:
71
+ obj: The object to check.
72
+
73
+ Returns:
74
+ bool: True if the object is a built-in type, False otherwise.
75
+ """
76
+ return obj.__name__ in dir(builtins)
77
+
78
+
79
+ def _create_add_method(
80
+ coll_cls: type[DataModel],
81
+ field: str,
82
+ ):
83
+ """
84
+ Create an adder method for the given collection class and field.
85
+
86
+ Args:
87
+ coll_cls (type[DataModel]): The collection class for which the adder method is created.
88
+ field (str): The field to which the adder method will be applied.
89
+
90
+ Returns:
91
+ function: The created adder method.
92
+ """
93
+
94
+ @sign(*_create_signature(coll_cls))
95
+ def add_method(self, **kwargs):
96
+ coll = getattr(self, field)
97
+ coll.append(coll_cls(**kwargs))
98
+ return coll[-1]
99
+
100
+ return add_method
101
+
102
+
103
+ def _create_signature(coll_cls: type[DataModel]):
104
+ """
105
+ Create a signature for the adder method.
106
+
107
+ Args:
108
+ coll_cls (type[DataModel]): The collection class for which the signature is created.
109
+
110
+ Returns:
111
+ FSignature: The created signature.
112
+ """
113
+ if get_origin(coll_cls) is Annotated:
114
+ coll_cls = extract_dtype(coll_cls)
115
+
116
+ annotations = coll_cls.__annotations__
117
+ parameters = [
118
+ FParameter(
119
+ kind=FParameter.KEYWORD_ONLY,
120
+ name=name,
121
+ type=annotations[name],
122
+ default=None if get_origin(annotations[name]) is not list else [],
123
+ )
124
+ for name in annotations
125
+ if name != "return"
126
+ ]
127
+
128
+ return FSignature(
129
+ [forge.self] + parameters,
130
+ )
mdmodels/create.py ADDED
@@ -0,0 +1,498 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) 2024 Jan Range
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ # #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ # #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ # -----------------------------------------------------------------------------
22
+
23
+ import copy
24
+ import pathlib
25
+ from enum import Enum
26
+ from functools import partial
27
+ from typing import Any, Annotated, ForwardRef, Union
28
+
29
+ import httpx
30
+ import validators
31
+ from mdmodels_core import DataModel as RSDataModel # type: ignore
32
+ from pydantic import BeforeValidator
33
+ from pydantic_core.core_schema import ValidationInfo
34
+ from pydantic_xml import RootXmlModel, create_model, attr, element, wrapped
35
+
36
+ from mdmodels.adder_method import apply_adder_methods
37
+ from mdmodels.datamodel import DataModel
38
+ from mdmodels.library import Library
39
+ from mdmodels.path import PathFactory
40
+ from mdmodels.reference import ReferenceContext
41
+ from mdmodels.units.annotation import UnitDefinitionAnnot
42
+ from mdmodels.utils import extract_option
43
+
44
+ # Mapping of string type names to Python units
45
+ TYPE_MAPPING = {
46
+ "string": str,
47
+ "integer": int,
48
+ "float": float,
49
+ "boolean": bool,
50
+ "number": float,
51
+ "date": str,
52
+ "bytes": bytes,
53
+ }
54
+
55
+
56
+ class StringElement(RootXmlModel):
57
+ root: str
58
+
59
+
60
+ class FloatElement(RootXmlModel):
61
+ root: float
62
+
63
+
64
+ class BooleanElement(RootXmlModel):
65
+ root: bool
66
+
67
+
68
+ class IntegerElement(RootXmlModel):
69
+ root: int
70
+
71
+
72
+ class BytesElement(RootXmlModel):
73
+ root: bytes
74
+
75
+
76
+ BASIC_TYPE_ELEMENTS = {
77
+ str: StringElement,
78
+ float: FloatElement,
79
+ bool: BooleanElement,
80
+ int: IntegerElement,
81
+ bytes: BytesElement,
82
+ }
83
+
84
+
85
+ def build_module(
86
+ path: pathlib.Path | str | None = None,
87
+ data_model: RSDataModel | None = None,
88
+ ) -> Library:
89
+ """
90
+ Create a data model module from a markdown file.
91
+
92
+ Args:
93
+ path (pathlib.Path | str): Path to the markdown file.
94
+ data_model (RSDataModel | None): The data model. If None, it will be initialized from the path.
95
+
96
+ Returns:
97
+ Library: A module containing the generated data model.
98
+ """
99
+
100
+ if data_model and path:
101
+ raise ValueError("Only one of 'path' or 'data_model' should be provided")
102
+
103
+ if data_model:
104
+ assert isinstance(data_model, RSDataModel), "data_model must be an RSDataModel"
105
+ dm = data_model
106
+ elif path:
107
+ dm = init_data_model(path)
108
+ else:
109
+ raise ValueError("Either 'path' or 'data_model' must be provided")
110
+
111
+ global path_factory
112
+ global references
113
+ global module
114
+
115
+ references = {}
116
+ path_factory = PathFactory(model=dm)
117
+ module = Library(rust_model=dm, path_factory=path_factory)
118
+
119
+ for rs_type in dm.model.objects:
120
+ if rs_type.name in module:
121
+ module[rs_type.name].__mdmodels__.path_factory = path_factory
122
+ continue
123
+
124
+ py_type = build_type(dm, rs_type, module)
125
+ py_type.__mdmodels__.path_factory = path_factory # type: ignore
126
+
127
+ module[rs_type.name] = py_type
128
+
129
+ for obj, reference in references.items():
130
+ module[obj].__mdmodels__.reference_paths += reference
131
+
132
+ module.resolve_target_primary_keys()
133
+
134
+ return module
135
+
136
+
137
+ def init_data_model(path):
138
+ """
139
+ Initialize the data model from a path or URL.
140
+
141
+ Args:
142
+ path (str | pathlib.Path): Path or URL to the markdown file.
143
+
144
+ Returns:
145
+ RSDataModel: The initialized data model.
146
+ """
147
+
148
+ if validators.url(path):
149
+ content = httpx.get(path).text
150
+ return RSDataModel.from_markdown_string(content)
151
+ else:
152
+ if isinstance(path, str):
153
+ path = pathlib.Path(path)
154
+
155
+ assert path.exists(), f"Path '{path}' does not exist"
156
+ return RSDataModel.from_markdown(str(path))
157
+
158
+
159
+ def build_type(
160
+ dm: RSDataModel,
161
+ rs_type,
162
+ py_types: dict,
163
+ ):
164
+ """
165
+ Build a Python type from a data model type.
166
+
167
+ Args:
168
+ dm (RSDataModel): The data model.
169
+ rs_type: The data model type.
170
+ py_types (dict): Dictionary of Python units.
171
+ """
172
+
173
+ forward_refs = []
174
+ attrs = {}
175
+
176
+ for attribute in rs_type.attributes:
177
+ params = {}
178
+ dtypes = []
179
+
180
+ for dtype in attribute.dtypes:
181
+ dtype = get_dtype(dtype, dm, py_types, rs_type.name)
182
+
183
+ if dtype.__name__ in py_types or hasattr(dtype, "__recursive__"):
184
+ module.add_cross_connection(
185
+ source_type=rs_type.name,
186
+ source_attr=attribute.name,
187
+ target_type=dtype.__name__,
188
+ is_array=attribute.is_array,
189
+ )
190
+
191
+ if hasattr(dtype, "__recursive__"):
192
+ dtype = ForwardRef(dtype.__name__)
193
+ forward_refs.append(dtype)
194
+
195
+ before_validator = partial(
196
+ _check_type_compliance,
197
+ cls=dtype, # type: ignore
198
+ py_types=py_types, # type: ignore
199
+ )
200
+
201
+ dtype = Annotated[dtype, BeforeValidator(before_validator)] # type: ignore
202
+
203
+ dtypes.append(dtype)
204
+
205
+ dtypes = _set_custom_tags(attribute, dtypes)
206
+
207
+ if len(dtypes) > 1:
208
+ dtype = Union[tuple(dtypes)] # type: ignore
209
+ elif len(dtypes) == 0:
210
+ raise ValueError(f"No data type found for attribute {attribute.name}")
211
+ else:
212
+ dtype = dtypes[0]
213
+
214
+ if attribute.is_array:
215
+ dtype = list[dtype]
216
+
217
+ if description := attribute.docstring:
218
+ params["description"] = description
219
+
220
+ params["default"] = _get_default(attribute.default)
221
+
222
+ if not attribute.required and not attribute.is_array:
223
+ dtype = dtype | None # type: ignore
224
+ elif not attribute.required and attribute.is_array:
225
+ params["default_factory"] = list
226
+ del params["default"]
227
+
228
+ attrs[attribute.name] = _process_xml_attribute(attribute, dtype, params)
229
+
230
+ model = create_model(
231
+ rs_type.name,
232
+ __base__=DataModel,
233
+ **attrs,
234
+ )
235
+
236
+ for ref in forward_refs:
237
+ ref._evaluate(py_types, py_types, set())
238
+
239
+ _extract_references(rs_type)
240
+ apply_adder_methods(model)
241
+
242
+ return model
243
+
244
+
245
+ def _process_xml_attribute(attribute, dtype, params: dict) -> tuple[type, Any]:
246
+ """
247
+ Process an XML attribute and update the attrs dictionary.
248
+
249
+ Args:
250
+ attribute: The attribute to process.
251
+ dtype: The data type of the attribute.
252
+ params: Additional parameters for the attribute.
253
+
254
+ Returns:
255
+ Tuple[type, Any]: The processed attribute.
256
+ """
257
+ if attribute.xml.is_attr:
258
+ assert not _is_wrapped_xml(
259
+ attribute.xml.name
260
+ ), "Wrapped XML is not allowed to be an attribute"
261
+ return (
262
+ dtype,
263
+ attr(
264
+ name=attribute.xml.name,
265
+ **params,
266
+ ),
267
+ )
268
+ elif _is_wrapped_xml(attribute.xml.name):
269
+ *path, name = attribute.xml.name.split("/")
270
+ return (dtype, wrapped("/".join(path), element(tag=name, **params)))
271
+ elif _is_multiple_xml(attribute.xml.name):
272
+ return (dtype, element(**params))
273
+ else:
274
+ return (dtype, element(tag=attribute.xml.name, **params))
275
+
276
+
277
+ def _set_custom_tags(attribute, dtypes):
278
+ """
279
+ Set custom XML tags for the given attribute and data types.
280
+
281
+ This function processes the XML name of the attribute to determine if it
282
+ contains multiple or wrapped XML elements. It then assigns custom XML tags
283
+ to the data types based on the parsed names.
284
+
285
+ Args:
286
+ attribute: The attribute containing the XML name to process.
287
+ dtypes: A list of data types to which custom XML tags will be assigned.
288
+
289
+ Returns:
290
+ list: A list of data types with assigned custom XML tags.
291
+ """
292
+ new_dtypes = []
293
+ if _is_multiple_xml(attribute.xml.name):
294
+ paths = [p.strip() for p in attribute.xml.name.split(",")]
295
+
296
+ if _is_wrapped_xml(attribute.xml.name):
297
+ names = [p.split("/")[-1] for p in paths]
298
+ else:
299
+ names = paths
300
+
301
+ for dtype, name in zip(dtypes, names):
302
+ if dtype in BASIC_TYPE_ELEMENTS:
303
+ dtype = copy.copy(BASIC_TYPE_ELEMENTS[dtype])
304
+ dtype.__xml_tag__ = name
305
+ new_dtypes.append(dtype)
306
+ else:
307
+ dtype = copy.copy(dtype)
308
+ dtype.__xml_tag__ = name
309
+ new_dtypes.append(dtype)
310
+ else:
311
+ return dtypes
312
+
313
+ return new_dtypes
314
+
315
+
316
+ def _is_multiple_xml(name: str):
317
+ """
318
+ Check if the XML name contains multiple elements.
319
+
320
+ Args:
321
+ name (str): The XML name to check.
322
+
323
+ Returns:
324
+ bool: True if the name contains multiple elements, False otherwise.
325
+ """
326
+ return len(name.split(",")) > 1
327
+
328
+
329
+ def _is_wrapped_xml(name: str):
330
+ """
331
+ Check if the XML name is wrapped, indicating a nested structure.
332
+
333
+ Args:
334
+ name (str): The XML name to check.
335
+
336
+ Returns:
337
+ bool: True if the name is wrapped, False otherwise.
338
+ """
339
+ return len(name.split("/")) > 1
340
+
341
+
342
+ def _extract_references(obj):
343
+ """Extract attribute references from an object.
344
+
345
+ References are used for cross-referencing objects in the data model.
346
+
347
+ Args:
348
+ obj: The object to extract references from.
349
+
350
+ Returns:
351
+ List[str]: A list of references.
352
+ """
353
+
354
+ for attribute in obj.attributes:
355
+ if ref := extract_option(attribute, "references"):
356
+ _create_ref_context(attribute, obj, ref)
357
+
358
+ # Add cross connection for DB schemes
359
+ tbl, col = path_factory.get_attr_type_by_dot(ref)
360
+ module.add_cross_connection(
361
+ source_type=obj.name,
362
+ source_attr=attribute.name,
363
+ target_type=tbl,
364
+ target_attr=col,
365
+ is_identifier=True,
366
+ )
367
+
368
+ return references
369
+
370
+
371
+ def _create_ref_context(attr, obj, ref: str):
372
+ """
373
+ Process a reference attribute and update the references dictionary.
374
+
375
+ Args:
376
+ attr: The attribute containing the reference.
377
+ obj: The object to which the attribute belongs.
378
+ ref (str): The reference string in dot notation.
379
+
380
+ """
381
+ root = ref.split(".")[0]
382
+ target_path = path_factory.dot_to_json_path(ref)
383
+ source_paths = path_factory.get_type_paths(root, obj.name, attr.name)
384
+ for source_path in source_paths:
385
+ ctx = ReferenceContext(
386
+ source_path=source_path,
387
+ target_path=target_path,
388
+ )
389
+
390
+ if root not in references:
391
+ references[root] = []
392
+
393
+ references[root].append(ctx)
394
+
395
+
396
+ def _get_default(default):
397
+ """
398
+ Get the default value from a given default object.
399
+
400
+ Args:
401
+ default: The default object to extract the value from.
402
+
403
+ Returns:
404
+ The extracted default value, which can be a string, boolean, integer, float, or None.
405
+ """
406
+ if default is None:
407
+ return None
408
+
409
+ if default.is_string():
410
+ return default.as_string().replace('"', "")
411
+ elif default.is_boolean():
412
+ return default.as_boolean()
413
+ elif default.is_integer():
414
+ return default.as_integer()
415
+ elif default.is_float():
416
+ return default.as_float()
417
+
418
+
419
+ def get_dtype(
420
+ dtype: str,
421
+ dm: RSDataModel,
422
+ py_types: dict,
423
+ rs_type_name: str,
424
+ ):
425
+ """
426
+ Get the Python data type for an attribute.
427
+
428
+ Args:
429
+ dtype: The data type.
430
+ dm (RSDataModel): The data model.
431
+ py_types (dict): Dictionary of Python units.
432
+ rs_type_name (str): The name of the data model type.
433
+ Returns:
434
+ type: The Python data type.
435
+ """
436
+
437
+ if dtype == rs_type_name:
438
+ return type(rs_type_name, (DataModel,), {"__recursive__": True})
439
+
440
+ if dtype in TYPE_MAPPING:
441
+ return TYPE_MAPPING[dtype]
442
+ elif dtype == "UnitDefinition":
443
+ return UnitDefinitionAnnot
444
+ elif dtype in py_types:
445
+ return py_types[dtype]
446
+ elif sub_obj := next((o for o in dm.model.objects if o.name == dtype), None):
447
+ py_types[dtype] = build_type(dm, sub_obj, py_types)
448
+ return py_types[dtype]
449
+ elif enum_obj := next((o for o in dm.model.enums if o.name == dtype), None):
450
+ py_types[dtype] = build_enum(enum_obj, py_types)
451
+ return py_types[dtype]
452
+ else:
453
+ raise ValueError(f"Unknown type {dtype}")
454
+
455
+
456
+ def build_enum(enum_obj, py_types: dict):
457
+ """
458
+ Create a Python Enum from a data model Enum object.
459
+
460
+ Args:
461
+ enum_obj: The Enum object.
462
+ py_types (dict): Dictionary of Python types/enums.
463
+
464
+ Returns:
465
+ Enum: The created Python Enum.
466
+ """
467
+
468
+ if enum_obj.name in py_types:
469
+ return py_types[enum_obj.name]
470
+
471
+ return Enum(enum_obj.name, enum_obj.mappings)
472
+
473
+
474
+ def _check_type_compliance(
475
+ value: Any,
476
+ info: ValidationInfo,
477
+ cls: type[DataModel] | ForwardRef,
478
+ py_types: Library,
479
+ ):
480
+ """
481
+ Check if the value complies with the expected data model type.
482
+
483
+ Args:
484
+ value (Any): The value to check.
485
+ info (ValidationInfo): Validation information.
486
+ cls (type[DataModel]): The expected data model class.
487
+
488
+ Returns:
489
+ Any: The validated value.
490
+ """
491
+ if not hasattr(value, "model_fields"):
492
+ return value
493
+ if isinstance(cls, ForwardRef):
494
+ cls = cls._evaluate(py_types, py_types, set()) # type: ignore
495
+ if type(value).__name__ == cls.__name__: # type: ignore
496
+ return cls(**value.model_dump()) # type: ignore
497
+
498
+ return value