ialdev-core 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.
- iad/core/__init__.py +9 -0
- iad/core/array.py +1961 -0
- iad/core/binary.py +377 -0
- iad/core/cache.py +903 -0
- iad/core/codetools.py +203 -0
- iad/core/datatools.py +671 -0
- iad/core/docs/locators.ipynb +754 -0
- iad/core/dotstyle.py +99 -0
- iad/core/env.py +271 -0
- iad/core/events.py +650 -0
- iad/core/filesproc.py +1046 -0
- iad/core/fnctools.py +390 -0
- iad/core/label.py +240 -0
- iad/core/logs.py +182 -0
- iad/core/nptools.py +449 -0
- iad/core/one_dark.puml +881 -0
- iad/core/param/__init__.py +17 -0
- iad/core/param/confargparse.py +55 -0
- iad/core/param/paramaze.py +339 -0
- iad/core/param/tbox.py +277 -0
- iad/core/paths.py +563 -0
- iad/core/pdtools.py +2570 -0
- iad/core/pydantools/__init__.py +5 -0
- iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
- iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
- iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
- iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
- iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
- iad/core/pydantools/models.py +560 -0
- iad/core/regexp.py +348 -0
- iad/core/short.py +308 -0
- iad/core/strings.py +635 -0
- iad/core/unc_panda.py +270 -0
- iad/core/units.py +58 -0
- iad/core/wrap.py +420 -0
- ialdev_core-0.1.0.dist-info/METADATA +73 -0
- ialdev_core-0.1.0.dist-info/RECORD +48 -0
- ialdev_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Union, Type, Callable, List, Literal, Iterable
|
|
8
|
+
|
|
9
|
+
import pydantic.v1 as pyd
|
|
10
|
+
|
|
11
|
+
from iad.core import logger, as_list, as_iter
|
|
12
|
+
from iad.core.param.tbox import TBox
|
|
13
|
+
from iad.io.format import FileFormat, CT, FormatHandler, MetaFormat
|
|
14
|
+
from iad.core.pydantools.fixed_pydantic_yaml import YamlModelMixin
|
|
15
|
+
|
|
16
|
+
PathT = Union[Path, str]
|
|
17
|
+
|
|
18
|
+
Integers = Union[int, list[int]]
|
|
19
|
+
Strings = Union[str, list[str]]
|
|
20
|
+
Scalar = Union[float, bool, str]
|
|
21
|
+
VarName = pyd.constr(regex=r'^[A-Za-z]\w*$')
|
|
22
|
+
Regex = pyd.constr(regex=r'.*[\{\}\+\*\?]+.*')
|
|
23
|
+
NumArgs = dict[VarName, Union[float, int, bool]]
|
|
24
|
+
ScalarArgs = dict[VarName, Scalar]
|
|
25
|
+
Desc = lambda s: pyd.Field(default=None, description=s, required=False)
|
|
26
|
+
|
|
27
|
+
_log = logger('models', level='WARNING')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def model_arguments(*, exclude: Union[str, List[str]] = None, config=None,
|
|
31
|
+
**pydantic_kwargs):
|
|
32
|
+
"""
|
|
33
|
+
Pydantic validate_arguments wrapper.
|
|
34
|
+
|
|
35
|
+
For more information about pydantic.validate_arguments check:
|
|
36
|
+
https://docs.pydantic.dev/1.10/usage/validation_decorator/
|
|
37
|
+
|
|
38
|
+
Adds exclusion functionality that allows the user to exclude fields from the model.
|
|
39
|
+
The excluded fields are passed to 'exclude' argument.
|
|
40
|
+
In addition to user defined keys, pydantic.validate arguments add a key as well.
|
|
41
|
+
This key is added to a list containing it along 'args' and 'kwargs'.
|
|
42
|
+
|
|
43
|
+
Excluded fields types, are generally non-serializable types, such as np.ndarray.
|
|
44
|
+
In addition, excluded keys might be the ones with no validator possibly defined.
|
|
45
|
+
|
|
46
|
+
The remaining fields are being casted and validated using their type hint using pydantic.create_model
|
|
47
|
+
For more information about pydantic.create_model check:
|
|
48
|
+
https://docs.pydantic.dev/1.10/usage/models/#dynamic-model-creation
|
|
49
|
+
|
|
50
|
+
The function flow can be summarized as ({step:description}):
|
|
51
|
+
1. CAST : Casting user defined keys to a set.
|
|
52
|
+
2. VALIDATE_ARGS : Running pydantic.validate_arguments on ALL keys which results on pydantic object.
|
|
53
|
+
3. ISSUBSET : Check whether the arguments asked are actually the pydantic object model fields.
|
|
54
|
+
4. USER ARGS REMOVAL : User exclude arguments removal from pydantic object fields.
|
|
55
|
+
5. DEFAULTS REMOVAL : Default arguments removal, if so exists.
|
|
56
|
+
6. MODEL CREATION : Pydantic Model creation from the remaining fields.
|
|
57
|
+
7. MODEL ASSIGNMENT : Assignment of the new model to the pydantic object.
|
|
58
|
+
8. RETURN : Returns the pydantic object with the newly created model assigned as 'model' attribute.
|
|
59
|
+
|
|
60
|
+
Usage example of the decorator:
|
|
61
|
+
>>> @model_arguments(exclude=['exclude_arg1', 'exclude_arg2'])
|
|
62
|
+
... def f(exclude_arg1, exclude_arg2, arg3, *args, **kwargs)
|
|
63
|
+
The decorator will create a pydantic model without 'exclude_arg1', 'exclude_arg2'.
|
|
64
|
+
|
|
65
|
+
:param exclude: A set-friendly container of the excluded arguments.
|
|
66
|
+
:param pydantic_kwargs: pydantic.validate_arguments acceptable kwargs
|
|
67
|
+
:return: pydantic object with appropriate model attached.
|
|
68
|
+
"""
|
|
69
|
+
from iad.core.datatools import issubset_report
|
|
70
|
+
config = config or {}
|
|
71
|
+
if exclude:
|
|
72
|
+
config['extra'] = 'allow'
|
|
73
|
+
pydantic_kwargs['config'] = config
|
|
74
|
+
|
|
75
|
+
def dec(func: Callable):
|
|
76
|
+
_exclude = set(exclude or [])
|
|
77
|
+
exclude_defaults = ['v__duplicate_kwargs', 'args', 'kwargs']
|
|
78
|
+
# TODO add type hint here
|
|
79
|
+
pydantic_obj = pyd.validate_arguments(func, **pydantic_kwargs)
|
|
80
|
+
model_cls = pydantic_obj.model
|
|
81
|
+
issubset_report(_exclude, model_cls.__fields__, on_diff=True)
|
|
82
|
+
|
|
83
|
+
for excl in _exclude:
|
|
84
|
+
model_cls.__fields__.pop(excl)
|
|
85
|
+
|
|
86
|
+
for excl in exclude_defaults:
|
|
87
|
+
model_cls.__fields__.pop(excl, None)
|
|
88
|
+
|
|
89
|
+
thin = pyd.create_model(f'{model_cls}', __base__=model_cls)
|
|
90
|
+
pydantic_obj.model = thin
|
|
91
|
+
return pydantic_obj
|
|
92
|
+
|
|
93
|
+
return dec
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _template_missing_defaults(model: type[pyd.BaseModel], _rep_models=None):
|
|
97
|
+
from iad.core.codetools import NamedObj
|
|
98
|
+
_fixing = NamedObj('FixInProcess')
|
|
99
|
+
if _rep_models is None:
|
|
100
|
+
_rep_models = {}
|
|
101
|
+
|
|
102
|
+
def convert(field):
|
|
103
|
+
t = field.type_
|
|
104
|
+
if field.required:
|
|
105
|
+
if isinstance(t, type) and issubclass(t, pyd.BaseModel):
|
|
106
|
+
fixed = _rep_models.get(t, None)
|
|
107
|
+
if fixed is _fixing: raise RuntimeError("Recursive class definitions not supported")
|
|
108
|
+
if not fixed: # new model to fix
|
|
109
|
+
_rep_models[t] = _fixing # mark we are going to fix it
|
|
110
|
+
_rep_models[t] = fixed = _template_missing_defaults(t, _rep_models)
|
|
111
|
+
return fixed, fixed()
|
|
112
|
+
else:
|
|
113
|
+
return f"<<{getattr(t, '__name__', '')}>> {getattr(field, 'description', '')}"
|
|
114
|
+
else:
|
|
115
|
+
return t, field.get_default()
|
|
116
|
+
|
|
117
|
+
fields = {name: convert(fld) for name, fld in model.__fields__.items()}
|
|
118
|
+
_log.info(f"Creating template for model {model}")
|
|
119
|
+
return pyd.create_model(model.__name__, __module__=model.__module__, __base__=YamlModel, **fields)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_hash_exclude_attr = '__hash_exclude'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _ExcludeContext:
|
|
126
|
+
_active: bool = False
|
|
127
|
+
_exclude: set | None = None
|
|
128
|
+
_attr: str | None = None
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def enter(cls, obj: YamlModel, exclude: str | Iterable(str)):
|
|
132
|
+
if not isinstance(obj, YamlModel):
|
|
133
|
+
raise TypeError("Exclude context requires YamlModel instance")
|
|
134
|
+
if cls._active:
|
|
135
|
+
raise NotImplementedError("Nested exclude contexts are not supported")
|
|
136
|
+
cls._active = True
|
|
137
|
+
|
|
138
|
+
if exclude == 'hash':
|
|
139
|
+
cls._attr = _hash_exclude_attr
|
|
140
|
+
cls._exclude = None
|
|
141
|
+
return getattr(obj, _hash_exclude_attr, set())
|
|
142
|
+
else:
|
|
143
|
+
cls._attr = None
|
|
144
|
+
cls._exclude = set(as_iter(exclude))
|
|
145
|
+
return cls._exclude
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def exit(cls):
|
|
149
|
+
assert cls._active
|
|
150
|
+
cls._active = False
|
|
151
|
+
cls._exclude = None
|
|
152
|
+
cls._attr = None
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def excludes_for(cls, obj) -> None | set:
|
|
156
|
+
"""Return excludes only if they are defined for the obj"""
|
|
157
|
+
if isinstance(obj, YamlModel):
|
|
158
|
+
if cls._attr:
|
|
159
|
+
return getattr(obj, cls._attr, None)
|
|
160
|
+
return cls._exclude
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class YamlModel(YamlModelMixin, pyd.BaseModel):
|
|
164
|
+
"""
|
|
165
|
+
Extends ``pydantic_yaml YamlModel`` to support:
|
|
166
|
+
|
|
167
|
+
- ``to_file`` method
|
|
168
|
+
- nicer str representation
|
|
169
|
+
- model's YAML scheme ``.json`` file (for editors to provide error checking)
|
|
170
|
+
- model's default configuration ``.yml`` file
|
|
171
|
+
- case independent (always lower) fields
|
|
172
|
+
- extra fields allowed by default
|
|
173
|
+
- implemented validator to lower str values
|
|
174
|
+
|
|
175
|
+
Used as a base class for Yaml based models. To subclass one must provide
|
|
176
|
+
information of the file format.
|
|
177
|
+
The possible FileFormat support is:
|
|
178
|
+
1. Explicitly pass FileFormat object.
|
|
179
|
+
2. Pass patterns or description when:
|
|
180
|
+
- desc : The description of the
|
|
181
|
+
- patterns : Regex with file names suffixes
|
|
182
|
+
|
|
183
|
+
Subclassing Arguments
|
|
184
|
+
---------------------
|
|
185
|
+
|
|
186
|
+
When sub-classing YamlModels the following **optional** arguments are supported
|
|
187
|
+
::
|
|
188
|
+
desc: Description of the model
|
|
189
|
+
patterns: Pattern or PathScheme to define file format for the model
|
|
190
|
+
file_format: (alternatively to patterns) pre-defined FileFormat
|
|
191
|
+
finalize: [True] apply recursive update_forward_refs
|
|
192
|
+
hash_exclude: fields to exclude from hash
|
|
193
|
+
extra: 'allow' | 'forbid' | 'ignore'
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
# ------- HASH EXCLUDE --------------
|
|
197
|
+
# This is a temporal workaround for missing mechanism in pydantic 1.10 of
|
|
198
|
+
# restricting specific fields be used foy dumps. Not needed in pydantic 2+
|
|
199
|
+
#
|
|
200
|
+
# This mechanism is built around 'hash_exclude' arguments for __init_subclass__.
|
|
201
|
+
# It allows to exclude such attributes from being emitted by content exporting
|
|
202
|
+
# functions built based on ._iter(), like .dict(), .yaml(), etc.
|
|
203
|
+
#
|
|
204
|
+
# Such functions has (.., exclude,..) arguments and exclusion is achieved
|
|
205
|
+
# with `._hash_exclude_context` context manager:
|
|
206
|
+
#
|
|
207
|
+
# with model._hash_exclude_context() as exclude:
|
|
208
|
+
# model.dict(exclude=exclude)
|
|
209
|
+
#
|
|
210
|
+
# Notice, that models own attributes are excluded by passing required attributes.
|
|
211
|
+
# The Context manager return them as a context just for convinience,
|
|
212
|
+
# instead one could just use `model._hash_exclude`.
|
|
213
|
+
#
|
|
214
|
+
# Main role of the context manager is to ensure that within it ANY retrival of the
|
|
215
|
+
# sub-models activates their own exclusion mechanism.
|
|
216
|
+
|
|
217
|
+
@contextmanager
|
|
218
|
+
def exclude_context(self, exclude: Literal['hash'] | list[str] | str = 'hash'):
|
|
219
|
+
exclude = _ExcludeContext.enter(self, exclude)
|
|
220
|
+
try:
|
|
221
|
+
yield exclude
|
|
222
|
+
finally:
|
|
223
|
+
_ExcludeContext.exit()
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def _get_value(cls, v: Any, exclude, **kws) -> Any:
|
|
227
|
+
"""Override BaseModel._get_value to exclude from nested models hash_exclude attributes
|
|
228
|
+
# during damping"""
|
|
229
|
+
if exclude_set := _ExcludeContext.excludes_for(v):
|
|
230
|
+
exclude = exclude_set.union(exclude) if exclude else exclude_set
|
|
231
|
+
return super()._get_value(v, exclude=exclude, **kws)
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------
|
|
234
|
+
class Config:
|
|
235
|
+
extra = pyd.Extra.allow
|
|
236
|
+
|
|
237
|
+
_format: FormatHandler | None = None # an optional format handler associated with the model
|
|
238
|
+
_kind_name = '' # Essential name of the model. Subclasses override that in __init_subclass__
|
|
239
|
+
|
|
240
|
+
def __init_subclass__(cls, *, desc=None, patterns=None,
|
|
241
|
+
file_format: FormatHandler = None, finalize=True,
|
|
242
|
+
hash_exclude=None, **kwargs):
|
|
243
|
+
"""
|
|
244
|
+
:param desc: Description of the model
|
|
245
|
+
:param patterns: Pattern or PathScheme to define file format for the model
|
|
246
|
+
:param file_format: (alternatively to patterns) pre-defined FileFormat
|
|
247
|
+
:param finalize: apply recursive update_forward_refs
|
|
248
|
+
"""
|
|
249
|
+
cls._kind_name = cls._kind_name or cls.__name__.replace('Model', '')
|
|
250
|
+
super().__init_subclass__(**kwargs)
|
|
251
|
+
|
|
252
|
+
# bind hash recipe
|
|
253
|
+
hash_exclude = set(as_list(hash_exclude))
|
|
254
|
+
if prev := getattr(cls, _hash_exclude_attr, None): # append from super class
|
|
255
|
+
hash_exclude |= prev
|
|
256
|
+
setattr(cls, _hash_exclude_attr, hash_exclude)
|
|
257
|
+
|
|
258
|
+
# meta reading logic
|
|
259
|
+
if file_format:
|
|
260
|
+
if not issubclass(file_format, FileFormat) or isinstance(file_format, FileFormat):
|
|
261
|
+
raise TypeError(f"Expected type: {FormatHandler} received: {file_format}")
|
|
262
|
+
if desc or patterns:
|
|
263
|
+
raise TypeError(f"passing `file_format' excludes 'desc' and 'patterns' arguments")
|
|
264
|
+
cls._format = file_format # ToDo: Use this to read YAML files?
|
|
265
|
+
elif desc or patterns:
|
|
266
|
+
cls._format = make_yml_model_format(
|
|
267
|
+
f'{cls._kind_name}Format', desc=desc,
|
|
268
|
+
model=cls, patterns=patterns)
|
|
269
|
+
else:
|
|
270
|
+
cls._format = None
|
|
271
|
+
|
|
272
|
+
if finalize:
|
|
273
|
+
cls.update_forward_refs()
|
|
274
|
+
|
|
275
|
+
def _str_form(self, width=80, levels=3, indent=3 * ' ', exclude=None):
|
|
276
|
+
# -------------
|
|
277
|
+
from iad.core.strings import repr_nested
|
|
278
|
+
head = f"<{type(self).__name__}>"
|
|
279
|
+
exclude = set(as_iter(exclude))
|
|
280
|
+
dct = {k: getattr(self, k) for k in self.__fields__ if k not in exclude}
|
|
281
|
+
|
|
282
|
+
s = repr_nested(dct, width, indent, levels)
|
|
283
|
+
|
|
284
|
+
if len(s) < width:
|
|
285
|
+
return f"{head} {s}"
|
|
286
|
+
|
|
287
|
+
return f"{head}\n{indent}{s}"
|
|
288
|
+
|
|
289
|
+
def __str__(self):
|
|
290
|
+
return self._str_form(width=50, levels=0)
|
|
291
|
+
|
|
292
|
+
def __repr__(self):
|
|
293
|
+
# return self._str_form(width=60, levels=4) # FixMe: @Ilya!task task hierarchy is broken
|
|
294
|
+
from pprint import pformat
|
|
295
|
+
s = pformat(self.dict(), indent=2, width=80, depth=3,
|
|
296
|
+
compact=True, sort_dicts=False)
|
|
297
|
+
s = re.sub("'([^']+)':", r"\1:", s)
|
|
298
|
+
|
|
299
|
+
sep = '\n' if '\n' in s else ':: '
|
|
300
|
+
return f"<{type(self).__name__}>{sep}{s}"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _deep_copy_attrs(self, src):
|
|
304
|
+
"""Deep copy values of all the attributes, including private.
|
|
305
|
+
"""
|
|
306
|
+
if not src.__class__ is self.__class__:
|
|
307
|
+
raise TypeError(f"{src.__class__=} is NOT {self.__class__=}!")
|
|
308
|
+
object.__setattr__(self, '__dict__', deepcopy(src.__dict__))
|
|
309
|
+
object.__setattr__(self, '__fields_set__', set(src.__fields_set__))
|
|
310
|
+
|
|
311
|
+
Undefined = pyd.fields.Undefined
|
|
312
|
+
for name in self.__private_attributes__:
|
|
313
|
+
value = getattr(self, name, Undefined)
|
|
314
|
+
if value is not Undefined:
|
|
315
|
+
if deep:
|
|
316
|
+
value = deepcopy(value)
|
|
317
|
+
object_setattr(self, name, value)
|
|
318
|
+
|
|
319
|
+
def hash_str(self, length):
|
|
320
|
+
"""
|
|
321
|
+
Creates hash string for the model, while avoiding all exclude_hash fields in the model.
|
|
322
|
+
Works in a recursive manner, every lower level models' exclude_hash fields are also avoided.
|
|
323
|
+
:param length: length of the hash str
|
|
324
|
+
:return: hashed string
|
|
325
|
+
"""
|
|
326
|
+
with self.exclude_context('hash') as exclude:
|
|
327
|
+
return TBox(self.dict(exclude=exclude)).hash_str(length)
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def write_templates(cls, folder, scheme=True, default=True) -> tuple[Path | None, Path | None]:
|
|
331
|
+
"""
|
|
332
|
+
Write template file(s) for this model, named by default automatically:
|
|
333
|
+
::
|
|
334
|
+
<model_snake_case_name>_default.yml
|
|
335
|
+
<model_snake_case_name>_scheme.json
|
|
336
|
+
|
|
337
|
+
:param folder: location for the file(s)
|
|
338
|
+
:param scheme: YAML scheme file name, ``False`` - skip, ``True`` - auto name
|
|
339
|
+
:param default: default config file name, ``False`` - skip, ``True`` - auto name
|
|
340
|
+
:return:
|
|
341
|
+
"""
|
|
342
|
+
if not (scheme or default): return None, None
|
|
343
|
+
folder = Path(folder)
|
|
344
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
|
|
346
|
+
# remove 'Model' from the class name and convert into snake case
|
|
347
|
+
name = re.sub('model', '', cls.__name__, re.I)
|
|
348
|
+
name = re.sub('((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))', r'_\1', name).lower()
|
|
349
|
+
|
|
350
|
+
if scheme is True:
|
|
351
|
+
scheme = f"{name}_yaml_scheme.json"
|
|
352
|
+
if scheme:
|
|
353
|
+
if scheme.rsplit('.', 1)[-1].lower() not in ('jsn', 'json'):
|
|
354
|
+
scheme += '.json'
|
|
355
|
+
scheme = folder / scheme
|
|
356
|
+
scheme.write_text(cls.schema_json(indent=2))
|
|
357
|
+
|
|
358
|
+
if default is True:
|
|
359
|
+
default = f"{name}_default.yml"
|
|
360
|
+
if default:
|
|
361
|
+
if default.rsplit('.', 1)[-1].lower() not in ('yml', 'yaml'):
|
|
362
|
+
default += '.yml'
|
|
363
|
+
try:
|
|
364
|
+
inst = cls()
|
|
365
|
+
except pyd.ValidationError:
|
|
366
|
+
_log.info("Model can not generate fully defined default configuration!")
|
|
367
|
+
inst = _template_missing_defaults(cls)()
|
|
368
|
+
default = folder / default
|
|
369
|
+
inst.to_yaml(default)
|
|
370
|
+
|
|
371
|
+
return scheme, default
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def lower_contain_str(v):
|
|
375
|
+
return v.lower() if isinstance(v, str) else \
|
|
376
|
+
type(v)(map(str.lower, v)) if hasattr(v, '__len__') else v
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def lower_values(cls, *fields: str):
|
|
380
|
+
"""Validator to be used in the inheriting classes
|
|
381
|
+
on specific fields to lower case of their string values.
|
|
382
|
+
|
|
383
|
+
Example::
|
|
384
|
+
|
|
385
|
+
class Model(BaseYaml):
|
|
386
|
+
domain: str
|
|
387
|
+
name: str
|
|
388
|
+
size: int
|
|
389
|
+
_lower_values = BaseYaml.lower_values("domain", "name")
|
|
390
|
+
"""
|
|
391
|
+
assert len(fields) > 0
|
|
392
|
+
return pyd.validator(*fields, pre=True, allow_reuse=True, check_fields=False
|
|
393
|
+
)(cls.lower_contain_str)
|
|
394
|
+
|
|
395
|
+
@pyd.root_validator(pre=True)
|
|
396
|
+
def _low_case_fields(cls, values):
|
|
397
|
+
return {k.lower(): v for k, v in values.items()}
|
|
398
|
+
|
|
399
|
+
@classmethod
|
|
400
|
+
def field_type(cls, name):
|
|
401
|
+
""""""
|
|
402
|
+
return cls.__fields__[name].type_
|
|
403
|
+
|
|
404
|
+
def to_yaml(self, filename=None, **kws) -> str | None:
|
|
405
|
+
"""Generate a YAML representation of the model.
|
|
406
|
+
|
|
407
|
+
Support ``yaml_dump`` kws:
|
|
408
|
+
|
|
409
|
+
include, exclude:
|
|
410
|
+
Fields to include or exclude. See `dict()`.
|
|
411
|
+
by_alias : bool
|
|
412
|
+
Whether to use aliases instead of declared names. Default is False.
|
|
413
|
+
skip_defaults, exclude_unset, exclude_defaults, exclude_none
|
|
414
|
+
Arguments as per `BaseModel.dict()`.
|
|
415
|
+
sort_keys : bool
|
|
416
|
+
If True, will sort the keys in alphanumeric order for dictionaries.
|
|
417
|
+
Default is False, which will dump in the field definition order.
|
|
418
|
+
default_flow_style : bool or None
|
|
419
|
+
Whether to use the "flow" style in the dumper. By default, this is False,
|
|
420
|
+
which uses the "block" style (probably the most familiar to users).
|
|
421
|
+
default_style : {None, "", "'", '"', "|", ">"}
|
|
422
|
+
This is the default style for quoting strings, used by `ruamel.yaml` dumper.
|
|
423
|
+
Default is None, which varies the style based on line length.
|
|
424
|
+
indent, encoding, kwargs
|
|
425
|
+
Additional arguments for the dumper.
|
|
426
|
+
|
|
427
|
+
:param filename: optional name to dump into, otherwise return the text buffer
|
|
428
|
+
"""
|
|
429
|
+
buf = self.yaml(**kws)
|
|
430
|
+
if not filename:
|
|
431
|
+
return buf
|
|
432
|
+
Path(filename).expanduser().resolve().write_text(buf)
|
|
433
|
+
|
|
434
|
+
def to_box(self, *args, **kws):
|
|
435
|
+
return TBox(self, *args, **kws)
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def update_forward_refs(cls, **kws):
|
|
439
|
+
def models_dict(c):
|
|
440
|
+
return {name: obj for name, obj in c.__dict__.items()
|
|
441
|
+
if hasattr(obj, 'update_forward_refs')}
|
|
442
|
+
|
|
443
|
+
models = models_dict(cls)
|
|
444
|
+
for m in models.values():
|
|
445
|
+
m.update_forward_refs(**models_dict(m))
|
|
446
|
+
super().update_forward_refs(**(kws | models))
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def nested_classes(model_cls: Type[YamlModel]):
|
|
450
|
+
"""Decorate class to indicate it contains nested definitions of models classes"""
|
|
451
|
+
model_cls.update_forward_refs()
|
|
452
|
+
return model_cls
|
|
453
|
+
|
|
454
|
+
@classmethod
|
|
455
|
+
def suffixes(cls) -> list[str] | None:
|
|
456
|
+
"""If the model is associated with a ``FileFormat``,
|
|
457
|
+
rerturn list of its suffixes:
|
|
458
|
+
::
|
|
459
|
+
[".scm.yml", ".scheme.yml"]
|
|
460
|
+
"""
|
|
461
|
+
return cls._format and cls._format.suffixes
|
|
462
|
+
|
|
463
|
+
@classmethod
|
|
464
|
+
def format_match_pattern(cls) -> str | None:
|
|
465
|
+
"""
|
|
466
|
+
If the model is associated with a ``FileFormat``,
|
|
467
|
+
represents regular expression matching any of the file names defined by the format.
|
|
468
|
+
|
|
469
|
+
Otherwise, its None
|
|
470
|
+
"""
|
|
471
|
+
return cls._format and cls._format.match_pattern
|
|
472
|
+
|
|
473
|
+
def difference(self, other: YamlModel):
|
|
474
|
+
"""
|
|
475
|
+
Calculate difference wuith other model of same type.
|
|
476
|
+
|
|
477
|
+
Return nested dict tree following the nested structure of the model
|
|
478
|
+
but only with fields where differences are found.
|
|
479
|
+
|
|
480
|
+
Leaf nodes of this tree are fields with different values `(self_value, other_value)`
|
|
481
|
+
|
|
482
|
+
Parent nodes contain either:
|
|
483
|
+
- dict with differences in sub-models
|
|
484
|
+
- list containing differences in sub-models of its elements
|
|
485
|
+
|
|
486
|
+
:param other:
|
|
487
|
+
:return:
|
|
488
|
+
"""
|
|
489
|
+
if (t1 := type(self)) != (t2 := type(other)):
|
|
490
|
+
return (self, other)
|
|
491
|
+
|
|
492
|
+
def equals(v1, v2):
|
|
493
|
+
try:
|
|
494
|
+
return (v1 == v2)
|
|
495
|
+
except Exception:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
def compare(pair: Iterable):
|
|
499
|
+
"""Compare TWO values of arbitrary type.
|
|
500
|
+
|
|
501
|
+
Retrun:
|
|
502
|
+
- ``None | False | 0 | [] | {} | ...`` if equal
|
|
503
|
+
- ``tuple(v1, v2)`` if not equal
|
|
504
|
+
- ``[..., (pos, <compare>), ...]`` if lists of same size containing sub-models
|
|
505
|
+
- ``difference(v1, v2)`` if both are models
|
|
506
|
+
"""
|
|
507
|
+
v1, v2 = (pair := tuple(pair))
|
|
508
|
+
if isinstance(v1, YamlModel):
|
|
509
|
+
return v1 - v2
|
|
510
|
+
elif ( # if v1 and v2 are lists of sub-models of same size
|
|
511
|
+
all(isinstance(_, list) for _ in pair)
|
|
512
|
+
and len(v1) == len(v2)
|
|
513
|
+
and len(v1) < 100 and # models not expected in long lists
|
|
514
|
+
any(isinstance(_, YamlModel) for lst in pair for _ in lst)
|
|
515
|
+
):
|
|
516
|
+
return [(i, delta) for i, p in enumerate(zip(pair))
|
|
517
|
+
if (delta := compare(p))]
|
|
518
|
+
else:
|
|
519
|
+
try: # try to compare values,
|
|
520
|
+
if v1 == v2: # if __eq__ is not supported
|
|
521
|
+
return None # catch and return the pair
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
return (v1, v2)
|
|
525
|
+
|
|
526
|
+
return {k: delta for k, fld in self.__fields__.items() if # only if delta contains something
|
|
527
|
+
(delta := compare(getattr(_, k, None) for _ in [self, other]))}
|
|
528
|
+
|
|
529
|
+
__sub__ = difference
|
|
530
|
+
|
|
531
|
+
def __eq__(self, other: YamlModel):
|
|
532
|
+
return not self.difference(other)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class YamlModelFormat(FileFormat, content=CT.CONFIG):
|
|
536
|
+
_model: Type[pyd.BaseModel] = None
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def model(self):
|
|
540
|
+
return self._model
|
|
541
|
+
|
|
542
|
+
def __init_subclass__(cls, *, model: Type[YamlModel], **kwargs):
|
|
543
|
+
cls._model = model
|
|
544
|
+
super().__init_subclass__(**kwargs)
|
|
545
|
+
|
|
546
|
+
@classmethod
|
|
547
|
+
def read(cls, filename, *, content=CT.CONFIG, **kwargs):
|
|
548
|
+
return cls._model.parse_file(filename, **kwargs)
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def write(cls, filename: PathT, data: YamlModel, **kws):
|
|
552
|
+
data.to_yaml(filename, **kws)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def make_yml_model_format(name, model, patterns, desc=None):
|
|
556
|
+
"""Create Format subclass of ``YamlModelFormat`` for given pydantic ``model``."""
|
|
557
|
+
return MetaFormat(name, (YamlModelFormat,), {},
|
|
558
|
+
content=CT.CONFIG, model=model, specific=1,
|
|
559
|
+
patterns=patterns, desc=desc or f'{model._kind_name} Model'
|
|
560
|
+
)
|