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.
- {didactic-0.3.2 → didactic-0.4.1}/PKG-INFO +1 -1
- {didactic-0.3.2 → didactic-0.4.1}/pyproject.toml +1 -1
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/api.py +1 -1
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_json_schema.py +0 -3
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_derived.py +0 -6
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_unions.py +0 -4
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_diff.py +0 -3
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_fingerprint.py +0 -4
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_config.py +16 -6
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_meta.py +81 -18
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_model.py +275 -4
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_types.py +26 -3
- {didactic-0.3.2 → didactic-0.4.1}/.gitignore +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/README.md +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/_self_describing.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/_axiom_enforcement.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/axioms/_axioms.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/cli/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/cli/_cli.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_emitter.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/_write.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/io.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/codegen/source.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_computed.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_fields.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_refs.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/fields/_validators.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_dependent_lens.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_lens.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/lenses/_testing.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_migrations.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/migrations/_synthesis.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_root.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/models/_storage.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/py.typed +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/theory/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/theory/_theory.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_types_lib.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/types/_typing.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/vcs/__init__.py +0 -0
- {didactic-0.3.2 → didactic-0.4.1}/src/didactic/vcs/_backref.py +0 -0
- {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
|
+
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
|
|
@@ -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.
|
|
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.
|
|
50
|
-
``"
|
|
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
|
-
#
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|