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.
Files changed (48) hide show
  1. iad/core/__init__.py +9 -0
  2. iad/core/array.py +1961 -0
  3. iad/core/binary.py +377 -0
  4. iad/core/cache.py +903 -0
  5. iad/core/codetools.py +203 -0
  6. iad/core/datatools.py +671 -0
  7. iad/core/docs/locators.ipynb +754 -0
  8. iad/core/dotstyle.py +99 -0
  9. iad/core/env.py +271 -0
  10. iad/core/events.py +650 -0
  11. iad/core/filesproc.py +1046 -0
  12. iad/core/fnctools.py +390 -0
  13. iad/core/label.py +240 -0
  14. iad/core/logs.py +182 -0
  15. iad/core/nptools.py +449 -0
  16. iad/core/one_dark.puml +881 -0
  17. iad/core/param/__init__.py +17 -0
  18. iad/core/param/confargparse.py +55 -0
  19. iad/core/param/paramaze.py +339 -0
  20. iad/core/param/tbox.py +277 -0
  21. iad/core/paths.py +563 -0
  22. iad/core/pdtools.py +2570 -0
  23. iad/core/pydantools/__init__.py +5 -0
  24. iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
  25. iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
  26. iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
  27. iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
  28. iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
  29. iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
  30. iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
  31. iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
  32. iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
  33. iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
  34. iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
  35. iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
  36. iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
  37. iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
  38. iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
  39. iad/core/pydantools/models.py +560 -0
  40. iad/core/regexp.py +348 -0
  41. iad/core/short.py +308 -0
  42. iad/core/strings.py +635 -0
  43. iad/core/unc_panda.py +270 -0
  44. iad/core/units.py +58 -0
  45. iad/core/wrap.py +420 -0
  46. ialdev_core-0.1.0.dist-info/METADATA +73 -0
  47. ialdev_core-0.1.0.dist-info/RECORD +48 -0
  48. 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
+ )