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.
Files changed (49) hide show
  1. {didactic-0.4.0 → didactic-0.4.2}/PKG-INFO +1 -1
  2. {didactic-0.4.0 → didactic-0.4.2}/pyproject.toml +1 -1
  3. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/api.py +1 -1
  4. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_meta.py +62 -0
  5. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_model.py +107 -9
  6. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_types.py +26 -3
  7. {didactic-0.4.0 → didactic-0.4.2}/.gitignore +0 -0
  8. {didactic-0.4.0 → didactic-0.4.2}/README.md +0 -0
  9. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/_self_describing.py +0 -0
  10. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/__init__.py +0 -0
  11. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/_axiom_enforcement.py +0 -0
  12. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/axioms/_axioms.py +0 -0
  13. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/cli/__init__.py +0 -0
  14. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/cli/_cli.py +0 -0
  15. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/__init__.py +0 -0
  16. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_emitter.py +0 -0
  17. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_json_schema.py +0 -0
  18. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/_write.py +0 -0
  19. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/io.py +0 -0
  20. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/codegen/source.py +0 -0
  21. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/__init__.py +0 -0
  22. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_computed.py +0 -0
  23. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_derived.py +0 -0
  24. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_fields.py +0 -0
  25. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_refs.py +0 -0
  26. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_unions.py +0 -0
  27. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/fields/_validators.py +0 -0
  28. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/__init__.py +0 -0
  29. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_dependent_lens.py +0 -0
  30. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_lens.py +0 -0
  31. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/lenses/_testing.py +0 -0
  32. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/__init__.py +0 -0
  33. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_diff.py +0 -0
  34. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_fingerprint.py +0 -0
  35. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_migrations.py +0 -0
  36. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/migrations/_synthesis.py +0 -0
  37. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/__init__.py +0 -0
  38. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_config.py +0 -0
  39. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_root.py +0 -0
  40. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/models/_storage.py +0 -0
  41. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/py.typed +0 -0
  42. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/theory/__init__.py +0 -0
  43. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/theory/_theory.py +0 -0
  44. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/__init__.py +0 -0
  45. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_types_lib.py +0 -0
  46. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/types/_typing.py +0 -0
  47. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/vcs/__init__.py +0 -0
  48. {didactic-0.4.0 → didactic-0.4.2}/src/didactic/vcs/_backref.py +0 -0
  49. {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.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "didactic"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Pydantic-class API on top of panproto: GATs, lenses, and VCS."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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.0"
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 ``_encode_field``.
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 = _encode_field(spec, cast("FieldValue", 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] = _encode_field(specs[k], v)
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
- def _encode_field(spec: FieldSpec, value: FieldValue) -> Encoded:
1035
- """Run the converter (if any) and the type-translation encoder.
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
- If the value cannot be encoded.
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
- return spec.translation.encode(value)
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
- assert isinstance(v, tuple)
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
- assert isinstance(v, frozenset)
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