didactic 0.2.0__tar.gz → 0.3.0__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.2.0 → didactic-0.3.0}/PKG-INFO +1 -1
- {didactic-0.2.0 → didactic-0.3.0}/pyproject.toml +1 -1
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/api.py +1 -1
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_model.py +9 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/theory/_theory.py +22 -1
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_types.py +691 -15
- {didactic-0.2.0 → didactic-0.3.0}/.gitignore +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/README.md +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/_self_describing.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/_axiom_enforcement.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/_axioms.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/cli/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/cli/_cli.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_emitter.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_json_schema.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_write.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/io.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/source.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_computed.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_derived.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_fields.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_refs.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_unions.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_validators.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_dependent_lens.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_lens.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_testing.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_diff.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_fingerprint.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_migrations.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_synthesis.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_config.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_meta.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_root.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_storage.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/py.typed +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/theory/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_types_lib.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_typing.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/vcs/__init__.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/src/didactic/vcs/_backref.py +0 -0
- {didactic-0.2.0 → didactic-0.3.0}/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.3.0
|
|
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.3.0"
|
|
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
|
|
@@ -340,6 +340,15 @@ class Model(metaclass=ModelMeta):
|
|
|
340
340
|
if isinstance(value, Model):
|
|
341
341
|
# Embed[T] fields: recurse into the sub-model
|
|
342
342
|
result[key] = value.model_dump(by_alias=by_alias)
|
|
343
|
+
elif spec.translation.inner_kind == "sum":
|
|
344
|
+
# Sum-sort fields (Model-ref recursive aliases) carry
|
|
345
|
+
# constructor-tag dispatch info that gets lost if we drop
|
|
346
|
+
# the value into the dump as-is. Route through the
|
|
347
|
+
# translation's encoder to produce the tagged JsonValue
|
|
348
|
+
# shape; ``model_validate_json`` reverses it.
|
|
349
|
+
result[key] = cast(
|
|
350
|
+
"FieldValue", json.loads(spec.translation.encode(value))
|
|
351
|
+
)
|
|
343
352
|
else:
|
|
344
353
|
result[key] = value
|
|
345
354
|
# computed fields evaluate on access; include them in the dump but
|
|
@@ -40,7 +40,7 @@ didactic.fields._fields.FieldSpec : the per-field record consumed here.
|
|
|
40
40
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
|
-
from typing import TYPE_CHECKING, TypedDict
|
|
43
|
+
from typing import TYPE_CHECKING, TypedDict, cast
|
|
44
44
|
|
|
45
45
|
if TYPE_CHECKING:
|
|
46
46
|
import panproto
|
|
@@ -119,7 +119,22 @@ def build_theory_spec(cls: type[Model]) -> TheorySpec:
|
|
|
119
119
|
# is its own piece of work (panproto-Expr-parser hookup).
|
|
120
120
|
eqs: list[dict[str, JsonValue]] = []
|
|
121
121
|
|
|
122
|
+
# auxiliary sorts/ops contributed by translations (currently only the
|
|
123
|
+
# Model-ref recursive-alias sum-sort translation). Deduped by ``name``.
|
|
124
|
+
seen_aux_sorts: set[str] = set()
|
|
125
|
+
seen_aux_ops: set[str] = set()
|
|
126
|
+
|
|
122
127
|
for fname, spec in field_specs.items():
|
|
128
|
+
for aux_sort in spec.translation.auxiliary_sorts:
|
|
129
|
+
sort_name = cast("str", aux_sort["name"])
|
|
130
|
+
if sort_name not in seen_aux_sorts:
|
|
131
|
+
seen_aux_sorts.add(sort_name)
|
|
132
|
+
sorts.append(aux_sort)
|
|
133
|
+
for aux_op in spec.translation.auxiliary_ops:
|
|
134
|
+
op_name = cast("str", aux_op["name"])
|
|
135
|
+
if op_name not in seen_aux_ops:
|
|
136
|
+
seen_aux_ops.add(op_name)
|
|
137
|
+
ops.append(aux_op)
|
|
123
138
|
if spec.translation.inner_kind == "ref":
|
|
124
139
|
# Ref[T] becomes a structural edge from this sort to T's sort
|
|
125
140
|
target_sort = spec.translation.sort.removeprefix("Ref ").strip()
|
|
@@ -132,6 +147,12 @@ def build_theory_spec(cls: type[Model]) -> TheorySpec:
|
|
|
132
147
|
target_sort = spec.translation.sort.removeprefix("Embed ").strip()
|
|
133
148
|
ops.append(_embed_accessor(fname, schema_kind, target_sort))
|
|
134
149
|
continue
|
|
150
|
+
if spec.translation.inner_kind == "sum":
|
|
151
|
+
# Model-ref recursive alias: the alias's sum sort is already
|
|
152
|
+
# in ``auxiliary_sorts`` above; the field accessor returns
|
|
153
|
+
# that sort directly (no per-field constraint sort needed).
|
|
154
|
+
ops.append(_edge_accessor(fname, schema_kind, spec.translation.sort))
|
|
155
|
+
continue
|
|
135
156
|
# constraint sort name follows panproto convention: ParentSort_field
|
|
136
157
|
constraint_sort_name = f"{schema_kind}_{fname}"
|
|
137
158
|
sorts.append(_constraint_sort(constraint_sort_name, spec.translation.sort))
|
|
@@ -52,16 +52,22 @@ from typing import (
|
|
|
52
52
|
from uuid import UUID
|
|
53
53
|
|
|
54
54
|
if TYPE_CHECKING:
|
|
55
|
-
from collections.abc import Callable
|
|
55
|
+
from collections.abc import Callable, Mapping
|
|
56
56
|
|
|
57
|
+
from didactic.models._model import Model
|
|
57
58
|
from didactic.types._typing import Encoded, FieldValue, JsonValue, Opaque
|
|
58
59
|
|
|
60
|
+
# A theory-spec record contributed by a translation: a sort declaration
|
|
61
|
+
# or an operation declaration. The shape mirrors ``build_theory_spec``'s
|
|
62
|
+
# spec dicts (sorts/ops are JSON-serialisable maps).
|
|
63
|
+
type SpecRecord = dict[str, "JsonValue"]
|
|
64
|
+
|
|
59
65
|
# The widest annotation form ``classify`` accepts. Includes the nominal
|
|
60
66
|
# ``type`` (covering bare classes and pyright's special-cased parameterised
|
|
61
67
|
# generics like ``tuple[int, ...]`` and ``dict[str, int]``) plus PEP 604
|
|
62
68
|
# ``UnionType`` (``int | None``), which pyright does *not* narrow to
|
|
63
|
-
# ``type``. Other typing special forms
|
|
64
|
-
# ``Annotated[...]``, PEP 695 type aliases
|
|
69
|
+
# ``type``. Other typing special forms (``typing.Union[...]``,
|
|
70
|
+
# ``Annotated[...]``, PEP 695 type aliases) are accepted at runtime but
|
|
65
71
|
# fall outside this static union; callers that pass them rely on pyright's
|
|
66
72
|
# legacy structural acceptance of those forms.
|
|
67
73
|
TypeForm = type | UnionType
|
|
@@ -117,6 +123,25 @@ class TypeTranslation:
|
|
|
117
123
|
represents.
|
|
118
124
|
"""
|
|
119
125
|
|
|
126
|
+
auxiliary_sorts: tuple[SpecRecord, ...] = ()
|
|
127
|
+
"""Extra sort declarations the parent Model's Theory must include.
|
|
128
|
+
|
|
129
|
+
Populated by translations that generate auxiliary panproto sorts
|
|
130
|
+
beyond the field's primary sort: currently only the Model-ref
|
|
131
|
+
recursive-alias translation, which contributes a closed sum sort
|
|
132
|
+
plus optional ``List`` / ``Map`` helper sorts. Empty for every
|
|
133
|
+
other translation. ``build_theory_spec`` walks this on each field
|
|
134
|
+
spec and merges by sort name (later duplicates dropped).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
auxiliary_ops: tuple[SpecRecord, ...] = ()
|
|
138
|
+
"""Extra operation declarations the parent Model's Theory must include.
|
|
139
|
+
|
|
140
|
+
The constructor operations of any auxiliary sum / list / map sort
|
|
141
|
+
declared in :attr:`auxiliary_sorts`. Walked alongside the sorts
|
|
142
|
+
in ``build_theory_spec``; deduped by op name.
|
|
143
|
+
"""
|
|
144
|
+
|
|
120
145
|
|
|
121
146
|
class TypeNotSupportedError(TypeError):
|
|
122
147
|
"""Raised when a Python type cannot be translated to a panproto sort."""
|
|
@@ -535,6 +560,636 @@ def _json_alias_translation(alias_name: str) -> TypeTranslation:
|
|
|
535
560
|
)
|
|
536
561
|
|
|
537
562
|
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
# Model-ref recursive type aliases (closed sum sort)
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
# A "Model-ref recursive alias" is a recursive type alias whose arms
|
|
568
|
+
# include at least one ``dx.Model`` subclass alongside the JSON-shape
|
|
569
|
+
# allow-list (primitive scalars, list/tuple/dict containers,
|
|
570
|
+
# self-references). The motivating shape is::
|
|
571
|
+
#
|
|
572
|
+
# class Heading(dx.Model):
|
|
573
|
+
# text: str
|
|
574
|
+
#
|
|
575
|
+
# type Component = (
|
|
576
|
+
# str | int | Heading
|
|
577
|
+
# | list["Component"] | dict[str, "Component"]
|
|
578
|
+
# )
|
|
579
|
+
#
|
|
580
|
+
# These translate to a panproto-native closed sum sort. The metaclass
|
|
581
|
+
# emits a ``Structural`` sort named after the alias whose
|
|
582
|
+
# ``SortClosure`` is ``Closed`` against one ``Operation`` per arm:
|
|
583
|
+
#
|
|
584
|
+
# Component_str(v: Component_str_value) -> Component
|
|
585
|
+
# Component_int(v: Component_int_value) -> Component
|
|
586
|
+
# Component_heading(v: Heading) -> Component
|
|
587
|
+
# Component_list(v: Component_List) -> Component
|
|
588
|
+
# Component_dict(v: Component_Map) -> Component
|
|
589
|
+
#
|
|
590
|
+
# ``Term::Case`` over the resulting sort is exhaustiveness-checked by
|
|
591
|
+
# panproto-gat. Wire format is a single-key JSON object whose key is
|
|
592
|
+
# the constructor name (matches the panproto term-of-closed-sort
|
|
593
|
+
# encoding); see :func:`_alias_sum_translation` for the encoder.
|
|
594
|
+
|
|
595
|
+
# Container payload sorts are Val-kinded with ``Str`` payloads in this
|
|
596
|
+
# release (Phase A of the plan). Phase B promotes them to parametric
|
|
597
|
+
# structural sorts shared with non-alias list/dict fields.
|
|
598
|
+
|
|
599
|
+
_PRIMITIVE_TAGS: dict[type, str] = {
|
|
600
|
+
str: "str",
|
|
601
|
+
int: "int",
|
|
602
|
+
float: "float",
|
|
603
|
+
bool: "bool",
|
|
604
|
+
type(None): "none",
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@dataclass(frozen=True, slots=True)
|
|
609
|
+
class _AliasSignature:
|
|
610
|
+
"""Structured summary of a Model-ref recursive alias's arm set."""
|
|
611
|
+
|
|
612
|
+
model_arms: tuple[type, ...]
|
|
613
|
+
primitives: frozenset[type]
|
|
614
|
+
has_list: bool
|
|
615
|
+
has_tuple: bool
|
|
616
|
+
has_dict: bool
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _arm_is_model_or_json_shape(
|
|
620
|
+
arm: object,
|
|
621
|
+
alias_name: str,
|
|
622
|
+
alias_id: int,
|
|
623
|
+
depth: int,
|
|
624
|
+
model_arms_out: list[type],
|
|
625
|
+
) -> bool:
|
|
626
|
+
"""Predicate for the wider Model-ref allow-list.
|
|
627
|
+
|
|
628
|
+
Same as :func:`_arm_is_json_shape` but also admits ``dx.Model``
|
|
629
|
+
subclasses; each Model class encountered is appended (deduped by
|
|
630
|
+
identity) to ``model_arms_out`` so the caller can build the
|
|
631
|
+
constructor-name table without a second walk.
|
|
632
|
+
"""
|
|
633
|
+
if depth > 64:
|
|
634
|
+
return False
|
|
635
|
+
if isinstance(arm, type):
|
|
636
|
+
if arm in _JSON_PRIMITIVE_TYPES:
|
|
637
|
+
return True
|
|
638
|
+
if _is_model_class(arm):
|
|
639
|
+
if arm not in model_arms_out:
|
|
640
|
+
model_arms_out.append(arm)
|
|
641
|
+
return True
|
|
642
|
+
if isinstance(arm, str) and arm == alias_name:
|
|
643
|
+
return True
|
|
644
|
+
if isinstance(arm, TypeAliasType) and id(arm) == alias_id:
|
|
645
|
+
return True
|
|
646
|
+
origin = get_origin(arm)
|
|
647
|
+
args = get_args(arm)
|
|
648
|
+
if origin is list:
|
|
649
|
+
return len(args) == 1 and _arm_is_model_or_json_shape(
|
|
650
|
+
args[0], alias_name, alias_id, depth + 1, model_arms_out
|
|
651
|
+
)
|
|
652
|
+
if origin is tuple:
|
|
653
|
+
return (
|
|
654
|
+
len(args) == 2
|
|
655
|
+
and args[1] is Ellipsis
|
|
656
|
+
and _arm_is_model_or_json_shape(
|
|
657
|
+
args[0], alias_name, alias_id, depth + 1, model_arms_out
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
if origin is dict:
|
|
661
|
+
return (
|
|
662
|
+
len(args) == 2
|
|
663
|
+
and args[0] is str
|
|
664
|
+
and _arm_is_model_or_json_shape(
|
|
665
|
+
args[1], alias_name, alias_id, depth + 1, model_arms_out
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
if origin in {Union, UnionType}:
|
|
669
|
+
return all(
|
|
670
|
+
_arm_is_model_or_json_shape(
|
|
671
|
+
a, alias_name, alias_id, depth + 1, model_arms_out
|
|
672
|
+
)
|
|
673
|
+
for a in args
|
|
674
|
+
)
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _is_model_class(cls: type) -> bool:
|
|
679
|
+
"""Return True iff ``cls`` is a ``dx.Model`` subclass.
|
|
680
|
+
|
|
681
|
+
Imports ``Model`` lazily to avoid an import cycle (``models._model``
|
|
682
|
+
indirectly imports the types module).
|
|
683
|
+
"""
|
|
684
|
+
from didactic.models._model import Model # noqa: PLC0415
|
|
685
|
+
|
|
686
|
+
return issubclass(cls, Model)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _collect_alias_signature(alias: TypeAliasType) -> _AliasSignature:
|
|
690
|
+
"""Walk a recursive Model-ref alias, return its arm signature.
|
|
691
|
+
|
|
692
|
+
Assumes the alias has already passed
|
|
693
|
+
:func:`_arm_is_model_or_json_shape`; the returned signature is
|
|
694
|
+
well-formed.
|
|
695
|
+
"""
|
|
696
|
+
model_arms: list[type] = []
|
|
697
|
+
primitives: set[type] = set()
|
|
698
|
+
flags = {"list": False, "tuple": False, "dict": False}
|
|
699
|
+
aname = alias.__name__
|
|
700
|
+
aid = id(alias)
|
|
701
|
+
|
|
702
|
+
def walk(arm: object, depth: int) -> None:
|
|
703
|
+
if depth > 64:
|
|
704
|
+
return
|
|
705
|
+
if isinstance(arm, type):
|
|
706
|
+
if arm in _JSON_PRIMITIVE_TYPES:
|
|
707
|
+
primitives.add(arm)
|
|
708
|
+
return
|
|
709
|
+
if _is_model_class(arm):
|
|
710
|
+
if arm not in model_arms:
|
|
711
|
+
model_arms.append(arm)
|
|
712
|
+
return
|
|
713
|
+
if isinstance(arm, str) and arm == aname:
|
|
714
|
+
return
|
|
715
|
+
if isinstance(arm, TypeAliasType) and id(arm) == aid:
|
|
716
|
+
return
|
|
717
|
+
origin = get_origin(arm)
|
|
718
|
+
args = get_args(arm)
|
|
719
|
+
if origin is list:
|
|
720
|
+
flags["list"] = True
|
|
721
|
+
walk(args[0], depth + 1)
|
|
722
|
+
return
|
|
723
|
+
if origin is tuple:
|
|
724
|
+
flags["tuple"] = True
|
|
725
|
+
walk(args[0], depth + 1)
|
|
726
|
+
return
|
|
727
|
+
if origin is dict:
|
|
728
|
+
flags["dict"] = True
|
|
729
|
+
walk(args[1], depth + 1)
|
|
730
|
+
return
|
|
731
|
+
if origin in {Union, UnionType}:
|
|
732
|
+
for a in args:
|
|
733
|
+
walk(a, depth + 1)
|
|
734
|
+
|
|
735
|
+
walk(alias.__value__, 0)
|
|
736
|
+
return _AliasSignature(
|
|
737
|
+
model_arms=tuple(model_arms),
|
|
738
|
+
primitives=frozenset(primitives),
|
|
739
|
+
has_list=flags["list"],
|
|
740
|
+
has_tuple=flags["tuple"],
|
|
741
|
+
has_dict=flags["dict"],
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _model_arm_tag(cls: type) -> str:
|
|
746
|
+
"""Build the constructor tag fragment for a Model arm.
|
|
747
|
+
|
|
748
|
+
Lower-cases the class name and strips any leading underscores
|
|
749
|
+
(which conventionally mark test-only or private classes and
|
|
750
|
+
shouldn't bleed into the on-wire constructor name).
|
|
751
|
+
"""
|
|
752
|
+
return cls.__name__.lstrip("_").lower()
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _alias_constructor_table(
|
|
756
|
+
alias_name: str, sig: _AliasSignature
|
|
757
|
+
) -> dict[str, type | str]:
|
|
758
|
+
"""Map constructor tag (e.g. ``Component_int``) to its arm dispatch key.
|
|
759
|
+
|
|
760
|
+
For primitive arms the value is the Python ``type`` (used for
|
|
761
|
+
instance-checking on encode and value identity on decode). For
|
|
762
|
+
Model arms it's the Model class. For container arms the value is
|
|
763
|
+
a marker string (``"list"``, ``"tuple"``, ``"dict"``) so the
|
|
764
|
+
encoder can branch.
|
|
765
|
+
"""
|
|
766
|
+
table: dict[str, type | str] = {}
|
|
767
|
+
for typ in sig.primitives:
|
|
768
|
+
table[f"{alias_name}_{_PRIMITIVE_TAGS[typ]}"] = typ
|
|
769
|
+
for cls in sig.model_arms:
|
|
770
|
+
tag = f"{alias_name}_{_model_arm_tag(cls)}"
|
|
771
|
+
if tag in table:
|
|
772
|
+
msg = (
|
|
773
|
+
f"alias {alias_name!r} has two Model arms whose lowercased "
|
|
774
|
+
f"names collide on constructor tag {tag!r}; rename one of the "
|
|
775
|
+
"Model classes or use a wrapper subclass."
|
|
776
|
+
)
|
|
777
|
+
raise TypeNotSupportedError(msg)
|
|
778
|
+
table[tag] = cls
|
|
779
|
+
if sig.has_list:
|
|
780
|
+
table[f"{alias_name}_list"] = "list"
|
|
781
|
+
if sig.has_tuple:
|
|
782
|
+
table[f"{alias_name}_tuple"] = "tuple"
|
|
783
|
+
if sig.has_dict:
|
|
784
|
+
table[f"{alias_name}_dict"] = "dict"
|
|
785
|
+
return table
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _alias_aux_spec(
|
|
789
|
+
alias_name: str, table: dict[str, type | str], sig: _AliasSignature
|
|
790
|
+
) -> tuple[tuple[SpecRecord, ...], tuple[SpecRecord, ...]]:
|
|
791
|
+
"""Build the auxiliary sort/op records for the alias's Theory.
|
|
792
|
+
|
|
793
|
+
Returns
|
|
794
|
+
-------
|
|
795
|
+
tuple
|
|
796
|
+
``(sorts, ops)`` lists of plain dicts shaped for
|
|
797
|
+
:func:`build_theory_spec`'s consumption. Each constructor op
|
|
798
|
+
targets the alias's primary sum sort; the sum sort itself
|
|
799
|
+
carries a ``Closed`` closure listing every constructor name.
|
|
800
|
+
"""
|
|
801
|
+
constructor_names: list[JsonValue] = list(table)
|
|
802
|
+
sorts: list[SpecRecord] = [
|
|
803
|
+
{
|
|
804
|
+
"name": alias_name,
|
|
805
|
+
"params": [],
|
|
806
|
+
"kind": "Structural",
|
|
807
|
+
"closure": {"Closed": constructor_names},
|
|
808
|
+
}
|
|
809
|
+
]
|
|
810
|
+
ops: list[SpecRecord] = []
|
|
811
|
+
# primitive constructors take a Val-Str-kinded arg and produce the sum sort
|
|
812
|
+
for typ in sig.primitives:
|
|
813
|
+
tag = f"{alias_name}_{_PRIMITIVE_TAGS[typ]}"
|
|
814
|
+
arg_sort = f"{alias_name}__{_PRIMITIVE_TAGS[typ]}_value"
|
|
815
|
+
sorts.append(
|
|
816
|
+
{
|
|
817
|
+
"name": arg_sort,
|
|
818
|
+
"params": [],
|
|
819
|
+
"kind": {"Val": _value_kind_for_primitive(typ)},
|
|
820
|
+
"closure": "Open",
|
|
821
|
+
}
|
|
822
|
+
)
|
|
823
|
+
ops.append(
|
|
824
|
+
{
|
|
825
|
+
"name": tag,
|
|
826
|
+
"inputs": [["v", arg_sort, "No"]],
|
|
827
|
+
"output": alias_name,
|
|
828
|
+
}
|
|
829
|
+
)
|
|
830
|
+
# Model constructors take the Model's primary sort directly.
|
|
831
|
+
for cls in sig.model_arms:
|
|
832
|
+
tag = f"{alias_name}_{_model_arm_tag(cls)}"
|
|
833
|
+
ops.append(
|
|
834
|
+
{
|
|
835
|
+
"name": tag,
|
|
836
|
+
"inputs": [["v", cls.__name__, "No"]],
|
|
837
|
+
"output": alias_name,
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
# Container constructors share a Val-Str helper sort per container shape.
|
|
841
|
+
if sig.has_list or sig.has_tuple:
|
|
842
|
+
helper = f"{alias_name}__list_value"
|
|
843
|
+
sorts.append(
|
|
844
|
+
{
|
|
845
|
+
"name": helper,
|
|
846
|
+
"params": [],
|
|
847
|
+
"kind": {"Val": "Str"},
|
|
848
|
+
"closure": "Open",
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
if sig.has_list:
|
|
852
|
+
ops.append(
|
|
853
|
+
{
|
|
854
|
+
"name": f"{alias_name}_list",
|
|
855
|
+
"inputs": [["v", helper, "No"]],
|
|
856
|
+
"output": alias_name,
|
|
857
|
+
}
|
|
858
|
+
)
|
|
859
|
+
if sig.has_tuple:
|
|
860
|
+
ops.append(
|
|
861
|
+
{
|
|
862
|
+
"name": f"{alias_name}_tuple",
|
|
863
|
+
"inputs": [["v", helper, "No"]],
|
|
864
|
+
"output": alias_name,
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
if sig.has_dict:
|
|
868
|
+
helper = f"{alias_name}__dict_value"
|
|
869
|
+
sorts.append(
|
|
870
|
+
{
|
|
871
|
+
"name": helper,
|
|
872
|
+
"params": [],
|
|
873
|
+
"kind": {"Val": "Str"},
|
|
874
|
+
"closure": "Open",
|
|
875
|
+
}
|
|
876
|
+
)
|
|
877
|
+
ops.append(
|
|
878
|
+
{
|
|
879
|
+
"name": f"{alias_name}_dict",
|
|
880
|
+
"inputs": [["v", helper, "No"]],
|
|
881
|
+
"output": alias_name,
|
|
882
|
+
}
|
|
883
|
+
)
|
|
884
|
+
return tuple(sorts), tuple(ops)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
_PRIMITIVE_VALUE_KIND: dict[type, str] = {
|
|
888
|
+
str: "Str",
|
|
889
|
+
int: "Int",
|
|
890
|
+
float: "Float",
|
|
891
|
+
bool: "Bool",
|
|
892
|
+
type(None): "Null",
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _value_kind_for_primitive(typ: type) -> str:
|
|
897
|
+
"""Map a primitive Python type to its panproto ``ValueKind`` variant."""
|
|
898
|
+
return _PRIMITIVE_VALUE_KIND[typ]
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _alias_sum_translation(alias: TypeAliasType) -> TypeTranslation:
|
|
902
|
+
"""Build a TypeTranslation for a Model-ref recursive alias.
|
|
903
|
+
|
|
904
|
+
Encoded form is a single-key JSON object whose key is the
|
|
905
|
+
constructor name (e.g. ``"Component_heading"``) and whose value
|
|
906
|
+
is the constructor's payload (a primitive, a Model dump-dict,
|
|
907
|
+
or a JSON array / object of inner-encoded values for the
|
|
908
|
+
container constructors). The translation also exposes
|
|
909
|
+
:attr:`TypeTranslation.auxiliary_sorts` and ``auxiliary_ops``
|
|
910
|
+
so :func:`build_theory_spec` can splice the alias's sum sort
|
|
911
|
+
and constructor ops into the parent Model's Theory.
|
|
912
|
+
"""
|
|
913
|
+
alias_name = alias.__name__
|
|
914
|
+
sig = _collect_alias_signature(alias)
|
|
915
|
+
table = _alias_constructor_table(alias_name, sig)
|
|
916
|
+
aux_sorts, aux_ops = _alias_aux_spec(alias_name, table, sig)
|
|
917
|
+
|
|
918
|
+
# tag-by-Python-type for the encoder; primitives take precedence
|
|
919
|
+
# over Models (a Model that happens to subclass int would still
|
|
920
|
+
# round-trip via the int constructor, by design).
|
|
921
|
+
primitive_tag_by_type = {
|
|
922
|
+
typ: f"{alias_name}_{_PRIMITIVE_TAGS[typ]}" for typ in sig.primitives
|
|
923
|
+
}
|
|
924
|
+
model_tag_by_class = {
|
|
925
|
+
cls: f"{alias_name}_{_model_arm_tag(cls)}" for cls in sig.model_arms
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
def encode_one(value: object, seen: set[int]) -> JsonValue:
|
|
929
|
+
if id(value) in seen:
|
|
930
|
+
msg = (
|
|
931
|
+
f"alias {alias_name!r} value graph contains a cycle through "
|
|
932
|
+
f"object id={id(value)}; cycle support is out of scope for "
|
|
933
|
+
"this release."
|
|
934
|
+
)
|
|
935
|
+
raise ValueError(msg)
|
|
936
|
+
seen = seen | {id(value)}
|
|
937
|
+
# bool is an int subclass; check it before int.
|
|
938
|
+
if isinstance(value, bool) and bool in primitive_tag_by_type:
|
|
939
|
+
return {primitive_tag_by_type[bool]: value}
|
|
940
|
+
if value is None and type(None) in primitive_tag_by_type:
|
|
941
|
+
return {primitive_tag_by_type[type(None)]: None}
|
|
942
|
+
if isinstance(value, str) and str in primitive_tag_by_type:
|
|
943
|
+
return {primitive_tag_by_type[str]: value}
|
|
944
|
+
if isinstance(value, int) and int in primitive_tag_by_type:
|
|
945
|
+
return {primitive_tag_by_type[int]: value}
|
|
946
|
+
if isinstance(value, float) and float in primitive_tag_by_type:
|
|
947
|
+
return {primitive_tag_by_type[float]: value}
|
|
948
|
+
for cls, tag in model_tag_by_class.items():
|
|
949
|
+
if isinstance(value, cls):
|
|
950
|
+
# Model.model_dump returns the canonical record dict;
|
|
951
|
+
# the constructor payload IS that dict, no envelope.
|
|
952
|
+
model_value = cast("Model", value)
|
|
953
|
+
return {tag: cast("JsonValue", model_value.model_dump())}
|
|
954
|
+
if isinstance(value, tuple) and sig.has_tuple:
|
|
955
|
+
tup = cast("tuple[FieldValue, ...]", value)
|
|
956
|
+
inner: list[JsonValue] = [encode_one(item, seen) for item in tup]
|
|
957
|
+
return {f"{alias_name}_tuple": inner}
|
|
958
|
+
if isinstance(value, list) and sig.has_list:
|
|
959
|
+
lst = cast("list[FieldValue]", value)
|
|
960
|
+
inner = [encode_one(item, seen) for item in lst]
|
|
961
|
+
return {f"{alias_name}_list": inner}
|
|
962
|
+
if isinstance(value, dict) and sig.has_dict:
|
|
963
|
+
mapping = cast("dict[str, FieldValue]", value)
|
|
964
|
+
inner_obj: dict[str, JsonValue] = {
|
|
965
|
+
k: encode_one(v, seen) for k, v in mapping.items()
|
|
966
|
+
}
|
|
967
|
+
return {f"{alias_name}_dict": inner_obj}
|
|
968
|
+
msg = (
|
|
969
|
+
f"value of type {type(value).__name__} does not match any arm of "
|
|
970
|
+
f"alias {alias_name!r}; expected one of {sorted(table)}."
|
|
971
|
+
)
|
|
972
|
+
raise TypeError(msg)
|
|
973
|
+
|
|
974
|
+
def decode_one(payload: JsonValue) -> FieldValue:
|
|
975
|
+
if not isinstance(payload, dict):
|
|
976
|
+
msg = (
|
|
977
|
+
f"alias {alias_name!r} payload must be a single-key dict "
|
|
978
|
+
f"tagged by constructor; got {type(payload).__name__}."
|
|
979
|
+
)
|
|
980
|
+
raise TypeError(msg)
|
|
981
|
+
if len(payload) != 1:
|
|
982
|
+
msg = (
|
|
983
|
+
f"alias {alias_name!r} payload must have exactly one "
|
|
984
|
+
f"constructor key; got keys {sorted(payload)!r}."
|
|
985
|
+
)
|
|
986
|
+
raise ValueError(msg)
|
|
987
|
+
((tag, body),) = payload.items()
|
|
988
|
+
if tag not in table:
|
|
989
|
+
msg = (
|
|
990
|
+
f"alias {alias_name!r} payload uses unknown constructor "
|
|
991
|
+
f"{tag!r}; expected one of {sorted(table)!r}."
|
|
992
|
+
)
|
|
993
|
+
raise KeyError(tag)
|
|
994
|
+
target = table[tag]
|
|
995
|
+
if isinstance(target, type):
|
|
996
|
+
if target in _JSON_PRIMITIVE_TYPES:
|
|
997
|
+
# primitive arm; payload is the literal value
|
|
998
|
+
return cast("FieldValue", body)
|
|
999
|
+
# Model arm; ``target`` came from the alias's collected
|
|
1000
|
+
# Model classes so it's known to expose ``model_validate``.
|
|
1001
|
+
model_cls = cast("type[Model]", target)
|
|
1002
|
+
return model_cls.model_validate(cast("Mapping[str, JsonValue]", body))
|
|
1003
|
+
# container arm; body is a JSON list/dict of inner-encoded values
|
|
1004
|
+
if target in {"list", "tuple"}:
|
|
1005
|
+
if not isinstance(body, list):
|
|
1006
|
+
msg = (
|
|
1007
|
+
f"alias {alias_name!r} container constructor {tag!r} "
|
|
1008
|
+
f"payload must be a JSON array; got "
|
|
1009
|
+
f"{type(body).__name__}."
|
|
1010
|
+
)
|
|
1011
|
+
raise TypeError(msg)
|
|
1012
|
+
return tuple(decode_one(item) for item in body)
|
|
1013
|
+
# target == "dict"
|
|
1014
|
+
if not isinstance(body, dict):
|
|
1015
|
+
msg = (
|
|
1016
|
+
f"alias {alias_name!r} dict constructor {tag!r} payload "
|
|
1017
|
+
f"must be a JSON object; got {type(body).__name__}."
|
|
1018
|
+
)
|
|
1019
|
+
raise TypeError(msg)
|
|
1020
|
+
return {k: decode_one(v) for k, v in body.items()}
|
|
1021
|
+
|
|
1022
|
+
def enc(v: FieldValue) -> Encoded:
|
|
1023
|
+
return json.dumps(encode_one(v, set()))
|
|
1024
|
+
|
|
1025
|
+
def dec(s: Encoded) -> FieldValue:
|
|
1026
|
+
return decode_one(json.loads(s))
|
|
1027
|
+
|
|
1028
|
+
def from_json(v: JsonValue) -> FieldValue:
|
|
1029
|
+
return decode_one(v)
|
|
1030
|
+
|
|
1031
|
+
return TypeTranslation(
|
|
1032
|
+
sort=alias_name,
|
|
1033
|
+
encode=enc,
|
|
1034
|
+
decode=dec,
|
|
1035
|
+
inner_kind="sum",
|
|
1036
|
+
from_json=from_json,
|
|
1037
|
+
auxiliary_sorts=aux_sorts,
|
|
1038
|
+
auxiliary_ops=aux_ops,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
# ---------------------------------------------------------------------------
|
|
1043
|
+
# TaggedUnion as field type (closed sum sort dispatched by discriminator)
|
|
1044
|
+
# ---------------------------------------------------------------------------
|
|
1045
|
+
|
|
1046
|
+
# A ``dx.TaggedUnion`` subclass declared with a ``discriminator=`` keyword is
|
|
1047
|
+
# a sum over the variants registered against it (``cls.__variants__``). When
|
|
1048
|
+
# a Model field is annotated with the union root, the field accepts any
|
|
1049
|
+
# variant; on encode the variant is dumped to its record dict (which already
|
|
1050
|
+
# carries the discriminator value), on decode the discriminator field
|
|
1051
|
+
# selects which variant to instantiate. The wire format is therefore the
|
|
1052
|
+
# variant's natural ``model_dump`` shape; no envelope and no synthesised
|
|
1053
|
+
# constructor tag is needed because the discriminator IS the constructor
|
|
1054
|
+
# tag, baked into every variant.
|
|
1055
|
+
#
|
|
1056
|
+
# The translation also exposes the closed sum sort plus per-variant
|
|
1057
|
+
# constructor ops via ``auxiliary_sorts`` / ``auxiliary_ops``, so the
|
|
1058
|
+
# parent Model's Theory carries the same panproto-native sum-sort shape
|
|
1059
|
+
# as a Model-ref recursive alias would.
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _is_tagged_union_root(cls: type) -> bool:
|
|
1063
|
+
"""Return True iff ``cls`` is a ``TaggedUnion`` subclass with variants set."""
|
|
1064
|
+
from didactic.fields._unions import TaggedUnion # noqa: PLC0415
|
|
1065
|
+
|
|
1066
|
+
if not issubclass(cls, TaggedUnion):
|
|
1067
|
+
return False
|
|
1068
|
+
if cls is TaggedUnion:
|
|
1069
|
+
return False
|
|
1070
|
+
return cls.__discriminator__ is not None and bool(cls.__variants__)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _tagged_union_translation(cls: type) -> TypeTranslation:
|
|
1074
|
+
"""Build a TypeTranslation for a ``dx.TaggedUnion`` root used as a field type.
|
|
1075
|
+
|
|
1076
|
+
The encoded form is the JSON dump of the chosen variant (a dict
|
|
1077
|
+
that already carries the discriminator field). The decoder
|
|
1078
|
+
inspects the discriminator field, looks the variant up in
|
|
1079
|
+
``cls.__variants__``, and instantiates it via ``model_validate``.
|
|
1080
|
+
"""
|
|
1081
|
+
discriminator = cast("str", cls.__discriminator__) # type: ignore[attr-defined]
|
|
1082
|
+
variants_by_value: dict[object, type[Model]] = dict(cls.__variants__) # type: ignore[attr-defined]
|
|
1083
|
+
union_name = cls.__name__
|
|
1084
|
+
|
|
1085
|
+
aux_sorts, aux_ops = _tagged_union_aux_spec(
|
|
1086
|
+
union_name, discriminator, variants_by_value
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
def encode_one(value: object) -> JsonValue:
|
|
1090
|
+
for variant_cls in variants_by_value.values():
|
|
1091
|
+
if isinstance(value, variant_cls):
|
|
1092
|
+
return cast("JsonValue", value.model_dump())
|
|
1093
|
+
variant_names = sorted(v.__name__ for v in variants_by_value.values())
|
|
1094
|
+
msg = (
|
|
1095
|
+
f"value of type {type(value).__name__} is not a registered variant "
|
|
1096
|
+
f"of TaggedUnion {union_name!r}; expected one of {variant_names}."
|
|
1097
|
+
)
|
|
1098
|
+
raise TypeError(msg)
|
|
1099
|
+
|
|
1100
|
+
def decode_one(payload: JsonValue) -> FieldValue:
|
|
1101
|
+
if not isinstance(payload, dict):
|
|
1102
|
+
msg = (
|
|
1103
|
+
f"TaggedUnion {union_name!r} payload must be a dict; got "
|
|
1104
|
+
f"{type(payload).__name__}."
|
|
1105
|
+
)
|
|
1106
|
+
raise TypeError(msg)
|
|
1107
|
+
if discriminator not in payload:
|
|
1108
|
+
msg = (
|
|
1109
|
+
f"TaggedUnion {union_name!r} payload is missing discriminator "
|
|
1110
|
+
f"field {discriminator!r}; got keys {sorted(payload)!r}."
|
|
1111
|
+
)
|
|
1112
|
+
raise KeyError(discriminator)
|
|
1113
|
+
disc_value = payload[discriminator]
|
|
1114
|
+
variant_cls = variants_by_value.get(disc_value)
|
|
1115
|
+
if variant_cls is None:
|
|
1116
|
+
known_values = [repr(v) for v in variants_by_value]
|
|
1117
|
+
msg = (
|
|
1118
|
+
f"TaggedUnion {union_name!r} has no variant registered for "
|
|
1119
|
+
f"{discriminator}={disc_value!r}; expected one of "
|
|
1120
|
+
f"{known_values}."
|
|
1121
|
+
)
|
|
1122
|
+
raise KeyError(disc_value)
|
|
1123
|
+
# Route through ``model_validate_json`` so the variant's per-field
|
|
1124
|
+
# ``from_json`` callables get a chance to coerce JSON-shaped values
|
|
1125
|
+
# (e.g. list -> tuple for ``tuple[float, ...]`` fields) before
|
|
1126
|
+
# construction. ``model_validate`` would feed the raw JSON shape
|
|
1127
|
+
# straight to the field encoders, which expect Python-native types.
|
|
1128
|
+
return variant_cls.model_validate_json(json.dumps(payload))
|
|
1129
|
+
|
|
1130
|
+
def enc(v: FieldValue) -> Encoded:
|
|
1131
|
+
return json.dumps(encode_one(v))
|
|
1132
|
+
|
|
1133
|
+
def dec(s: Encoded) -> FieldValue:
|
|
1134
|
+
return decode_one(json.loads(s))
|
|
1135
|
+
|
|
1136
|
+
def from_json(v: JsonValue) -> FieldValue:
|
|
1137
|
+
return decode_one(v)
|
|
1138
|
+
|
|
1139
|
+
return TypeTranslation(
|
|
1140
|
+
sort=union_name,
|
|
1141
|
+
encode=enc,
|
|
1142
|
+
decode=dec,
|
|
1143
|
+
inner_kind="sum",
|
|
1144
|
+
from_json=from_json,
|
|
1145
|
+
auxiliary_sorts=aux_sorts,
|
|
1146
|
+
auxiliary_ops=aux_ops,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _tagged_union_aux_spec(
|
|
1151
|
+
union_name: str,
|
|
1152
|
+
discriminator: str,
|
|
1153
|
+
variants_by_value: dict[object, type[Model]],
|
|
1154
|
+
) -> tuple[tuple[SpecRecord, ...], tuple[SpecRecord, ...]]:
|
|
1155
|
+
"""Build the auxiliary sort/op records for a TaggedUnion used as a field type.
|
|
1156
|
+
|
|
1157
|
+
Constructor names are ``<UnionName>_<discriminator-value>`` (the
|
|
1158
|
+
discriminator value is the natural variant identifier and matches
|
|
1159
|
+
what the wire format already carries). Each constructor op takes
|
|
1160
|
+
the variant's primary sort as its single input and outputs the
|
|
1161
|
+
union sort. The closed sum sort declares ``Closed`` against every
|
|
1162
|
+
constructor name.
|
|
1163
|
+
"""
|
|
1164
|
+
constructor_table: dict[str, type[Model]] = {}
|
|
1165
|
+
for disc_value, variant_cls in variants_by_value.items():
|
|
1166
|
+
tag = f"{union_name}_{disc_value!s}"
|
|
1167
|
+
constructor_table[tag] = variant_cls
|
|
1168
|
+
constructor_names: list[JsonValue] = list(constructor_table)
|
|
1169
|
+
sorts: list[SpecRecord] = [
|
|
1170
|
+
{
|
|
1171
|
+
"name": union_name,
|
|
1172
|
+
"params": [],
|
|
1173
|
+
"kind": "Structural",
|
|
1174
|
+
"closure": {"Closed": constructor_names},
|
|
1175
|
+
}
|
|
1176
|
+
]
|
|
1177
|
+
ops: list[SpecRecord] = [
|
|
1178
|
+
{
|
|
1179
|
+
"name": tag,
|
|
1180
|
+
"inputs": [["v", variant_cls.__name__, "No"]],
|
|
1181
|
+
"output": union_name,
|
|
1182
|
+
}
|
|
1183
|
+
for tag, variant_cls in constructor_table.items()
|
|
1184
|
+
]
|
|
1185
|
+
# Reference the discriminator name in a metadata field on the sum
|
|
1186
|
+
# sort so consumers can introspect the dispatch convention without
|
|
1187
|
+
# round-tripping the Python class. The panproto schema spec ignores
|
|
1188
|
+
# unknown keys; this is purely informational.
|
|
1189
|
+
sorts[0]["discriminator"] = discriminator
|
|
1190
|
+
return tuple(sorts), tuple(ops)
|
|
1191
|
+
|
|
1192
|
+
|
|
538
1193
|
# ---------------------------------------------------------------------------
|
|
539
1194
|
# Annotated handling
|
|
540
1195
|
# ---------------------------------------------------------------------------
|
|
@@ -569,16 +1224,26 @@ def _expand_type_alias(typ: TypeForm) -> TypeForm:
|
|
|
569
1224
|
if isinstance(cast("object", typ), TypeAliasType):
|
|
570
1225
|
alias = cast("TypeAliasType", typ)
|
|
571
1226
|
# recursive alias: leave unwrapped so classify can build the
|
|
572
|
-
#
|
|
573
|
-
#
|
|
1227
|
+
# appropriate fixpoint translation. Two recursive shapes are
|
|
1228
|
+
# accepted: pure JSON-shaped (handled by ``_json_alias_translation``)
|
|
1229
|
+
# and the wider Model-ref shape (``_alias_sum_translation``).
|
|
1230
|
+
# Anything else is rejected up front with a clear message.
|
|
574
1231
|
if _has_self_reference(alias.__value__, alias.__name__, id(alias)):
|
|
575
|
-
|
|
1232
|
+
model_arms_probe: list[type] = []
|
|
1233
|
+
if not _arm_is_model_or_json_shape(
|
|
1234
|
+
alias.__value__,
|
|
1235
|
+
alias.__name__,
|
|
1236
|
+
id(alias),
|
|
1237
|
+
0,
|
|
1238
|
+
model_arms_probe,
|
|
1239
|
+
):
|
|
576
1240
|
msg = (
|
|
577
1241
|
f"recursive type alias {alias.__name__!r} contains an arm "
|
|
578
|
-
"that is not
|
|
579
|
-
"list[X], tuple[X, ...],
|
|
580
|
-
"and self-references).
|
|
581
|
-
"
|
|
1242
|
+
"that is not in the supported allow-list (primitive "
|
|
1243
|
+
"scalars, dx.Model subclasses, list[X], tuple[X, ...], "
|
|
1244
|
+
"dict[str, X], unions of these, and self-references). "
|
|
1245
|
+
"Restrict the alias to the supported subset, or use a "
|
|
1246
|
+
"dx.TaggedUnion."
|
|
582
1247
|
)
|
|
583
1248
|
raise TypeNotSupportedError(msg)
|
|
584
1249
|
return cast("TypeForm", alias)
|
|
@@ -842,14 +1507,19 @@ def classify(typ: TypeForm) -> TypeTranslation:
|
|
|
842
1507
|
"""
|
|
843
1508
|
# Expand PEP 695 type aliases (``Embed[T]``, ``Ref[T]``) so the
|
|
844
1509
|
# downstream Annotated logic sees the underlying form. Recursive
|
|
845
|
-
#
|
|
846
|
-
#
|
|
1510
|
+
# aliases come back unwrapped; we dispatch on shape (Model-ref
|
|
1511
|
+
# gets the closed sum-sort translation; pure JSON-shaped gets the
|
|
1512
|
+
# opaque JSON-fixpoint translation).
|
|
847
1513
|
typ = _expand_type_alias(typ)
|
|
848
1514
|
if isinstance(cast("object", typ), TypeAliasType):
|
|
849
1515
|
alias = cast("TypeAliasType", typ)
|
|
850
|
-
# _expand_type_alias only returns a ``TypeAliasType`` when
|
|
851
|
-
#
|
|
852
|
-
#
|
|
1516
|
+
# ``_expand_type_alias`` only returns a ``TypeAliasType`` when
|
|
1517
|
+
# the alias passed the wider Model-or-JSON allow-list. Now
|
|
1518
|
+
# decide which translation to build: any Model arm forces the
|
|
1519
|
+
# sum-sort path; pure JSON-shaped uses the opaque fixpoint.
|
|
1520
|
+
sig = _collect_alias_signature(alias)
|
|
1521
|
+
if sig.model_arms:
|
|
1522
|
+
return _alias_sum_translation(alias)
|
|
853
1523
|
return _json_alias_translation(alias.__name__)
|
|
854
1524
|
# Annotated[T, ...]; strip metadata, recurse on the base
|
|
855
1525
|
if get_origin(typ) is Annotated:
|
|
@@ -900,6 +1570,12 @@ def classify(typ: TypeForm) -> TypeTranslation:
|
|
|
900
1570
|
if isinstance(inner_type, type) and _is_scalar(inner_type):
|
|
901
1571
|
return _scalar_translation(inner_type)
|
|
902
1572
|
|
|
1573
|
+
# TaggedUnion root used as a field type: dispatch via the variants'
|
|
1574
|
+
# discriminator field. The translation contributes a closed sum
|
|
1575
|
+
# sort plus per-variant constructor ops to the parent Model's Theory.
|
|
1576
|
+
if isinstance(inner_type, type) and _is_tagged_union_root(inner_type):
|
|
1577
|
+
return _tagged_union_translation(inner_type)
|
|
1578
|
+
|
|
903
1579
|
# parameterised generics
|
|
904
1580
|
origin = get_origin(inner_type)
|
|
905
1581
|
args = get_args(inner_type)
|
|
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
|