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.
Files changed (49) hide show
  1. {didactic-0.2.0 → didactic-0.3.0}/PKG-INFO +1 -1
  2. {didactic-0.2.0 → didactic-0.3.0}/pyproject.toml +1 -1
  3. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/api.py +1 -1
  4. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_model.py +9 -0
  5. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/theory/_theory.py +22 -1
  6. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_types.py +691 -15
  7. {didactic-0.2.0 → didactic-0.3.0}/.gitignore +0 -0
  8. {didactic-0.2.0 → didactic-0.3.0}/README.md +0 -0
  9. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/_self_describing.py +0 -0
  10. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/__init__.py +0 -0
  11. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/_axiom_enforcement.py +0 -0
  12. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/axioms/_axioms.py +0 -0
  13. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/cli/__init__.py +0 -0
  14. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/cli/_cli.py +0 -0
  15. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/__init__.py +0 -0
  16. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_emitter.py +0 -0
  17. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_json_schema.py +0 -0
  18. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/_write.py +0 -0
  19. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/io.py +0 -0
  20. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/codegen/source.py +0 -0
  21. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/__init__.py +0 -0
  22. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_computed.py +0 -0
  23. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_derived.py +0 -0
  24. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_fields.py +0 -0
  25. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_refs.py +0 -0
  26. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_unions.py +0 -0
  27. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/fields/_validators.py +0 -0
  28. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/__init__.py +0 -0
  29. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_dependent_lens.py +0 -0
  30. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_lens.py +0 -0
  31. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/lenses/_testing.py +0 -0
  32. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/__init__.py +0 -0
  33. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_diff.py +0 -0
  34. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_fingerprint.py +0 -0
  35. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_migrations.py +0 -0
  36. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/migrations/_synthesis.py +0 -0
  37. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/__init__.py +0 -0
  38. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_config.py +0 -0
  39. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_meta.py +0 -0
  40. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_root.py +0 -0
  41. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/models/_storage.py +0 -0
  42. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/py.typed +0 -0
  43. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/theory/__init__.py +0 -0
  44. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/__init__.py +0 -0
  45. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_types_lib.py +0 -0
  46. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/types/_typing.py +0 -0
  47. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/vcs/__init__.py +0 -0
  48. {didactic-0.2.0 → didactic-0.3.0}/src/didactic/vcs/_backref.py +0 -0
  49. {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.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "didactic"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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.2.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 ``typing.Union[...]``,
64
- # ``Annotated[...]``, PEP 695 type aliases are accepted at runtime but
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
- # JSON-fixpoint translation. Reject recursive aliases that are
573
- # not JSON-shaped early, with a clear message.
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
- if not _arm_is_json_shape(alias.__value__, alias.__name__, id(alias), 0):
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 JSON-shaped (allowed: primitive scalars, "
579
- "list[X], tuple[X, ...], dict[str, X], unions of these, "
580
- "and self-references). Restrict the alias to the "
581
- "JSON-compatible subset, or use a dx.TaggedUnion."
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
- # JSON-shaped aliases come back unwrapped; we detect them here and
846
- # build the JSON-fixpoint translation directly.
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 it has
851
- # already validated that the alias is a JSON-shaped recursive
852
- # alias (otherwise it unwraps or raises).
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