didactic 0.3.2__tar.gz → 0.4.1__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.3.2 → didactic-0.4.1}/PKG-INFO +1 -1
  2. {didactic-0.3.2 → didactic-0.4.1}/pyproject.toml +1 -1
  3. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/api.py +1 -1
  4. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_json_schema.py +0 -3
  5. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_derived.py +0 -6
  6. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_unions.py +0 -4
  7. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_diff.py +0 -3
  8. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_fingerprint.py +0 -4
  9. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_config.py +16 -6
  10. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_meta.py +81 -18
  11. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_model.py +275 -4
  12. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_types.py +26 -3
  13. {didactic-0.3.2 → didactic-0.4.1}/.gitignore +0 -0
  14. {didactic-0.3.2 → didactic-0.4.1}/README.md +0 -0
  15. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/_self_describing.py +0 -0
  16. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/__init__.py +0 -0
  17. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/_axiom_enforcement.py +0 -0
  18. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/_axioms.py +0 -0
  19. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/cli/__init__.py +0 -0
  20. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/cli/_cli.py +0 -0
  21. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/__init__.py +0 -0
  22. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_emitter.py +0 -0
  23. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_write.py +0 -0
  24. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/io.py +0 -0
  25. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/source.py +0 -0
  26. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/__init__.py +0 -0
  27. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_computed.py +0 -0
  28. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_fields.py +0 -0
  29. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_refs.py +0 -0
  30. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_validators.py +0 -0
  31. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/__init__.py +0 -0
  32. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_dependent_lens.py +0 -0
  33. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_lens.py +0 -0
  34. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_testing.py +0 -0
  35. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/__init__.py +0 -0
  36. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_migrations.py +0 -0
  37. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_synthesis.py +0 -0
  38. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/__init__.py +0 -0
  39. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_root.py +0 -0
  40. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_storage.py +0 -0
  41. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/py.typed +0 -0
  42. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/theory/__init__.py +0 -0
  43. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/theory/_theory.py +0 -0
  44. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/__init__.py +0 -0
  45. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_types_lib.py +0 -0
  46. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_typing.py +0 -0
  47. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/vcs/__init__.py +0 -0
  48. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/vcs/_backref.py +0 -0
  49. {didactic-0.3.2 → didactic-0.4.1}/src/didactic/vcs/_repo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: didactic
3
- Version: 0.3.2
3
+ Version: 0.4.1
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.3.2"
7
+ version = "0.4.1"
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.3.2"
71
+ __version__ = "0.4.1"
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
@@ -1,6 +1,3 @@
1
- # ``annotated_metadata`` lives on ``spec.extras`` as ``Opaque`` and
2
- # the per-marker iteration takes the marker as an opaque value to
3
- # duck-type. Tracked in panproto/didactic#1.
4
1
  """JSON Schema (Draft 2020-12) generation from a Model class.
5
2
 
6
3
  The single user-facing entry point is
@@ -42,12 +42,6 @@ See Also
42
42
  didactic.computed : evaluated every read; not cached.
43
43
  """
44
44
 
45
- # Reaches into ``Model._derived_cache`` (a documented Model-internal
46
- # slot) from a closure attached to the @derived decorator. The
47
- # ``_`` prefix is conventional for "implementation detail of the
48
- # Model layer"; the derived-decorator integration is part of that
49
- # layer. Tracked in panproto/didactic#1.
50
-
51
45
  from __future__ import annotations
52
46
 
53
47
  from typing import TYPE_CHECKING, cast
@@ -1,7 +1,3 @@
1
- # ``TaggedUnion.model_validate`` overrides the base class with a
2
- # narrower payload type; the runtime accepts the base shape too. The
3
- # ``Literal[...]`` discriminator extraction goes through annotation
4
- # walking that pyright can't follow. Tracked in panproto/didactic#1.
5
1
  """Tagged (discriminated) unions over [Model][didactic.api.Model] subclasses.
6
2
 
7
3
  Pydantic-shaped discriminated unions: declare a base class with a
@@ -1,6 +1,3 @@
1
- # ``diff_and_classify`` returns a ``CompatReport`` whose ``.to_dict()``
2
- # we re-emit as ``JsonObject``; pyright's stub doesn't narrow the
3
- # panproto-side types tightly enough. Tracked in panproto/didactic#1.
4
1
  """Schema diff and breaking-change detection.
5
2
 
6
3
  Two thin functions over panproto's ``diff_schemas`` and
@@ -1,7 +1,3 @@
1
- # ``structural_spec`` and friends shape ``TheorySpec`` (a TypedDict)
2
- # back into ``JsonObject`` (``dict[str, JsonValue]``); the dict
3
- # invariance bites. ``_default`` narrowing on tuple inputs is
4
- # pyright-flagged as redundant. Tracked in panproto/didactic#1.
5
1
  """Stable fingerprints for didactic Theory specs.
6
2
 
7
3
  didactic stores migration registry entries by a fingerprint that is
@@ -46,8 +46,13 @@ class ModelConfig:
46
46
  ----------
47
47
  extra
48
48
  How to handle keyword arguments that don't match a declared
49
- field. Only ``"forbid"`` is honoured currently; ``"ignore"`` and
50
- ``"allow"`` raise ``NotImplementedError``.
49
+ field. ``"forbid"`` (default) raises ``ValidationError`` on
50
+ any unknown key; ``"ignore"`` silently drops unknown keys at
51
+ construction (the dropped values never enter the model and
52
+ never appear in ``model_dump()``). ``"allow"`` raises
53
+ ``NotImplementedError`` (it needs a storage decision for
54
+ unknown fields that the immutable Model contract doesn't
55
+ cleanly support yet).
51
56
  strict
52
57
  If ``True``, type coercion is disabled; every field's value
53
58
  must already be the declared type. Default ``True``. ``False``
@@ -75,11 +80,16 @@ class ModelConfig:
75
80
  f"got {self.extra!r}"
76
81
  )
77
82
  raise ValueError(msg)
78
- # v0.0.1 only honours `forbid`; fail loud on the unsupported settings
79
- if self.extra != "forbid":
83
+ # ``"forbid"`` and ``"ignore"`` are honoured at construction
84
+ # (in ``Model.__init__``). ``"allow"`` is reserved: storing
85
+ # unknown values where ``model_dump`` can find them while
86
+ # keeping the model frozen has no settled design.
87
+ if self.extra == "allow":
80
88
  msg = (
81
- f"ModelConfig.extra={self.extra!r} is not yet implemented; "
82
- "only 'forbid' is honoured."
89
+ "ModelConfig.extra='allow' is not yet implemented; the "
90
+ "frozen-Model contract has no settled storage path for "
91
+ "unknown fields. Use 'ignore' to drop them silently or "
92
+ "'forbid' to reject them."
83
93
  )
84
94
  raise NotImplementedError(msg)
85
95
  if not self.strict:
@@ -1,10 +1,3 @@
1
- # The metaclass passes resolved annotations (``type | TypeVar |
2
- # ForwardRef``) into ``classify`` and ``_build_field_spec``, which
3
- # expect the narrower ``TypeForm`` (``type | UnionType``). The
4
- # runtime contract handles the wider input (the metaclass also
5
- # accepts string forward refs and TypeVar instances), but pyright
6
- # can't follow the case split through the dataclass_transform
7
- # layer. Tracked in panproto/didactic#1.
8
1
  """The ``ModelMeta`` metaclass: class-as-Theory derivation.
9
2
 
10
3
  ``ModelMeta`` runs at class-creation time. It reads each annotated field,
@@ -32,7 +25,14 @@ from __future__ import annotations
32
25
  import annotationlib
33
26
  import contextlib
34
27
  import sys
35
- from typing import TYPE_CHECKING, ForwardRef, TypeVar, cast, dataclass_transform
28
+ from typing import (
29
+ TYPE_CHECKING,
30
+ ForwardRef,
31
+ TypeVar,
32
+ cast,
33
+ dataclass_transform,
34
+ get_args,
35
+ )
36
36
 
37
37
  from didactic.axioms._axioms import collect_class_axioms
38
38
  from didactic.fields._computed import computed_field_names
@@ -101,7 +101,27 @@ def _deferred_from_json(_: JsonValue) -> FieldValue:
101
101
  raise TypeError(msg)
102
102
 
103
103
 
104
- def _read_class_annotations(cls: type) -> dict[str, type | ForwardRef]:
104
+ def _annotation_contains_typevar(annotation: object, depth: int = 0) -> bool:
105
+ """Return True iff ``annotation`` contains a ``TypeVar`` anywhere in its shape.
106
+
107
+ Walks parameterised generics (``list[T]``, ``tuple[T, ...]``,
108
+ ``dict[str, T]``, ``Embed[T]``, ``Annotated[T, ...]``, unions of
109
+ these) and stops at the first ``TypeVar`` leaf. Depth-bounded so
110
+ pathological recursive aliases don't loop forever.
111
+ """
112
+ if depth > 64:
113
+ return False
114
+ if isinstance(annotation, TypeVar):
115
+ return True
116
+ args = get_args(annotation)
117
+ if not args:
118
+ return False
119
+ return any(
120
+ a is not Ellipsis and _annotation_contains_typevar(a, depth + 1) for a in args
121
+ )
122
+
123
+
124
+ def read_class_annotations(cls: type) -> dict[str, type | TypeVar | ForwardRef]:
105
125
  """Read a class's annotations through ``annotationlib``, robustly.
106
126
 
107
127
  Parameters
@@ -147,7 +167,7 @@ def _read_class_annotations(cls: type) -> dict[str, type | ForwardRef]:
147
167
  localns: dict[str, Opaque] = dict(vars(cls))
148
168
  localns.setdefault(cls.__name__, cls)
149
169
 
150
- resolved: dict[str, type | ForwardRef] = {}
170
+ resolved: dict[str, type | TypeVar | ForwardRef] = {}
151
171
  for name, ann in raw.items():
152
172
  if isinstance(ann, str):
153
173
  try:
@@ -206,6 +226,12 @@ def _build_field_spec(
206
226
  and annotation.__forward_arg__.isupper()
207
227
  ):
208
228
  tv_name = annotation.__forward_arg__
229
+ elif _annotation_contains_typevar(annotation):
230
+ # Nested TypeVar (e.g. ``items: tuple[T, ...]`` or
231
+ # ``by_name: dict[str, T]``). Defer the same way as a bare
232
+ # TypeVar; ``Model.__class_getitem__`` substitutes through
233
+ # the nested shape on parameterisation.
234
+ tv_name = "<nested>"
209
235
  if tv_name is not None:
210
236
  from didactic.types._types import TypeTranslation # noqa: PLC0415
211
237
 
@@ -216,11 +242,32 @@ def _build_field_spec(
216
242
  inner_kind="generic",
217
243
  from_json=_deferred_from_json,
218
244
  )
245
+ # If the user supplied ``f: T = dx.field(default=..., description=...)``
246
+ # the Field descriptor's metadata must survive on the deferred spec
247
+ # so a parameterised subclass can propagate the default and metadata
248
+ # onto its own (concrete-annotation) FieldSpec. Plain values land in
249
+ # ``default`` directly.
250
+ if isinstance(raw_default, Field):
251
+ return FieldSpec(
252
+ name=name,
253
+ annotation=annotation,
254
+ translation=deferred,
255
+ default=raw_default.default,
256
+ default_factory=raw_default.default_factory,
257
+ converter=raw_default.converter,
258
+ alias=raw_default.alias,
259
+ description=raw_default.description,
260
+ examples=raw_default.examples,
261
+ deprecated=raw_default.deprecated,
262
+ nominal=raw_default.nominal,
263
+ usage_mode=raw_default.usage_mode,
264
+ extras=dict(raw_default.extras) if raw_default.extras else {},
265
+ )
219
266
  return FieldSpec(
220
267
  name=name,
221
268
  annotation=annotation,
222
269
  translation=deferred,
223
- default=raw_default if not isinstance(raw_default, Field) else MISSING,
270
+ default=raw_default,
224
271
  usage_mode="readwrite",
225
272
  )
226
273
  # Above branches return for ``TypeVar`` / ``ForwardRef`` cases; the
@@ -464,17 +511,33 @@ class ModelMeta(type):
464
511
  """
465
512
  seen: dict[str, FieldSpec] = {}
466
513
 
467
- # walk MRO in reverse so derived classes overwrite base entries
514
+ # walk MRO in reverse so derived classes overwrite base entries.
515
+ # For ancestor classes the FieldSpec is already finalised
516
+ # (defaults captured during their own construction); copy the
517
+ # spec verbatim. For ``target`` itself we re-run
518
+ # ``_build_field_spec`` because the raw default still lives on
519
+ # the class dict and hasn't been stripped yet. Reading
520
+ # ``__dict__`` for ancestor classes would surface no default,
521
+ # because the metaclass strips field defaults from the class
522
+ # dict at the end of each Model's class-creation step.
468
523
  for klass in reversed(target.__mro__):
469
524
  if not isinstance(klass, ModelMeta):
470
525
  continue
471
- anns = _read_class_annotations(klass)
472
- for fname, ann in anns.items():
473
- # skip private / dunder attributes
474
- if fname.startswith("_"):
526
+ if klass is target:
527
+ anns = read_class_annotations(klass)
528
+ for fname, ann in anns.items():
529
+ if fname.startswith("_"):
530
+ continue
531
+ raw_default = klass.__dict__.get(fname, MISSING)
532
+ seen[fname] = _build_field_spec(fname, ann, raw_default)
533
+ else:
534
+ ancestor_specs = getattr(klass, "__field_specs__", None)
535
+ if ancestor_specs is None:
475
536
  continue
476
- raw_default = klass.__dict__.get(fname, MISSING)
477
- seen[fname] = _build_field_spec(fname, ann, raw_default)
537
+ for fname, spec in ancestor_specs.items():
538
+ if fname.startswith("_"):
539
+ continue
540
+ seen[fname] = spec
478
541
 
479
542
  return seen
480
543
 
@@ -28,15 +28,26 @@ from __future__ import annotations
28
28
  import json
29
29
  from datetime import date, datetime, time
30
30
  from decimal import Decimal
31
- from typing import TYPE_CHECKING, ClassVar, Self, cast
31
+ from types import UnionType
32
+ from typing import (
33
+ TYPE_CHECKING,
34
+ Annotated,
35
+ ClassVar,
36
+ Self,
37
+ TypeVar,
38
+ Union,
39
+ cast,
40
+ get_args,
41
+ get_origin,
42
+ )
32
43
  from uuid import UUID
33
44
 
34
- from didactic.fields._fields import MissingType
45
+ from didactic.fields._fields import MISSING, Field, MissingType
35
46
  from didactic.fields._validators import (
36
47
  ValidationError,
37
48
  ValidationErrorEntry,
38
49
  )
39
- from didactic.models._meta import ModelMeta
50
+ from didactic.models._meta import ModelMeta, read_class_annotations
40
51
  from didactic.models._storage import DictStorage
41
52
 
42
53
  if TYPE_CHECKING:
@@ -90,6 +101,42 @@ class Model(metaclass=ModelMeta):
90
101
  __computed_fields__: ClassVar[frozenset[str]] = frozenset()
91
102
  __class_axioms__: ClassVar[tuple[Axiom, ...]] = ()
92
103
 
104
+ def __class_getitem__(cls, params: object) -> type[Model]:
105
+ """Auto-parameterise a generic Model on subscript.
106
+
107
+ ``Range[int]`` returns a concrete subclass of ``Range`` whose
108
+ ``T``-typed fields use ``int``. The synthesised class is
109
+ cached per ``cls`` and per type-arg tuple, so repeated
110
+ subscripts return the same class object (and that class's
111
+ ``Theory`` is built once).
112
+
113
+ Falls back to typing's machinery when ``cls`` declares no
114
+ ``TypeVar`` field annotations to substitute (the subscript
115
+ becomes a structural ``_GenericAlias`` for type checkers
116
+ only, no runtime synthesis).
117
+
118
+ Parameters
119
+ ----------
120
+ params
121
+ A type or a tuple of types matching the class's
122
+ ``__parameters__`` arity.
123
+
124
+ Returns
125
+ -------
126
+ type
127
+ A synthesised concrete subclass when at least one field
128
+ annotation can be substituted; otherwise the upstream
129
+ ``_GenericAlias``.
130
+
131
+ Notes
132
+ -----
133
+ Only bare ``TypeVar`` annotations are substituted. Annotations
134
+ that contain a ``TypeVar`` nested inside a generic shape
135
+ (e.g. ``items: tuple[T, ...]``) stay unsubstituted and
136
+ surface the existing ``TypeVar``-encode error.
137
+ """
138
+ return _parameterise(cls, params)
139
+
93
140
  def __init__(self, **kwargs: FieldValue | JsonValue) -> None:
94
141
  """Construct a Model from keyword arguments.
95
142
 
@@ -153,9 +200,15 @@ class Model(metaclass=ModelMeta):
153
200
  from didactic.fields._derived import derived_field_names # noqa: PLC0415
154
201
 
155
202
  derived_names = derived_field_names(cls)
203
+ # ``extra="ignore"`` silently drops unknown kwargs at construction.
204
+ # ``with_()`` stays strict regardless: an unknown kwarg there is
205
+ # always a programming error (see ``Model.with_``).
206
+ ignore_extra = cls.__model_config__.extra == "ignore"
156
207
  for k in kwargs:
157
208
  if k in specs or k in computed_names or k in derived_names:
158
209
  continue
210
+ if ignore_extra:
211
+ continue
159
212
  errors.append(
160
213
  ValidationErrorEntry(
161
214
  loc=(k,),
@@ -681,7 +734,225 @@ class Model(metaclass=ModelMeta):
681
734
  # ---------------------------------------------------------------------------
682
735
 
683
736
 
684
- def _to_json_safe(value: FieldValue | JsonValue) -> JsonValue: # noqa: PLR0911
737
+ def _parameterise(cls: type[Model], params: object) -> type[Model]:
738
+ """Synthesise (or fetch from cache) a concrete subclass of a generic ``cls``.
739
+
740
+ Implementation of ``Model.__class_getitem__``; lifted to a
741
+ module-level function so the static-type narrowing of ``params``
742
+ isn't trapped behind a class scope's restricted self/cls vocabulary.
743
+ """
744
+ params_tuple: tuple[object, ...] = (
745
+ cast("tuple[object, ...]", params) if isinstance(params, tuple) else (params,)
746
+ )
747
+
748
+ typevars: tuple[TypeVar, ...] = cast(
749
+ "tuple[TypeVar, ...]", getattr(cls, "__parameters__", ())
750
+ )
751
+ if not typevars or len(typevars) != len(params_tuple):
752
+ return _fallback_subscript(cls, params_tuple)
753
+
754
+ cache: dict[tuple[object, ...], type[Model]] | None = cls.__dict__.get(
755
+ "__parameterised_cache__"
756
+ )
757
+ if cache is None:
758
+ cache = {}
759
+ # ``setattr`` rather than direct assignment so the metaclass's
760
+ # frozen-class guard doesn't fire on this private bookkeeping
761
+ # attribute (it's not in ``__field_specs__``).
762
+ cls.__parameterised_cache__ = cache # type: ignore[attr-defined]
763
+ cached = cache.get(params_tuple)
764
+ if cached is not None:
765
+ return cached
766
+
767
+ substitution: dict[TypeVar, object] = dict(zip(typevars, params_tuple, strict=True))
768
+ base_anns = read_class_annotations(cls)
769
+ new_anns: dict[str, object] = {}
770
+ for fname, ann in base_anns.items():
771
+ substituted = _substitute_typevars(ann, substitution)
772
+ if substituted is not ann:
773
+ new_anns[fname] = substituted
774
+ if not new_anns:
775
+ return _fallback_subscript(cls, params_tuple)
776
+
777
+ arg_names = ", ".join(getattr(p, "__name__", str(p)) for p in params_tuple)
778
+ new_name = f"{cls.__name__}[{arg_names}]"
779
+ namespace: dict[str, object] = {
780
+ "__annotations__": new_anns,
781
+ "__module__": cls.__module__,
782
+ "__qualname__": new_name,
783
+ }
784
+ # Propagate every parent FieldSpec's default and metadata onto the
785
+ # synthesised class so the metaclass's MRO walk sees them as
786
+ # own-class defaults. Without this stamp, the substituted-annotation
787
+ # re-build sees raw_default=MISSING for the synth class and treats
788
+ # the field as required.
789
+ parent_specs: dict[str, FieldSpec] = getattr(cls, "__field_specs__", {})
790
+ for fname in new_anns:
791
+ spec = parent_specs.get(fname)
792
+ if spec is None:
793
+ continue
794
+ descriptor = _field_descriptor_from_spec(spec)
795
+ # MISSING is the sentinel meaning "no default and no metadata
796
+ # to propagate"; the field stays required on the synthesised
797
+ # class. Any other value (including ``None``) IS the default.
798
+ if descriptor is not MISSING:
799
+ namespace[fname] = descriptor
800
+ new_cls = cast("type[Model]", type(new_name, (cls,), namespace))
801
+ cache[params_tuple] = new_cls
802
+ return new_cls
803
+
804
+
805
+ def _substitute_typevars(
806
+ annotation: object, substitution: dict[TypeVar, object]
807
+ ) -> object:
808
+ """Substitute ``TypeVar`` leaves in ``annotation`` against ``substitution``.
809
+
810
+ Walks through nested generic shapes and returns a new annotation
811
+ with every ``TypeVar`` replaced by its concrete type. Returns the
812
+ original ``annotation`` object when no substitution applies, so
813
+ callers can use identity comparison to detect changes.
814
+
815
+ Handled shapes:
816
+
817
+ - bare ``TypeVar`` -> the substitution target.
818
+ - parameterised generic (``list[T]``, ``tuple[T, U]``,
819
+ ``tuple[T, ...]``, ``dict[str, T]``, ``frozenset[T]``,
820
+ ``Embed[T]``, ``Ref[T]``, ``Annotated[T, ...]``, etc.) -> the
821
+ origin re-subscripted with substituted args.
822
+ - union (``T | int``, ``Union[T, str]``) -> a new union with each
823
+ arm substituted.
824
+ - non-generic types and ``ForwardRef`` -> returned as-is.
825
+
826
+ Recursion bottoms out at non-substitutable leaves (real types,
827
+ forward references, primitive literals).
828
+ """
829
+ # bare TypeVar leaf
830
+ if isinstance(annotation, TypeVar):
831
+ return substitution.get(annotation, annotation)
832
+
833
+ args = get_args(annotation)
834
+ if not args:
835
+ return annotation
836
+
837
+ new_args = tuple(_substitute_typevars(a, substitution) for a in args)
838
+ if new_args == args:
839
+ return annotation
840
+
841
+ origin = get_origin(annotation)
842
+ # PEP 604 / typing union: rebuild via ``typing.Union[(...)]`` which
843
+ # accepts a tuple of arms and produces the appropriate union form.
844
+ # Ruff prefers ``X | Y``, but that can't be expressed for a runtime
845
+ # tuple of arms; ``noqa: UP007`` keeps the dynamic form.
846
+ if origin in {Union, UnionType}:
847
+ return Union[new_args] # noqa: UP007
848
+ # ``Annotated[T, *meta]`` keeps its metadata tuple. Pyright's
849
+ # static check on ``Annotated[<dynamic-tuple>]`` requires the args
850
+ # to be statically known. Route through ``typing._SpecialForm``'s
851
+ # subscript via the standard ``[...]`` syntax wrapped in ``cast``
852
+ # to a plain subscriptable; the runtime construction works because
853
+ # ``obj[a, b, c]`` is sugar for ``obj.__getitem__((a, b, c))``.
854
+ if origin is Annotated:
855
+ meta = args[1:]
856
+ new_base = new_args[0]
857
+ if new_base is args[0]:
858
+ return annotation
859
+ # ``Annotated`` is a ``typing._SpecialForm`` whose ``__getitem__``
860
+ # accepts a tuple ``(base, *meta)``. The subscript syntax compiles
861
+ # to that call exactly; pyright doesn't model the special form, so
862
+ # cast to a subscriptable proxy.
863
+ annotated_subscript: object = Annotated
864
+ return cast("object", annotated_subscript[(new_base, *meta)]) # type: ignore[index]
865
+ # Generic alias: re-subscript the origin with the new args.
866
+ # ``tuple[T, ...]`` keeps the Ellipsis sentinel verbatim because
867
+ # ``Ellipsis`` is not a TypeVar (substitution returns it unchanged).
868
+ if origin is None:
869
+ return annotation
870
+ if not new_args:
871
+ return annotation
872
+ try:
873
+ return origin[new_args[0]] if len(new_args) == 1 else origin[new_args]
874
+ except TypeError:
875
+ return annotation
876
+
877
+
878
+ def _field_descriptor_from_spec(spec: FieldSpec) -> object:
879
+ """Reconstruct a ``Field`` descriptor (or bare default) from a ``FieldSpec``.
880
+
881
+ Used by ``_parameterise`` to propagate a generic-parent's defaults
882
+ and metadata onto the synthesised concrete subclass. Returns the
883
+ sentinel ``MISSING`` when the parent spec carried neither a
884
+ default nor any metadata that needs to round-trip through
885
+ ``Field``: in that case the field stays required on the
886
+ synthesised class, matching the parent's behaviour. Otherwise
887
+ returns either the bare default value (for plain ``f: T = 0``)
888
+ or a fully-populated ``Field`` descriptor (for any spec carrying
889
+ metadata beyond a default).
890
+ """
891
+ has_default = spec.default is not MISSING
892
+ has_factory = spec.default_factory is not None
893
+ has_metadata = bool(
894
+ spec.alias
895
+ or spec.description
896
+ or spec.examples
897
+ or spec.deprecated
898
+ or spec.nominal
899
+ or spec.converter
900
+ or spec.usage_mode != "readwrite"
901
+ or spec.extras
902
+ )
903
+ if not (has_default or has_factory or has_metadata):
904
+ return MISSING
905
+ if has_default and not has_metadata and not has_factory:
906
+ return spec.default
907
+ return Field(
908
+ default=spec.default,
909
+ default_factory=spec.default_factory,
910
+ converter=spec.converter,
911
+ alias=spec.alias,
912
+ description=spec.description,
913
+ examples=spec.examples,
914
+ deprecated=spec.deprecated,
915
+ nominal=spec.nominal,
916
+ usage_mode=spec.usage_mode,
917
+ extras=dict(spec.extras) if spec.extras else None,
918
+ )
919
+
920
+
921
+ def _fallback_subscript(cls: type[Model], params: object) -> type[Model]:
922
+ """Defer a non-synthesisable ``cls[params]`` to typing's machinery.
923
+
924
+ When ``cls`` declares no ``Generic[...]`` parameters, or when
925
+ ``params`` doesn't substitute any of its TypeVar field
926
+ annotations, the subscript falls through here. ``Generic`` (if
927
+ present in the MRO) returns a ``_GenericAlias`` for the static
928
+ type checker; otherwise typing raises ``TypeError``.
929
+ """
930
+ # Walk the MRO past ``Model`` itself to find an ancestor's
931
+ # ``__class_getitem__`` (typically ``Generic.__class_getitem__``
932
+ # when the user wrote ``class Foo(dx.Model, Generic[T]): ...``).
933
+ # If no ancestor defines it, raise the same error typing would
934
+ # have raised for a non-generic class.
935
+ for base in cls.__mro__:
936
+ if base is Model:
937
+ continue
938
+ descriptor = base.__dict__.get("__class_getitem__")
939
+ if descriptor is None:
940
+ continue
941
+ # The descriptor is a classmethod (Generic), classmethod_descriptor
942
+ # (object), or function. ``__get__(None, cls)`` rebinds it to
943
+ # ``cls`` so the call invokes ``base.__class_getitem__(cls, params)``
944
+ # with ``cls`` as the receiver, matching the lookup ``cls[params]``
945
+ # would have produced if ``Model`` were not in the MRO.
946
+ rebound = descriptor.__get__(None, cls)
947
+ return cast("type[Model]", rebound(params))
948
+ msg = (
949
+ f"{cls.__name__!r} does not declare any ``Generic[...]`` "
950
+ "type parameters; subscripting is not supported."
951
+ )
952
+ raise TypeError(msg)
953
+
954
+
955
+ def _to_json_safe(value: FieldValue | JsonValue) -> JsonValue:
685
956
  """Recursively coerce a decoded value into a JSON-encodable form.
686
957
 
687
958
  Parameters
@@ -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