didactic 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {didactic-0.4.0 → didactic-0.4.2}/PKG-INFO +1 -1
- {didactic-0.4.0 → didactic-0.4.2}/pyproject.toml +1 -1
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/api.py +1 -1
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_meta.py +62 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_model.py +107 -9
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_types.py +26 -3
- {didactic-0.4.0 → didactic-0.4.2}/.gitignore +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/README.md +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/_self_describing.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/_axiom_enforcement.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/_axioms.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/cli/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/cli/_cli.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_emitter.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_json_schema.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_write.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/io.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/source.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_computed.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_derived.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_fields.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_refs.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_unions.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_validators.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_dependent_lens.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_lens.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_testing.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_diff.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_fingerprint.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_migrations.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_synthesis.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_config.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_root.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_storage.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/py.typed +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/theory/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/theory/_theory.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_types_lib.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_typing.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/vcs/__init__.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/vcs/_backref.py +0 -0
- {didactic-0.4.0 → didactic-0.4.2}/src/didactic/vcs/_repo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: didactic
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Pydantic-class API on top of panproto: GATs, lenses, and VCS.
|
|
5
5
|
Project-URL: Homepage, https://github.com/panproto/didactic
|
|
6
6
|
Project-URL: Repository, https://github.com/panproto/didactic
|
|
@@ -68,7 +68,7 @@ from didactic.types import _types_lib as types
|
|
|
68
68
|
from didactic.vcs._backref import ModelPool, resolve_backrefs
|
|
69
69
|
from didactic.vcs._repo import Repository
|
|
70
70
|
|
|
71
|
-
__version__ = "0.4.
|
|
71
|
+
__version__ = "0.4.2"
|
|
72
72
|
|
|
73
73
|
#: Conventional namespace for lens utilities (`dx.lens.identity(...)`,
|
|
74
74
|
#: `dx.lens.Lens`, etc.). The ``lens`` name doubles as a decorator
|
|
@@ -348,6 +348,7 @@ class ModelMeta(type):
|
|
|
348
348
|
__model_config__: ModelConfig
|
|
349
349
|
__computed_fields__: tuple[str, ...]
|
|
350
350
|
__class_axioms__: tuple[Axiom, ...]
|
|
351
|
+
__field_validators__: dict[str, tuple[tuple[str, str], ...]]
|
|
351
352
|
__theory_cache__: panproto.Theory | None
|
|
352
353
|
|
|
353
354
|
@property
|
|
@@ -397,6 +398,7 @@ class ModelMeta(type):
|
|
|
397
398
|
cls.__model_config__ = DEFAULT_CONFIG
|
|
398
399
|
cls.__computed_fields__ = ()
|
|
399
400
|
cls.__class_axioms__ = ()
|
|
401
|
+
cls.__field_validators__ = {}
|
|
400
402
|
return cls
|
|
401
403
|
|
|
402
404
|
cls.__schema_kind__ = name
|
|
@@ -404,6 +406,7 @@ class ModelMeta(type):
|
|
|
404
406
|
cls.__field_specs__ = ModelMeta.collect_field_specs(cls)
|
|
405
407
|
cls.__computed_fields__ = computed_field_names(cls)
|
|
406
408
|
cls.__class_axioms__ = collect_class_axioms(cls)
|
|
409
|
+
cls.__field_validators__ = ModelMeta.collect_field_validators(cls)
|
|
407
410
|
# placeholder for the cached panproto.Theory; populated lazily
|
|
408
411
|
# by the `__theory__` property the metaclass exposes below.
|
|
409
412
|
cls.__theory_cache__ = None
|
|
@@ -541,6 +544,65 @@ class ModelMeta(type):
|
|
|
541
544
|
|
|
542
545
|
return seen
|
|
543
546
|
|
|
547
|
+
@staticmethod
|
|
548
|
+
def collect_field_validators(
|
|
549
|
+
target: type,
|
|
550
|
+
) -> dict[str, tuple[tuple[str, str], ...]]:
|
|
551
|
+
"""Walk MRO and collect ``@validates``-tagged methods.
|
|
552
|
+
|
|
553
|
+
Parameters
|
|
554
|
+
----------
|
|
555
|
+
target
|
|
556
|
+
The class to inspect.
|
|
557
|
+
|
|
558
|
+
Returns
|
|
559
|
+
-------
|
|
560
|
+
dict
|
|
561
|
+
Mapping ``field_name -> tuple of (method_name, mode)`` in
|
|
562
|
+
registration order. Methods are stored by name (not bound
|
|
563
|
+
callable) so that subclasses overriding a validator method
|
|
564
|
+
transparently take effect: ``getattr(cls, method_name)`` at
|
|
565
|
+
call time picks up the override via normal MRO.
|
|
566
|
+
|
|
567
|
+
Notes
|
|
568
|
+
-----
|
|
569
|
+
Walks ``target.__mro__`` in reverse so subclass declarations
|
|
570
|
+
override ancestor declarations. A subclass method that shadows
|
|
571
|
+
an ancestor validator *without* re-applying ``@validates``
|
|
572
|
+
unregisters that method's marker; the override is taken at face
|
|
573
|
+
value.
|
|
574
|
+
"""
|
|
575
|
+
# method_name -> marker dict, populated in reverse-MRO order so
|
|
576
|
+
# the subclass entry wins. Subclass methods that shadow without
|
|
577
|
+
# the marker remove the ancestor entry.
|
|
578
|
+
markers: dict[str, dict[str, Opaque]] = {}
|
|
579
|
+
for klass in reversed(target.__mro__):
|
|
580
|
+
if not isinstance(klass, ModelMeta):
|
|
581
|
+
continue
|
|
582
|
+
for member_name, member in vars(klass).items():
|
|
583
|
+
if member_name.startswith("__"):
|
|
584
|
+
continue
|
|
585
|
+
marker = getattr(member, "__didactic_validator__", None)
|
|
586
|
+
if marker is None:
|
|
587
|
+
func = getattr(member, "__func__", None)
|
|
588
|
+
if func is not None:
|
|
589
|
+
marker = getattr(func, "__didactic_validator__", None)
|
|
590
|
+
if marker is not None:
|
|
591
|
+
markers[member_name] = cast("dict[str, Opaque]", marker)
|
|
592
|
+
elif callable(member) and member_name in markers:
|
|
593
|
+
# subclass shadows the ancestor validator without
|
|
594
|
+
# re-tagging; honour the override and drop the marker
|
|
595
|
+
del markers[member_name]
|
|
596
|
+
|
|
597
|
+
# invert to per-field tuples, preserving registration order
|
|
598
|
+
per_field: dict[str, list[tuple[str, str]]] = {}
|
|
599
|
+
for member_name, marker in markers.items():
|
|
600
|
+
field_names = cast("tuple[str, ...]", marker["fields"])
|
|
601
|
+
mode = cast("str", marker["mode"])
|
|
602
|
+
for fname in field_names:
|
|
603
|
+
per_field.setdefault(fname, []).append((member_name, mode))
|
|
604
|
+
return {fname: tuple(items) for fname, items in per_field.items()}
|
|
605
|
+
|
|
544
606
|
|
|
545
607
|
__all__ = [
|
|
546
608
|
"ModelMeta",
|
|
@@ -22,10 +22,13 @@ didactic.models._storage : the pluggable storage backend.
|
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
24
|
# ``__init__(**kwargs: FieldValue | JsonValue)`` accepts JSON-shape
|
|
25
|
-
# dicts but pyright doesn't carry the union through
|
|
25
|
+
# dicts but pyright doesn't carry the union through the field
|
|
26
|
+
# pipeline; the per-call ``cast("FieldValue", value)`` covers this.
|
|
26
27
|
from __future__ import annotations
|
|
27
28
|
|
|
29
|
+
import inspect
|
|
28
30
|
import json
|
|
31
|
+
from dataclasses import dataclass
|
|
29
32
|
from datetime import date, datetime, time
|
|
30
33
|
from decimal import Decimal
|
|
31
34
|
from types import UnionType
|
|
@@ -51,7 +54,7 @@ from didactic.models._meta import ModelMeta, read_class_annotations
|
|
|
51
54
|
from didactic.models._storage import DictStorage
|
|
52
55
|
|
|
53
56
|
if TYPE_CHECKING:
|
|
54
|
-
from collections.abc import Mapping
|
|
57
|
+
from collections.abc import Callable, Mapping
|
|
55
58
|
|
|
56
59
|
from didactic.axioms._axioms import Axiom
|
|
57
60
|
from didactic.codegen._json_schema import JsonSchemaDoc
|
|
@@ -180,7 +183,18 @@ class Model(metaclass=ModelMeta):
|
|
|
180
183
|
value = default
|
|
181
184
|
|
|
182
185
|
try:
|
|
183
|
-
encoded_value =
|
|
186
|
+
encoded_value = _run_field_pipeline(
|
|
187
|
+
cls, spec, fname, cast("FieldValue", value)
|
|
188
|
+
)
|
|
189
|
+
except _ValidatorError as fail:
|
|
190
|
+
errors.append(
|
|
191
|
+
ValidationErrorEntry(
|
|
192
|
+
loc=(fname,),
|
|
193
|
+
type="validator_error",
|
|
194
|
+
msg=fail.message,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
184
198
|
except (TypeError, ValueError) as exc:
|
|
185
199
|
errors.append(
|
|
186
200
|
ValidationErrorEntry(
|
|
@@ -319,7 +333,13 @@ class Model(metaclass=ModelMeta):
|
|
|
319
333
|
)
|
|
320
334
|
continue
|
|
321
335
|
try:
|
|
322
|
-
encoded[k] =
|
|
336
|
+
encoded[k] = _run_field_pipeline(cls, specs[k], k, v)
|
|
337
|
+
except _ValidatorError as fail:
|
|
338
|
+
errors.append(
|
|
339
|
+
ValidationErrorEntry(
|
|
340
|
+
loc=(k,), type="validator_error", msg=fail.message
|
|
341
|
+
)
|
|
342
|
+
)
|
|
323
343
|
except (TypeError, ValueError) as exc:
|
|
324
344
|
errors.append(
|
|
325
345
|
ValidationErrorEntry(loc=(k,), type="type_error", msg=str(exc))
|
|
@@ -1031,15 +1051,56 @@ def _from_json_payload(
|
|
|
1031
1051
|
return out
|
|
1032
1052
|
|
|
1033
1053
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1054
|
+
@dataclass(frozen=True, slots=True)
|
|
1055
|
+
class _ValidatorError(Exception):
|
|
1056
|
+
"""Internal error type carrying a ``@validates``-raised message.
|
|
1057
|
+
|
|
1058
|
+
Wraps the ``ValueError`` / ``TypeError`` raised by a user
|
|
1059
|
+
validator so the ``__init__`` and ``with_()`` paths can route the
|
|
1060
|
+
failure to a ``"validator_error"`` ``ValidationErrorEntry``
|
|
1061
|
+
instead of conflating it with the encoder's ``"type_error"``.
|
|
1062
|
+
"""
|
|
1063
|
+
|
|
1064
|
+
message: str
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _invoke_validator(cls: type, method_name: str, value: FieldValue) -> FieldValue:
|
|
1068
|
+
"""Invoke a ``@validates``-tagged method with class-level binding.
|
|
1069
|
+
|
|
1070
|
+
Both ``@classmethod`` and plain instance methods are supported.
|
|
1071
|
+
Instance methods receive the class as their first argument (the
|
|
1072
|
+
Model is frozen and not yet constructed when validators run, so
|
|
1073
|
+
no instance ``self`` exists). ``@staticmethod`` validators are
|
|
1074
|
+
called with the value alone.
|
|
1075
|
+
"""
|
|
1076
|
+
descriptor = inspect.getattr_static(cls, method_name)
|
|
1077
|
+
if isinstance(descriptor, staticmethod):
|
|
1078
|
+
sm = cast("staticmethod[..., FieldValue]", descriptor)
|
|
1079
|
+
return sm.__func__(value)
|
|
1080
|
+
if isinstance(descriptor, classmethod):
|
|
1081
|
+
cm = cast("classmethod[type, ..., FieldValue]", descriptor)
|
|
1082
|
+
return cm.__func__(cls, value)
|
|
1083
|
+
return cast("Callable[..., FieldValue]", descriptor)(cls, value)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _run_field_pipeline(
|
|
1087
|
+
cls: type,
|
|
1088
|
+
spec: FieldSpec,
|
|
1089
|
+
fname: str,
|
|
1090
|
+
value: FieldValue,
|
|
1091
|
+
) -> Encoded:
|
|
1092
|
+
"""Run converter, before-validators, encoder, after-validators.
|
|
1036
1093
|
|
|
1037
1094
|
Parameters
|
|
1038
1095
|
----------
|
|
1096
|
+
cls
|
|
1097
|
+
The Model class (used to look up validators and bind them).
|
|
1039
1098
|
spec
|
|
1040
1099
|
The field's spec.
|
|
1100
|
+
fname
|
|
1101
|
+
Field name; used to find registered validators.
|
|
1041
1102
|
value
|
|
1042
|
-
The user-supplied value.
|
|
1103
|
+
The raw user-supplied value (or the resolved default).
|
|
1043
1104
|
|
|
1044
1105
|
Returns
|
|
1045
1106
|
-------
|
|
@@ -1048,12 +1109,49 @@ def _encode_field(spec: FieldSpec, value: FieldValue) -> Encoded:
|
|
|
1048
1109
|
|
|
1049
1110
|
Raises
|
|
1050
1111
|
------
|
|
1112
|
+
_ValidatorError
|
|
1113
|
+
A ``@validates`` callback raised ``ValueError`` or ``TypeError``.
|
|
1051
1114
|
TypeError or ValueError
|
|
1052
|
-
|
|
1115
|
+
The encoder rejected the value (mismatched type, failed
|
|
1116
|
+
``Annotated[...]`` constraint, etc.).
|
|
1053
1117
|
"""
|
|
1054
1118
|
if spec.converter is not None:
|
|
1055
1119
|
value = spec.converter(value)
|
|
1056
|
-
|
|
1120
|
+
|
|
1121
|
+
validators: tuple[tuple[str, str], ...] = cast(
|
|
1122
|
+
"ModelMeta", cls
|
|
1123
|
+
).__field_validators__.get(fname, ())
|
|
1124
|
+
|
|
1125
|
+
for method_name, mode in validators:
|
|
1126
|
+
if mode != "before":
|
|
1127
|
+
continue
|
|
1128
|
+
try:
|
|
1129
|
+
value = _invoke_validator(cls, method_name, value)
|
|
1130
|
+
except (ValueError, TypeError) as exc:
|
|
1131
|
+
raise _ValidatorError(str(exc)) from exc
|
|
1132
|
+
|
|
1133
|
+
encoded_value = spec.translation.encode(value)
|
|
1134
|
+
|
|
1135
|
+
if not any(mode == "after" for _, mode in validators):
|
|
1136
|
+
return encoded_value
|
|
1137
|
+
|
|
1138
|
+
# ``after`` validators see the canonical decoded form, mirroring
|
|
1139
|
+
# Pydantic's behaviour where the post-coercion value is what the
|
|
1140
|
+
# validator inspects. Re-encode if any validator returned a
|
|
1141
|
+
# different value.
|
|
1142
|
+
typed: FieldValue = spec.translation.decode(encoded_value)
|
|
1143
|
+
new_value = typed
|
|
1144
|
+
for method_name, mode in validators:
|
|
1145
|
+
if mode != "after":
|
|
1146
|
+
continue
|
|
1147
|
+
try:
|
|
1148
|
+
new_value = _invoke_validator(cls, method_name, new_value)
|
|
1149
|
+
except (ValueError, TypeError) as exc:
|
|
1150
|
+
raise _ValidatorError(str(exc)) from exc
|
|
1151
|
+
|
|
1152
|
+
if new_value is typed:
|
|
1153
|
+
return encoded_value
|
|
1154
|
+
return spec.translation.encode(new_value)
|
|
1057
1155
|
|
|
1058
1156
|
|
|
1059
1157
|
# alias for adoption ergonomics
|
|
@@ -1357,8 +1357,18 @@ def _make_list_encoder(
|
|
|
1357
1357
|
) -> Callable[[FieldValue], Encoded]:
|
|
1358
1358
|
def enc(v: FieldValue) -> Encoded:
|
|
1359
1359
|
# JSON-encode the encoded items; the panproto string is therefore
|
|
1360
|
-
# always a valid JSON array literal.
|
|
1361
|
-
|
|
1360
|
+
# always a valid JSON array literal. Accept list as well as tuple
|
|
1361
|
+
# so call sites migrating from Pydantic (which transparently
|
|
1362
|
+
# coerces list to tuple for tuple-typed fields) do not have to
|
|
1363
|
+
# rewrite every ``indices=[0, 1, 2]`` literal. Reject anything
|
|
1364
|
+
# else with ``TypeError`` so the caller's ``ValidationError``
|
|
1365
|
+
# carries the field name instead of a bare ``AssertionError``.
|
|
1366
|
+
if not isinstance(v, (tuple, list)):
|
|
1367
|
+
msg = (
|
|
1368
|
+
f"expected tuple or list for tuple[T, ...] field, "
|
|
1369
|
+
f"got {type(v).__name__}"
|
|
1370
|
+
)
|
|
1371
|
+
raise TypeError(msg)
|
|
1362
1372
|
return json.dumps([inner_encode(item) for item in v])
|
|
1363
1373
|
|
|
1364
1374
|
return enc
|
|
@@ -1384,7 +1394,20 @@ def _classify_frozenset(args: tuple[TypeForm, ...]) -> TypeTranslation:
|
|
|
1384
1394
|
inner = classify(args[0])
|
|
1385
1395
|
|
|
1386
1396
|
def enc(v: FieldValue) -> Encoded:
|
|
1387
|
-
|
|
1397
|
+
# Accept any non-string iterable that can lawfully be coerced to
|
|
1398
|
+
# ``frozenset`` (set, frozenset, list, tuple). The same reasoning
|
|
1399
|
+
# as the tuple encoder applies: Pydantic coerces, callers
|
|
1400
|
+
# migrating across should not have to rewrite every literal, and
|
|
1401
|
+
# rejecting via ``TypeError`` keeps the failure inside
|
|
1402
|
+
# ``ValidationError``.
|
|
1403
|
+
if isinstance(v, (str, bytes, bytearray)) or not isinstance(
|
|
1404
|
+
v, (frozenset, set, list, tuple)
|
|
1405
|
+
):
|
|
1406
|
+
msg = (
|
|
1407
|
+
f"expected frozenset, set, list, or tuple for frozenset[T] "
|
|
1408
|
+
f"field, got {type(v).__name__}"
|
|
1409
|
+
)
|
|
1410
|
+
raise TypeError(msg)
|
|
1388
1411
|
return json.dumps(sorted(inner.encode(item) for item in v))
|
|
1389
1412
|
|
|
1390
1413
|
def dec(s: Encoded) -> frozenset[FieldValue]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|