typebind 0.1.0a1__py3-none-any.whl
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.
- typebind/__init__.py +37 -0
- typebind/_behavior.py +202 -0
- typebind/_discovery.py +181 -0
- typebind/_models.py +584 -0
- typebind/py.typed +0 -0
- typebind-0.1.0a1.dist-info/METADATA +149 -0
- typebind-0.1.0a1.dist-info/RECORD +9 -0
- typebind-0.1.0a1.dist-info/WHEEL +4 -0
- typebind-0.1.0a1.dist-info/licenses/LICENSE +21 -0
typebind/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Explicit, hierarchical discriminated models backed by Pydantic."""
|
|
2
|
+
|
|
3
|
+
from ._behavior import BehaviorAdapter, Implementation, assemble
|
|
4
|
+
from ._discovery import (
|
|
5
|
+
DiscoveredImplementation,
|
|
6
|
+
DiscoveredVariant,
|
|
7
|
+
discover,
|
|
8
|
+
discover_variants,
|
|
9
|
+
select_implementations,
|
|
10
|
+
select_variants,
|
|
11
|
+
)
|
|
12
|
+
from ._models import (
|
|
13
|
+
DiscriminatedModel,
|
|
14
|
+
IdentityDiscriminatedModel,
|
|
15
|
+
bind_variants,
|
|
16
|
+
build_adapter,
|
|
17
|
+
model,
|
|
18
|
+
tag_field,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BehaviorAdapter",
|
|
23
|
+
"Implementation",
|
|
24
|
+
"assemble",
|
|
25
|
+
"DiscoveredImplementation",
|
|
26
|
+
"discover",
|
|
27
|
+
"select_implementations",
|
|
28
|
+
"DiscoveredVariant",
|
|
29
|
+
"DiscriminatedModel",
|
|
30
|
+
"IdentityDiscriminatedModel",
|
|
31
|
+
"bind_variants",
|
|
32
|
+
"build_adapter",
|
|
33
|
+
"discover_variants",
|
|
34
|
+
"select_variants",
|
|
35
|
+
"tag_field",
|
|
36
|
+
"model",
|
|
37
|
+
]
|
typebind/_behavior.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Assemble typed object adapters independently of model validation and discovery."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from inspect import isabstract, signature
|
|
5
|
+
from types import MappingProxyType, get_original_bases
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Protocol,
|
|
9
|
+
TypeVar,
|
|
10
|
+
cast,
|
|
11
|
+
get_args,
|
|
12
|
+
get_origin,
|
|
13
|
+
get_type_hints,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from typing_extensions import get_protocol_members, is_protocol
|
|
17
|
+
|
|
18
|
+
from ._models import DiscriminatedModel, concrete_model, family_base
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _name(cls: type[object]) -> str:
|
|
22
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Implementation[T: DiscriminatedModel]:
|
|
26
|
+
"""Hold a model without copying or validating it; specialize with its concrete type.
|
|
27
|
+
|
|
28
|
+
Implement interfaces through ordinary explicit Protocol inheritance. The
|
|
29
|
+
read-only property preserves precise model types without modifying the model
|
|
30
|
+
class. Each adaptation creates a new implementation instance.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, model: T) -> None:
|
|
34
|
+
self._model = model
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def model(self) -> T:
|
|
38
|
+
"""The original model, whose lifetime is shared with this adapter object."""
|
|
39
|
+
return self._model
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _implementation_class(value: object) -> type[Implementation[Any]]:
|
|
43
|
+
if not isinstance(value, type) or not issubclass(value, Implementation):
|
|
44
|
+
raise TypeError(f"Expected an Implementation class; got {value!r}")
|
|
45
|
+
return cast(type[Implementation[Any]], value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _implementation_model(implementation: type[Implementation[Any]]) -> type[DiscriminatedModel]:
|
|
49
|
+
"""Resolve native generic bases, including type-variable forwarding through mixins."""
|
|
50
|
+
targets: set[type[DiscriminatedModel]] = set()
|
|
51
|
+
|
|
52
|
+
def visit(cls: type[object], substitutions: dict[TypeVar, object]) -> None:
|
|
53
|
+
for base in get_original_bases(cls):
|
|
54
|
+
origin: object = get_origin(base) or base
|
|
55
|
+
if not isinstance(origin, type):
|
|
56
|
+
continue
|
|
57
|
+
arguments = tuple(
|
|
58
|
+
substitutions.get(arg, arg) if isinstance(arg, TypeVar) else arg
|
|
59
|
+
for arg in get_args(base)
|
|
60
|
+
)
|
|
61
|
+
if origin is Implementation:
|
|
62
|
+
if len(arguments) != 1:
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"{_name(implementation)}: expected Implementation[ConcreteModel], "
|
|
65
|
+
"not a bare base"
|
|
66
|
+
)
|
|
67
|
+
targets.add(concrete_model(arguments[0]))
|
|
68
|
+
elif issubclass(origin, Implementation):
|
|
69
|
+
parent = cast(type[Implementation[Any]], origin)
|
|
70
|
+
parameters = cast(tuple[TypeVar, ...], getattr(parent, "__parameters__", ()))
|
|
71
|
+
visit(parent, dict(zip(parameters, arguments, strict=True)))
|
|
72
|
+
|
|
73
|
+
visit(implementation, {})
|
|
74
|
+
if len(targets) != 1:
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"{_name(implementation)}: expected exactly one concrete Implementation[Model] target"
|
|
77
|
+
)
|
|
78
|
+
return next(iter(targets))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _interface_family(interface: object) -> type[DiscriminatedModel]:
|
|
82
|
+
"""Read the interface's native property annotation as the authoritative family."""
|
|
83
|
+
if not isinstance(interface, type) or not is_protocol(interface):
|
|
84
|
+
raise TypeError(f"Expected a Protocol interface; got {interface!r}")
|
|
85
|
+
member: object = getattr(interface, "model", None)
|
|
86
|
+
if not isinstance(member, property) or member.fget is None or member.fset is not None:
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"{_name(interface)}: declare a read-only model property returning a family"
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
family: object = get_type_hints(member.fget).get("return")
|
|
92
|
+
return family_base(family)
|
|
93
|
+
except Exception as error:
|
|
94
|
+
error.add_note(f"Interface family: {_name(interface)}.model")
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BehaviorAdapter:
|
|
99
|
+
"""An assembled scope, with exact-class lookup and fresh adapter objects.
|
|
100
|
+
|
|
101
|
+
Construct with assemble(). No model parsing, reflection, discovery, family
|
|
102
|
+
checking, or fallback search occurs on the successful adapt path. Interface
|
|
103
|
+
methods dispatch normally after adaptation. Unknown pairs raise LookupError.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
variants: Iterable[type[DiscriminatedModel]],
|
|
110
|
+
implementations: Iterable[type[Implementation[Any]]],
|
|
111
|
+
required_interfaces: Iterable[type[object]] = (),
|
|
112
|
+
) -> None:
|
|
113
|
+
models = dict.fromkeys(concrete_model(model) for model in variants)
|
|
114
|
+
families: dict[type[object], type[DiscriminatedModel]] = {}
|
|
115
|
+
required = tuple(dict.fromkeys(required_interfaces))
|
|
116
|
+
for interface in required:
|
|
117
|
+
families[interface] = _interface_family(interface)
|
|
118
|
+
factories: dict[
|
|
119
|
+
tuple[type[DiscriminatedModel], type[object]], type[Implementation[Any]]
|
|
120
|
+
] = {}
|
|
121
|
+
for candidate in dict.fromkeys(implementations):
|
|
122
|
+
implementation = _implementation_class(candidate)
|
|
123
|
+
try:
|
|
124
|
+
model = _implementation_model(implementation)
|
|
125
|
+
if model not in models:
|
|
126
|
+
continue
|
|
127
|
+
if isabstract(implementation):
|
|
128
|
+
raise TypeError("Cannot assemble an abstract implementation")
|
|
129
|
+
# Do not execute constructors during assembly. They must accept
|
|
130
|
+
# one model; optional constructor dependencies remain possible.
|
|
131
|
+
signature(implementation).bind(object())
|
|
132
|
+
interfaces = tuple(base for base in implementation.__mro__ if is_protocol(base))
|
|
133
|
+
for interface in interfaces:
|
|
134
|
+
# Only model-bound Protocols declare behavior interfaces.
|
|
135
|
+
# Ordinary orthogonal Protocols may coexist on the object.
|
|
136
|
+
if interface is Protocol or "model" not in get_protocol_members(interface):
|
|
137
|
+
continue
|
|
138
|
+
if interface not in families:
|
|
139
|
+
families[interface] = _interface_family(interface)
|
|
140
|
+
if not issubclass(model, families[interface]):
|
|
141
|
+
raise TypeError(
|
|
142
|
+
f"{_name(model)} is outside interface {_name(interface)} "
|
|
143
|
+
f"family {_name(families[interface])}"
|
|
144
|
+
)
|
|
145
|
+
key = model, interface
|
|
146
|
+
previous = factories.get(key)
|
|
147
|
+
if previous is not None and previous is not implementation:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Duplicate implementation for {_name(model)} / "
|
|
150
|
+
f"{_name(interface)}: {_name(previous)} and "
|
|
151
|
+
f"{_name(implementation)}"
|
|
152
|
+
)
|
|
153
|
+
factories[key] = implementation
|
|
154
|
+
except Exception as error:
|
|
155
|
+
error.add_note(f"Behavior implementation: {_name(implementation)}")
|
|
156
|
+
raise
|
|
157
|
+
for model in models:
|
|
158
|
+
for interface in required:
|
|
159
|
+
if issubclass(model, families[interface]) and (model, interface) not in factories:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"Missing implementation for {_name(model)} / {_name(interface)}"
|
|
162
|
+
)
|
|
163
|
+
self._factories = MappingProxyType(factories)
|
|
164
|
+
|
|
165
|
+
def adapt[I](self, model: DiscriminatedModel, interface: type[I]) -> I:
|
|
166
|
+
"""Bind an existing model to an interface; preserve the static interface type.
|
|
167
|
+
|
|
168
|
+
The heterogeneous table was checked at assembly. This local cast restores
|
|
169
|
+
its dependent result type; callers never need a cast. Plugins remain
|
|
170
|
+
trusted Python code, and their method signatures are checked statically.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
factory = self._factories[type(model), interface]
|
|
174
|
+
except KeyError:
|
|
175
|
+
raise LookupError(
|
|
176
|
+
f"No implementation in this scope for {_name(type(model))} / {interface!r}"
|
|
177
|
+
) from None
|
|
178
|
+
return cast(I, factory(model))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def assemble(
|
|
182
|
+
*,
|
|
183
|
+
variants: Iterable[type[DiscriminatedModel]],
|
|
184
|
+
implementations: Iterable[type[Implementation[Any]]],
|
|
185
|
+
required_interfaces: Iterable[type[object]] = (),
|
|
186
|
+
) -> BehaviorAdapter:
|
|
187
|
+
"""Build a scope snapshot and validate every applicable required interface.
|
|
188
|
+
|
|
189
|
+
Only exact targets in variants participate in dispatch/conflict/coverage
|
|
190
|
+
checks; other targets can coexist in the discovered sources. Repeated class
|
|
191
|
+
identities are harmless. Different implementations of the same pair fail,
|
|
192
|
+
regardless of publication order. Required interfaces apply to all selected
|
|
193
|
+
models in their family, including descendants of intermediate families.
|
|
194
|
+
|
|
195
|
+
A new scope requires a new assembly. There is no global registry, automatic
|
|
196
|
+
subclass fallback, object cache, or constructor execution during assembly.
|
|
197
|
+
"""
|
|
198
|
+
return BehaviorAdapter(
|
|
199
|
+
variants=variants,
|
|
200
|
+
implementations=implementations,
|
|
201
|
+
required_interfaces=required_interfaces,
|
|
202
|
+
)
|
typebind/_discovery.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Discover published models once, then select explicit family and source scopes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from itertools import chain
|
|
6
|
+
from typing import Any, NamedTuple, cast
|
|
7
|
+
|
|
8
|
+
from ._behavior import Implementation
|
|
9
|
+
from ._models import DiscriminatedModel, family_base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiscoveredVariant(NamedTuple):
|
|
13
|
+
"""A loaded model and its native publication metadata, without derived tags."""
|
|
14
|
+
|
|
15
|
+
model: type[DiscriminatedModel]
|
|
16
|
+
entry_point: metadata.EntryPoint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _source(entry: metadata.EntryPoint) -> str:
|
|
20
|
+
"""Describe an entry point without losing its providing distribution."""
|
|
21
|
+
distribution = entry.dist.metadata["Name"] if entry.dist is not None else "<unknown>"
|
|
22
|
+
return (
|
|
23
|
+
f"distribution={distribution!r}, group={entry.group!r}, "
|
|
24
|
+
f"name={entry.name!r}, value={entry.value!r}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _group_names(groups: Iterable[str]) -> frozenset[str]:
|
|
29
|
+
"""Consume a source scope once, rejecting accidental character scopes."""
|
|
30
|
+
if isinstance(groups, str):
|
|
31
|
+
raise TypeError(
|
|
32
|
+
"groups must be an iterable of group names, not a single string; use {name}"
|
|
33
|
+
)
|
|
34
|
+
return frozenset(groups)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discover_variants(
|
|
38
|
+
*,
|
|
39
|
+
groups: Iterable[str],
|
|
40
|
+
include: Callable[[metadata.EntryPoint], bool] | None = None,
|
|
41
|
+
) -> tuple[DiscoveredVariant, ...]:
|
|
42
|
+
"""Load model classes from selected installed entry point groups in one batch.
|
|
43
|
+
|
|
44
|
+
Enumerate metadata once; load each selected entry record once. ``include``
|
|
45
|
+
filters native metadata before loading, so excluded entries are not imported.
|
|
46
|
+
Groups may mix families. Preserve every source record, even when multiple
|
|
47
|
+
records publish the same class or use the same entry name. No global cache,
|
|
48
|
+
subclass scanning, instantiation, or tag validation is performed here.
|
|
49
|
+
|
|
50
|
+
Loading failures retain their original exception and gain a source-context
|
|
51
|
+
note; non-model exports raise TypeError. Predicate errors propagate unchanged.
|
|
52
|
+
Groups are consumed once; a single string raises TypeError before discovery.
|
|
53
|
+
Empty or absent groups yield an empty tuple. Loading executes plugin Python
|
|
54
|
+
code; a later selection cannot undo import side effects.
|
|
55
|
+
"""
|
|
56
|
+
discovered: list[DiscoveredVariant] = []
|
|
57
|
+
for model, entry in _load_entries(groups, include, label="Model"):
|
|
58
|
+
if not isinstance(model, type) or not issubclass(model, DiscriminatedModel):
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"Model entry point must export a DiscriminatedModel class ({_source(entry)}); "
|
|
61
|
+
f"got {model!r}"
|
|
62
|
+
)
|
|
63
|
+
discovered.append(DiscoveredVariant(model, entry))
|
|
64
|
+
return tuple(discovered)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DiscoveredImplementation(NamedTuple):
|
|
68
|
+
"""A behavior implementation and its unmodified publication metadata."""
|
|
69
|
+
|
|
70
|
+
implementation: type[Implementation[Any]]
|
|
71
|
+
entry_point: metadata.EntryPoint
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_entries(
|
|
75
|
+
groups: Iterable[str],
|
|
76
|
+
include: Callable[[metadata.EntryPoint], bool] | None,
|
|
77
|
+
*,
|
|
78
|
+
label: str,
|
|
79
|
+
) -> Iterator[tuple[object, metadata.EntryPoint]]:
|
|
80
|
+
selected_groups = _group_names(groups)
|
|
81
|
+
if not selected_groups:
|
|
82
|
+
return
|
|
83
|
+
for entry in metadata.entry_points():
|
|
84
|
+
if entry.group not in selected_groups or (include is not None and not include(entry)):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
value: object = entry.load()
|
|
88
|
+
except Exception as error:
|
|
89
|
+
error.add_note(f"{label} entry point: {_source(entry)}")
|
|
90
|
+
raise
|
|
91
|
+
yield value, entry
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def discover(
|
|
95
|
+
*,
|
|
96
|
+
groups: Iterable[str],
|
|
97
|
+
include: Callable[[metadata.EntryPoint], bool] | None = None,
|
|
98
|
+
) -> tuple[DiscoveredVariant | DiscoveredImplementation, ...]:
|
|
99
|
+
"""Discover models and behavior implementations together in one metadata scan.
|
|
100
|
+
|
|
101
|
+
Groups may mix export kinds and families. Source filters run before loading.
|
|
102
|
+
Preserve all publications for subsequent scope selection, including duplicate
|
|
103
|
+
class exports. Loading failures keep their identity and gain a source note.
|
|
104
|
+
Declaration reflection, family checks, conflicts and coverage belong to
|
|
105
|
+
assembly, not discovery. Use discover_variants for model-only consumers.
|
|
106
|
+
"""
|
|
107
|
+
found: list[DiscoveredVariant | DiscoveredImplementation] = []
|
|
108
|
+
for value, entry in _load_entries(groups, include, label="Plugin"):
|
|
109
|
+
if isinstance(value, type) and issubclass(value, DiscriminatedModel):
|
|
110
|
+
found.append(DiscoveredVariant(value, entry))
|
|
111
|
+
elif isinstance(value, type) and issubclass(value, Implementation):
|
|
112
|
+
found.append(DiscoveredImplementation(cast(type[Implementation[Any]], value), entry))
|
|
113
|
+
else:
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"Plugin entry point must export a DiscriminatedModel or Implementation class "
|
|
116
|
+
f"({_source(entry)}); got {value!r}"
|
|
117
|
+
)
|
|
118
|
+
return tuple(found)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def select_implementations(
|
|
122
|
+
discovered: Iterable[DiscoveredVariant | DiscoveredImplementation],
|
|
123
|
+
*,
|
|
124
|
+
groups: Iterable[str] | None = None,
|
|
125
|
+
extra_implementations: Iterable[type[Implementation[Any]]] = (),
|
|
126
|
+
) -> tuple[type[Implementation[Any]], ...]:
|
|
127
|
+
"""Select behavior sources using the same group scope semantics as models.
|
|
128
|
+
|
|
129
|
+
Extras bypass source filters. Deduplicate class identities after filtering.
|
|
130
|
+
Target-model and interface-family resolution happen only during assembly,
|
|
131
|
+
where the selected variants define the data scope.
|
|
132
|
+
"""
|
|
133
|
+
selected_groups = None if groups is None else _group_names(groups)
|
|
134
|
+
published = (
|
|
135
|
+
item.implementation
|
|
136
|
+
for item in discovered
|
|
137
|
+
if isinstance(item, DiscoveredImplementation)
|
|
138
|
+
and (selected_groups is None or item.entry_point.group in selected_groups)
|
|
139
|
+
)
|
|
140
|
+
selected: dict[int, type[Implementation[Any]]] = {}
|
|
141
|
+
for implementation in chain(published, extra_implementations):
|
|
142
|
+
selected.setdefault(id(implementation), implementation)
|
|
143
|
+
return tuple(selected.values())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def select_variants(
|
|
147
|
+
discovered: Iterable[DiscoveredVariant | DiscoveredImplementation],
|
|
148
|
+
*,
|
|
149
|
+
family: type[DiscriminatedModel],
|
|
150
|
+
groups: Iterable[str] | None = None,
|
|
151
|
+
extra_variants: Iterable[type[DiscriminatedModel]] = (),
|
|
152
|
+
) -> tuple[type[DiscriminatedModel], ...]:
|
|
153
|
+
"""Select model descendants from published and explicitly supplied types.
|
|
154
|
+
|
|
155
|
+
``groups=None`` accepts all discovered sources; an empty iterable accepts
|
|
156
|
+
none. A single string raises TypeError; other iterables are consumed once.
|
|
157
|
+
Source filtering does not apply to ``extra_variants``. Both sources
|
|
158
|
+
are filtered by native family ancestry and deduplicated by class identity.
|
|
159
|
+
Open views such as Family[Any] resolve to their native generic origin; this
|
|
160
|
+
is a family query, not a claim of static assignability to Family[str]. Named
|
|
161
|
+
classes and closed specializations keep their native issubclass constraints;
|
|
162
|
+
DiscriminatedModel selects every model. An open discriminator is not required.
|
|
163
|
+
|
|
164
|
+
Preserve first occurrence order, without giving it conflict precedence.
|
|
165
|
+
No metadata is read or plugins loaded. Empty matches are valid; tagged-leaf
|
|
166
|
+
and duplicate-tag checks belong to bind_variants/build_adapter. The input
|
|
167
|
+
snapshot and model declarations remain unchanged.
|
|
168
|
+
"""
|
|
169
|
+
base = family_base(family)
|
|
170
|
+
selected_groups = None if groups is None else _group_names(groups)
|
|
171
|
+
published = (
|
|
172
|
+
item.model
|
|
173
|
+
for item in discovered
|
|
174
|
+
if isinstance(item, DiscoveredVariant)
|
|
175
|
+
and (selected_groups is None or item.entry_point.group in selected_groups)
|
|
176
|
+
)
|
|
177
|
+
selected: dict[int, type[DiscriminatedModel]] = {}
|
|
178
|
+
for model in chain(published, extra_variants):
|
|
179
|
+
if issubclass(model, base):
|
|
180
|
+
selected.setdefault(id(model), model)
|
|
181
|
+
return tuple(selected.values())
|
typebind/_models.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""Generic/decorator declarations and explicit Pydantic discriminated unions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
from inspect import isabstract
|
|
7
|
+
from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import (
|
|
10
|
+
BaseModel,
|
|
11
|
+
Field,
|
|
12
|
+
GetCoreSchemaHandler,
|
|
13
|
+
GetPydanticSchema,
|
|
14
|
+
TypeAdapter,
|
|
15
|
+
create_model,
|
|
16
|
+
model_validator,
|
|
17
|
+
)
|
|
18
|
+
from pydantic.fields import FieldInfo
|
|
19
|
+
from pydantic_core import CoreSchema, core_schema
|
|
20
|
+
|
|
21
|
+
# A declaration-style marker, stored only on generated fields. Tag names and
|
|
22
|
+
# values are still read exclusively from the finalized Pydantic model_fields.
|
|
23
|
+
_DECORATED_TAG = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _decorated_field(field: FieldInfo) -> bool:
|
|
27
|
+
return any(item is _DECORATED_TAG for item in field.metadata)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tag_default() -> object:
|
|
31
|
+
"""Identify tag declarations; unchecked construction must supply tags itself."""
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"Automatic tag defaults were not compiled. "
|
|
34
|
+
"When using model_construct(), supply tags explicitly. "
|
|
35
|
+
"If overriding __get_pydantic_core_schema__, preserve the "
|
|
36
|
+
"DiscriminatedModel schema hook by calling super()."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def tag_field() -> Any:
|
|
41
|
+
"""Declare this level's discriminator as a native frozen Pydantic field.
|
|
42
|
+
|
|
43
|
+
The private default factory identifies tag fields after native field merging
|
|
44
|
+
and generic substitution. Schema generation replaces it with a validated
|
|
45
|
+
native default for bound tags, or a required field for open tags.
|
|
46
|
+
DiscriminatedModel keeps native unset semantics; IdentityDiscriminatedModel
|
|
47
|
+
additionally marks bound tags as set through Pydantic's public validator API.
|
|
48
|
+
|
|
49
|
+
Like Pydantic's Field, this helper returns Any for assignment to a typed
|
|
50
|
+
field. The explicit annotation owns the field and constructor argument types;
|
|
51
|
+
the assigned default makes the argument optional to static type checkers.
|
|
52
|
+
"""
|
|
53
|
+
return Field(default_factory=_tag_default, frozen=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _model_name(model: type[BaseModel]) -> str:
|
|
57
|
+
"""Identify declarations unambiguously across extension packages."""
|
|
58
|
+
return f"{model.__module__}.{model.__qualname__}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tag_fields(model: type[DiscriminatedModel]) -> dict[str, FieldInfo]:
|
|
62
|
+
"""Read marked fields after Pydantic's native merging and specialization."""
|
|
63
|
+
return {
|
|
64
|
+
name: field
|
|
65
|
+
for name, field in model.model_fields.items()
|
|
66
|
+
if field.default_factory is _tag_default
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_open_tag(annotation: object) -> bool:
|
|
71
|
+
"""Recognize the unbound field types supported by family views and consumers."""
|
|
72
|
+
return annotation is str or annotation is Any or isinstance(annotation, TypeVar)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _open_discriminator(model: type[DiscriminatedModel]) -> str | None:
|
|
76
|
+
"""Read the open discriminator of an already checked model, if any."""
|
|
77
|
+
return next(
|
|
78
|
+
(name for name, field in _tag_fields(model).items() if _is_open_tag(field.annotation)),
|
|
79
|
+
None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _bound_names(model: type[DiscriminatedModel]) -> tuple[str, ...]:
|
|
84
|
+
"""Compile the identity field names from authoritative resolved fields."""
|
|
85
|
+
return tuple(
|
|
86
|
+
name for name, field in _tag_fields(model).items() if not _is_open_tag(field.annotation)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _literal_tag(model: type[DiscriminatedModel], name: str) -> str:
|
|
91
|
+
"""Resolve the authoritative field through Pydantic at declaration/assembly time."""
|
|
92
|
+
annotation = model.model_fields[name].annotation
|
|
93
|
+
schema = TypeAdapter(cast(Any, annotation)).core_schema
|
|
94
|
+
if schema["type"] == "literal" and len(schema["expected"]) == 1:
|
|
95
|
+
value: object = schema["expected"][0]
|
|
96
|
+
if isinstance(value, str) and value:
|
|
97
|
+
return value
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"{_model_name(model)}.{name}: a tag must be a single non-empty string Literal; "
|
|
100
|
+
f"got {annotation!r}. Select a tag with Family[Literal['tag']]."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _with_tag_defaults(schema: CoreSchema, model: type[DiscriminatedModel]) -> CoreSchema:
|
|
105
|
+
"""Compile defaults on this model's fields, preserving Pydantic's validator wrappers.
|
|
106
|
+
|
|
107
|
+
Copy only the model spine and marked fields during schema generation. Payload
|
|
108
|
+
schemas and referenced models retain their native definitions. Reference nodes
|
|
109
|
+
already point to schemas built by the referenced model's own hook.
|
|
110
|
+
"""
|
|
111
|
+
if schema["type"] == "model-fields":
|
|
112
|
+
inner = schema
|
|
113
|
+
fields = dict(inner["fields"])
|
|
114
|
+
for name, field in _tag_fields(model).items():
|
|
115
|
+
original = fields[name]["schema"]
|
|
116
|
+
# Pydantic can hand the hook an existing schema when embedding a model.
|
|
117
|
+
if original["type"] != "default":
|
|
118
|
+
continue
|
|
119
|
+
default = cast(core_schema.WithDefaultSchema, original)
|
|
120
|
+
if default.get("default_factory") is not _tag_default:
|
|
121
|
+
continue
|
|
122
|
+
resolved = default["schema"]
|
|
123
|
+
if not _is_open_tag(field.annotation):
|
|
124
|
+
resolved = core_schema.with_default_schema(
|
|
125
|
+
resolved, default=_literal_tag(model, name), validate_default=True
|
|
126
|
+
)
|
|
127
|
+
fields[name] = {**fields[name], "schema": resolved}
|
|
128
|
+
return {**inner, "fields": fields}
|
|
129
|
+
if schema["type"] == "model":
|
|
130
|
+
return {**schema, "schema": _with_tag_defaults(schema["schema"], model)}
|
|
131
|
+
if schema["type"] in ("function-before", "function-after", "function-wrap"):
|
|
132
|
+
wrapper = cast(
|
|
133
|
+
core_schema.BeforeValidatorFunctionSchema
|
|
134
|
+
| core_schema.AfterValidatorFunctionSchema
|
|
135
|
+
| core_schema.WrapValidatorFunctionSchema,
|
|
136
|
+
schema,
|
|
137
|
+
)
|
|
138
|
+
updated = wrapper.copy()
|
|
139
|
+
updated["schema"] = _with_tag_defaults(wrapper["schema"], model)
|
|
140
|
+
return updated
|
|
141
|
+
return schema
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DiscriminatedModel(BaseModel):
|
|
145
|
+
"""Declare model families with one discriminator per level.
|
|
146
|
+
|
|
147
|
+
A family declares a str-bound type parameter and marks its field with
|
|
148
|
+
``tag_field()``. Subclasses select a tag through native
|
|
149
|
+
``Family[Literal["tag"]]`` specialization and may introduce another family.
|
|
150
|
+
Alternatively, @model generates runtime-only tag fields.
|
|
151
|
+
The field declaration is the sole source of its name, type, and tag behavior.
|
|
152
|
+
|
|
153
|
+
These hooks validate declarations without storing a second tag definition;
|
|
154
|
+
Pydantic owns generic class creation and field substitution. Polymorphic dispatch is provided by
|
|
155
|
+
``build_adapter``, not by direct validation of the family model. Omitted tag
|
|
156
|
+
defaults remain unset, as in BaseModel. Choose IdentityDiscriminatedModel at
|
|
157
|
+
the family root to retain bound tags in exclude_unset exports instead.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
# Shared derived names for copy protection and optional identity marking.
|
|
161
|
+
_bound_tag_names: ClassVar[tuple[str, ...]] = ()
|
|
162
|
+
|
|
163
|
+
def model_copy(self, *, update: Mapping[str, Any] | None = None, deep: bool = False) -> Self:
|
|
164
|
+
"""Copy using native semantics, rejecting updates to bound discriminator fields.
|
|
165
|
+
|
|
166
|
+
Even the same tag value is rejected. Other updates remain unvalidated,
|
|
167
|
+
as in BaseModel.model_copy; this is not a replacement for validation.
|
|
168
|
+
Only this copy operation checks updates; constructors and serializers
|
|
169
|
+
acquire no new callbacks or field scans.
|
|
170
|
+
"""
|
|
171
|
+
if update:
|
|
172
|
+
for name in type(self)._bound_tag_names:
|
|
173
|
+
if name in update:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"{_model_name(type(self))}.{name}: model_copy(update=...) cannot "
|
|
176
|
+
"update a bound discriminator; omit it from update"
|
|
177
|
+
)
|
|
178
|
+
return super().model_copy(update=update, deep=deep)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def __get_pydantic_core_schema__(
|
|
182
|
+
cls, source: type[BaseModel], handler: GetCoreSchemaHandler
|
|
183
|
+
) -> CoreSchema:
|
|
184
|
+
"""Derive native defaults from specialized fields before schema compilation.
|
|
185
|
+
|
|
186
|
+
Pydantic owns validation, serialization, and generic substitution. Bound
|
|
187
|
+
defaults validate like explicit inputs; open tags remain required.
|
|
188
|
+
Omitted defaults retain native unset semantics. No marking validator or
|
|
189
|
+
runtime policy branch is registered by this base. The hook never mutates
|
|
190
|
+
model_fields or an already compiled validator/serializer.
|
|
191
|
+
"""
|
|
192
|
+
schema = _with_tag_defaults(handler(source), cls)
|
|
193
|
+
cls._bound_tag_names = _bound_names(cls)
|
|
194
|
+
return schema
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
198
|
+
"""Check native fields and inheritance without retaining a second tag definition.
|
|
199
|
+
|
|
200
|
+
Native specializations either bind one Literal tag or remain open views.
|
|
201
|
+
Named subclasses preserve fixed ancestor tags and optionally declare a
|
|
202
|
+
new tag_field. Invalid fields, inheritance, or tag declarations raise
|
|
203
|
+
TypeError. Remaining kwargs follow Pydantic's custom-hook contract; model
|
|
204
|
+
configuration has already been consumed.
|
|
205
|
+
"""
|
|
206
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
207
|
+
# Also initialize deferred models, including explicit model_construct calls.
|
|
208
|
+
# Schema generation/rebuild refreshes this same derived tuple.
|
|
209
|
+
cls._bound_tag_names = _bound_names(cls)
|
|
210
|
+
parents = [base for base in cls.__bases__ if issubclass(base, DiscriminatedModel)]
|
|
211
|
+
if len(parents) != 1:
|
|
212
|
+
raise TypeError(
|
|
213
|
+
f"{_model_name(cls)}: exactly one DiscriminatedModel parent is supported; "
|
|
214
|
+
f"got {[_model_name(base) for base in parents]!r}. "
|
|
215
|
+
"Inherit from one family and use ordinary mixins for shared behavior."
|
|
216
|
+
)
|
|
217
|
+
parent = parents[0]
|
|
218
|
+
metadata = cls.__pydantic_generic_metadata__
|
|
219
|
+
origin = metadata["origin"]
|
|
220
|
+
if origin is not None:
|
|
221
|
+
# Native Pydantic owns field substitution and specialization caching.
|
|
222
|
+
assert issubclass(origin, DiscriminatedModel)
|
|
223
|
+
field_name = _open_discriminator(origin)
|
|
224
|
+
if field_name is None:
|
|
225
|
+
raise TypeError(
|
|
226
|
+
f"{_model_name(cls)}: only an open discriminator family can be specialized; "
|
|
227
|
+
f"{_model_name(origin)} has no open tag_field(). "
|
|
228
|
+
"Specialize the family that declares the next discriminator."
|
|
229
|
+
)
|
|
230
|
+
annotation = cls.model_fields[field_name].annotation
|
|
231
|
+
if not _is_open_tag(annotation):
|
|
232
|
+
_literal_tag(cls, field_name)
|
|
233
|
+
return
|
|
234
|
+
parent_discriminator = _open_discriminator(parent)
|
|
235
|
+
if parent_discriminator is not None and not _decorated_field(
|
|
236
|
+
parent.model_fields[parent_discriminator]
|
|
237
|
+
):
|
|
238
|
+
family = parent.__pydantic_generic_metadata__["origin"] or parent
|
|
239
|
+
raise TypeError(
|
|
240
|
+
f"{_model_name(cls)}.{parent_discriminator}: the inherited discriminator "
|
|
241
|
+
f"from {_model_name(family)} is still open. "
|
|
242
|
+
f"Inherit from {family.__name__}[Literal['tag']] to select its discriminator."
|
|
243
|
+
)
|
|
244
|
+
inherited_fields = _tag_fields(parent)
|
|
245
|
+
for name in inherited_fields:
|
|
246
|
+
if name not in cls.model_fields:
|
|
247
|
+
raise TypeError(
|
|
248
|
+
f"{_model_name(cls)}.{name}: the inherited discriminator from "
|
|
249
|
+
f"{_model_name(parent)} cannot be removed. Keep the inherited model field."
|
|
250
|
+
)
|
|
251
|
+
if _is_open_tag(parent.model_fields[name].annotation):
|
|
252
|
+
# Decorators run after class creation. Keep an inherited open
|
|
253
|
+
# decorator field pending until @model binds it. Assembly
|
|
254
|
+
# rejects any model whose discriminator is still open.
|
|
255
|
+
cls._check_tag_field(name)
|
|
256
|
+
annotation = cls.model_fields[name].annotation
|
|
257
|
+
if not _is_open_tag(annotation):
|
|
258
|
+
_literal_tag(cls, name)
|
|
259
|
+
elif annotation is not str or not _decorated_field(cls.model_fields[name]):
|
|
260
|
+
raise TypeError(f"{_model_name(cls)}.{name}: preserve the open family field")
|
|
261
|
+
continue
|
|
262
|
+
inherited_tag = _literal_tag(parent, name)
|
|
263
|
+
tag = _literal_tag(cls, name)
|
|
264
|
+
if tag != inherited_tag:
|
|
265
|
+
raise TypeError(
|
|
266
|
+
f"{_model_name(cls)}.{name}: the inherited discriminator cannot be overridden; "
|
|
267
|
+
f"{_model_name(parent)} fixes it to {inherited_tag!r}, got {tag!r}. "
|
|
268
|
+
"Keep the inherited tag or specialize a different branch of its family."
|
|
269
|
+
)
|
|
270
|
+
cls._check_tag_field(name)
|
|
271
|
+
candidates = [name for name in _tag_fields(cls) if name not in inherited_fields]
|
|
272
|
+
if candidates and any(
|
|
273
|
+
_is_open_tag(cls.model_fields[name].annotation) for name in inherited_fields
|
|
274
|
+
):
|
|
275
|
+
raise TypeError(
|
|
276
|
+
f"{_model_name(cls)}: bind the inherited discriminator before introducing "
|
|
277
|
+
"another family; use @model(tag='field', variant='value')"
|
|
278
|
+
)
|
|
279
|
+
if len(candidates) > 1:
|
|
280
|
+
raise TypeError(
|
|
281
|
+
f"{_model_name(cls)}: found new tag fields {candidates!r}. "
|
|
282
|
+
"Declare exactly one new tag_field per family; introduce another "
|
|
283
|
+
"discriminator in a child family."
|
|
284
|
+
)
|
|
285
|
+
discriminator = candidates[0] if candidates else None
|
|
286
|
+
if discriminator is not None:
|
|
287
|
+
parameters = metadata["parameters"]
|
|
288
|
+
field = cls.model_fields[discriminator]
|
|
289
|
+
if _decorated_field(field):
|
|
290
|
+
if parameters or field.annotation is not str:
|
|
291
|
+
raise TypeError(
|
|
292
|
+
f"{_model_name(cls)}.{discriminator}: a decorated family must "
|
|
293
|
+
"introduce one non-generic string discriminator"
|
|
294
|
+
)
|
|
295
|
+
cls._check_tag_field(discriminator)
|
|
296
|
+
return
|
|
297
|
+
if (
|
|
298
|
+
len(parameters) != 1
|
|
299
|
+
or cast(object, field.annotation) is not parameters[0]
|
|
300
|
+
or parameters[0].__bound__ is not str
|
|
301
|
+
):
|
|
302
|
+
raise TypeError(
|
|
303
|
+
f"{_model_name(cls)}.{discriminator}: Declare one str-bound type parameter "
|
|
304
|
+
f"as the discriminator field; got annotation {field.annotation!r} "
|
|
305
|
+
f"and type parameters {parameters!r}. Example: "
|
|
306
|
+
f"class {cls.__name__}[Tag: str](...): {discriminator}: Tag = tag_field()"
|
|
307
|
+
)
|
|
308
|
+
cls._check_tag_field(discriminator)
|
|
309
|
+
elif metadata["parameters"]:
|
|
310
|
+
raise TypeError(
|
|
311
|
+
f"{_model_name(cls)}: a generic family must declare its discriminator "
|
|
312
|
+
f"with tag_field(); no marked field was found. Example: "
|
|
313
|
+
f"kind: {metadata['parameters'][0].__name__} = tag_field()"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def _check_tag_field(cls, name: str) -> None:
|
|
318
|
+
"""Require a frozen tag field with our default factory and no alias/exclusion.
|
|
319
|
+
|
|
320
|
+
Raise TypeError for unsupported Field settings. Runtime freezing prevents
|
|
321
|
+
retagging; it does not impose covariance or a static read-only guarantee.
|
|
322
|
+
"""
|
|
323
|
+
field = cls.model_fields[name]
|
|
324
|
+
unsupported = [
|
|
325
|
+
setting
|
|
326
|
+
for setting, invalid in (
|
|
327
|
+
("default_factory", field.default_factory is not _tag_default),
|
|
328
|
+
("frozen", field.frozen is not True),
|
|
329
|
+
("alias", field.alias is not None),
|
|
330
|
+
("validation_alias", field.validation_alias is not None),
|
|
331
|
+
("serialization_alias", field.serialization_alias is not None),
|
|
332
|
+
("exclude", bool(field.exclude)),
|
|
333
|
+
("exclude_if", getattr(field, "exclude_if", None) is not None),
|
|
334
|
+
)
|
|
335
|
+
if invalid
|
|
336
|
+
]
|
|
337
|
+
if unsupported:
|
|
338
|
+
raise TypeError(
|
|
339
|
+
f"{_model_name(cls)}.{name}: must use tag_field() without changing its factory, "
|
|
340
|
+
f"freezing, aliases, or exclusion. Unsupported settings: {', '.join(unsupported)}. "
|
|
341
|
+
"Remove those overrides or keep the inherited tag field."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class IdentityDiscriminatedModel(DiscriminatedModel):
|
|
346
|
+
"""Treat bound discriminator tags as set identity fields, even when omitted.
|
|
347
|
+
|
|
348
|
+
Choose this base when exclude_unset exports must retain routing tags. It adds
|
|
349
|
+
one after-validator using Pydantic's public API; ordinary defaults and other
|
|
350
|
+
exclusion options keep native semantics. Validator ordering follows Pydantic's native MRO.
|
|
351
|
+
The derived name tuple contains no tag values and is refreshed on schema
|
|
352
|
+
generation/rebuild; model_fields remains the authoritative declaration.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
@model_validator(mode="after")
|
|
356
|
+
def _mark_bound_tags(self) -> Self:
|
|
357
|
+
"""Mark type identity using public APIs; repeated nested validation is idempotent."""
|
|
358
|
+
self.model_fields_set.update(type(self)._bound_tag_names)
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _decorator_model(value: object) -> type[DiscriminatedModel]:
|
|
363
|
+
if not isinstance(value, type) or not issubclass(value, DiscriminatedModel):
|
|
364
|
+
raise TypeError(f"Decorators require a DiscriminatedModel class; got {value!r}")
|
|
365
|
+
if value.__pydantic_generic_metadata__["parameters"]:
|
|
366
|
+
raise TypeError(f"{_model_name(value)}: specialize explicit generic parameters first")
|
|
367
|
+
return value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _extend[T: DiscriminatedModel](model: type[T], fields: dict[str, Any]) -> type[T]:
|
|
371
|
+
"""Use native inheritance so payload fields, methods and super() keep working."""
|
|
372
|
+
return create_model(
|
|
373
|
+
model.__name__,
|
|
374
|
+
__base__=model,
|
|
375
|
+
__module__=model.__module__,
|
|
376
|
+
__qualname__=model.__qualname__,
|
|
377
|
+
__doc__=model.__doc__,
|
|
378
|
+
**fields,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def model[T: DiscriminatedModel](
|
|
383
|
+
*, tag: str | None = None, variant: str | None = None
|
|
384
|
+
) -> Callable[[type[T]], type[T]]:
|
|
385
|
+
"""Bind a parent variant, introduce a tag field, or do both at declaration time.
|
|
386
|
+
|
|
387
|
+
``variant`` is the value of the inherited open discriminator; ``tag`` is the
|
|
388
|
+
name of a new open string field on this level. At least one is required.
|
|
389
|
+
Both together bind the parent first, then introduce the next family.
|
|
390
|
+
|
|
391
|
+
Ordinary fields and methods retain their static types. Generated tag fields
|
|
392
|
+
are runtime-only; no generic parameter, stub or whole-model Any is introduced.
|
|
393
|
+
Finalized fields own tags and defaults. Assembly checks cross-type conflicts;
|
|
394
|
+
construction performs no decorator reflection or routing work.
|
|
395
|
+
"""
|
|
396
|
+
if tag is None and variant is None:
|
|
397
|
+
raise ValueError("@model requires at least one of tag or variant")
|
|
398
|
+
if tag is not None and (
|
|
399
|
+
not isinstance(cast(object, tag), str) or not tag or tag.startswith("_")
|
|
400
|
+
):
|
|
401
|
+
raise ValueError("tag must be a non-empty field name without a leading underscore")
|
|
402
|
+
if variant is not None and (not isinstance(cast(object, variant), str) or not variant):
|
|
403
|
+
raise ValueError("variant must be a non-empty string")
|
|
404
|
+
|
|
405
|
+
def decorate(cls: type[T]) -> type[T]:
|
|
406
|
+
_decorator_model(cls)
|
|
407
|
+
inherited = _open_discriminator(cls)
|
|
408
|
+
fields: dict[str, Any] = {}
|
|
409
|
+
if variant is not None:
|
|
410
|
+
if inherited is None:
|
|
411
|
+
raise TypeError(
|
|
412
|
+
f"{_model_name(cls)}: variant= requires an open family discriminator"
|
|
413
|
+
)
|
|
414
|
+
fields[inherited] = (cast(Any, Literal)[variant], cls.model_fields[inherited])
|
|
415
|
+
elif inherited is not None:
|
|
416
|
+
raise TypeError(
|
|
417
|
+
f"{_model_name(cls)}.{inherited}: bind the inherited discriminator "
|
|
418
|
+
"before introducing another family; supply variant= to @model"
|
|
419
|
+
)
|
|
420
|
+
if tag is not None:
|
|
421
|
+
if tag in cls.model_fields or hasattr(cls, tag):
|
|
422
|
+
raise TypeError(
|
|
423
|
+
f"{_model_name(cls)}.{tag}: discriminator conflicts with "
|
|
424
|
+
"an existing field or attribute"
|
|
425
|
+
)
|
|
426
|
+
field = cast(FieldInfo, tag_field())
|
|
427
|
+
field.metadata.append(_DECORATED_TAG)
|
|
428
|
+
fields[tag] = (str, field)
|
|
429
|
+
return _extend(cls, fields)
|
|
430
|
+
|
|
431
|
+
return decorate
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _declared_discriminator(model: type[DiscriminatedModel]) -> str | None:
|
|
435
|
+
"""Identify family owners, excluding pending subclasses that only inherit a tag."""
|
|
436
|
+
name = _open_discriminator(model)
|
|
437
|
+
if name is None:
|
|
438
|
+
return None
|
|
439
|
+
for parent in model.__bases__:
|
|
440
|
+
if issubclass(parent, DiscriminatedModel) and name in _tag_fields(parent):
|
|
441
|
+
return None
|
|
442
|
+
return name
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _branches(
|
|
446
|
+
variants: Iterable[object],
|
|
447
|
+
) -> dict[type[DiscriminatedModel], dict[str, type[DiscriminatedModel]]]:
|
|
448
|
+
"""Build a local dispatch table from leaf fields and native inheritance.
|
|
449
|
+
|
|
450
|
+
Unspecialized ancestors identify the family boundaries. All tag values come
|
|
451
|
+
from the supplied leaf's resolved Pydantic fields, not generic arguments or
|
|
452
|
+
stored paths. Repeated classes are harmless; conflicting parent/tag claims
|
|
453
|
+
raise ValueError. Open families and untagged models raise TypeError.
|
|
454
|
+
"""
|
|
455
|
+
branches: dict[type[DiscriminatedModel], dict[str, type[DiscriminatedModel]]] = {}
|
|
456
|
+
seen: set[type[DiscriminatedModel]] = set()
|
|
457
|
+
for variant in variants:
|
|
458
|
+
if not isinstance(variant, type) or not issubclass(variant, DiscriminatedModel):
|
|
459
|
+
raise TypeError(f"Expected a DiscriminatedModel variant class; got {variant!r}")
|
|
460
|
+
if variant in seen:
|
|
461
|
+
continue
|
|
462
|
+
seen.add(variant)
|
|
463
|
+
if _open_discriminator(variant) is not None or not _tag_fields(variant):
|
|
464
|
+
raise TypeError(f"{_model_name(variant)} must be a tagged leaf model")
|
|
465
|
+
families = [
|
|
466
|
+
base
|
|
467
|
+
for base in reversed(variant.__mro__)
|
|
468
|
+
if issubclass(base, DiscriminatedModel)
|
|
469
|
+
and base.__pydantic_generic_metadata__["origin"] is None
|
|
470
|
+
and _declared_discriminator(base) is not None
|
|
471
|
+
]
|
|
472
|
+
for index, parent in enumerate(families):
|
|
473
|
+
name = _open_discriminator(parent)
|
|
474
|
+
assert name is not None
|
|
475
|
+
tag = _literal_tag(variant, name)
|
|
476
|
+
child = families[index + 1] if index + 1 < len(families) else variant
|
|
477
|
+
children = branches.setdefault(parent, {})
|
|
478
|
+
previous = children.get(tag)
|
|
479
|
+
if previous is not None and previous is not child:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"Duplicate tag {tag!r} for {_model_name(parent)}.{name}: "
|
|
482
|
+
f"{_model_name(previous)} and {_model_name(child)} claim the same branch. "
|
|
483
|
+
"Use distinct Literal tags or include only one of these branches in variants."
|
|
484
|
+
)
|
|
485
|
+
children[tag] = child
|
|
486
|
+
return branches
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def family_base(family: object) -> type[DiscriminatedModel]:
|
|
490
|
+
"""Normalize open generic views while preserving concrete class ancestry.
|
|
491
|
+
|
|
492
|
+
Named classes and closed specializations retain their identity and bound
|
|
493
|
+
tags. Only open native views such as Family[Any] use their generic origin.
|
|
494
|
+
Require a model class; adapter construction separately requires an open tag.
|
|
495
|
+
"""
|
|
496
|
+
if not isinstance(family, type) or not issubclass(family, DiscriminatedModel):
|
|
497
|
+
raise TypeError(f"family must be a DiscriminatedModel class; got {family!r}")
|
|
498
|
+
origin = family.__pydantic_generic_metadata__["origin"]
|
|
499
|
+
if origin is not None and _open_discriminator(family) is not None:
|
|
500
|
+
assert issubclass(origin, DiscriminatedModel)
|
|
501
|
+
return origin
|
|
502
|
+
return family
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _union_for(
|
|
506
|
+
family: object,
|
|
507
|
+
branches: dict[type[DiscriminatedModel], dict[str, type[DiscriminatedModel]]],
|
|
508
|
+
) -> Any:
|
|
509
|
+
"""Assemble nested Annotated unions; let Pydantic generate all schemas.
|
|
510
|
+
|
|
511
|
+
Dynamic type-expression assembly is the only untyped boundary here. Each
|
|
512
|
+
leaf retains its model class; no model or compiled schema is modified.
|
|
513
|
+
"""
|
|
514
|
+
if (
|
|
515
|
+
not isinstance(family, type)
|
|
516
|
+
or not issubclass(family, DiscriminatedModel)
|
|
517
|
+
or _open_discriminator(family) is None
|
|
518
|
+
):
|
|
519
|
+
raise TypeError(
|
|
520
|
+
f"Expected an open DiscriminatedModel family; got {family!r}. "
|
|
521
|
+
"Apply the binding to the family itself: "
|
|
522
|
+
"use Annotated[Family[Any], binding] | None for an optional field."
|
|
523
|
+
)
|
|
524
|
+
origin = family_base(family)
|
|
525
|
+
discriminator = _open_discriminator(origin)
|
|
526
|
+
assert discriminator is not None
|
|
527
|
+
children = branches.get(origin)
|
|
528
|
+
if not children:
|
|
529
|
+
raise ValueError(f"No variants supplied for {_model_name(origin)}")
|
|
530
|
+
choices: list[Any] = [
|
|
531
|
+
_union_for(child, branches) if _open_discriminator(child) is not None else child
|
|
532
|
+
for child in children.values()
|
|
533
|
+
]
|
|
534
|
+
union: Any = choices[0]
|
|
535
|
+
for choice in choices[1:]:
|
|
536
|
+
union = union | choice
|
|
537
|
+
return cast(Any, Annotated)[union, Field(discriminator=discriminator)]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def bind_variants(variants: Iterable[type[DiscriminatedModel]]) -> GetPydanticSchema:
|
|
541
|
+
"""Bind a variant snapshot explicitly to an Annotated family field.
|
|
542
|
+
|
|
543
|
+
Use ``Annotated[Family[Any], bind_variants(leaves)]`` in a model field or a
|
|
544
|
+
container annotation, then use ordinary Pydantic validation and serialization.
|
|
545
|
+
The static field type remains the declared family; runtime leaves do not
|
|
546
|
+
synthesize a static union. Known sets can instead use native Annotated unions.
|
|
547
|
+
|
|
548
|
+
Duplicate parent/tag mappings raise ValueError. Pydantic owns discriminator
|
|
549
|
+
schema generation and its native restrictions, including tag-field validators.
|
|
550
|
+
The snapshot is independent of subsequent changes to the input iterable.
|
|
551
|
+
"""
|
|
552
|
+
branches = _branches(variants)
|
|
553
|
+
|
|
554
|
+
def schema(source: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
|
|
555
|
+
return handler.generate_schema(_union_for(source, branches))
|
|
556
|
+
|
|
557
|
+
return GetPydanticSchema(schema)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def build_adapter[T: DiscriminatedModel](
|
|
561
|
+
family: type[T], *, variants: Iterable[type[DiscriminatedModel]]
|
|
562
|
+
) -> TypeAdapter[T]:
|
|
563
|
+
"""Build a native TypeAdapter for one open family's nested union.
|
|
564
|
+
|
|
565
|
+
``family`` supplies the static result type; its variant paths supply the
|
|
566
|
+
runtime union. Conflicting tags or missing branches raise ValueError. Closed
|
|
567
|
+
leaf models and enclosing containers are not family roots and raise TypeError.
|
|
568
|
+
Bind container fields with bind_variants and use TypeAdapter(Container) instead.
|
|
569
|
+
|
|
570
|
+
No adapter attributes are patched. Native rebuild, JSON Schema generation,
|
|
571
|
+
validation, and serialization operate on the actual union annotation.
|
|
572
|
+
"""
|
|
573
|
+
return TypeAdapter[T](_union_for(family, _branches(variants)))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def concrete_model(value: object) -> type[DiscriminatedModel]:
|
|
577
|
+
"""Validate a concrete behavior target without compiling a data union."""
|
|
578
|
+
if not isinstance(value, type) or not issubclass(value, DiscriminatedModel):
|
|
579
|
+
raise TypeError(f"Expected a DiscriminatedModel class; got {value!r}")
|
|
580
|
+
if isabstract(value) or _open_discriminator(value) is not None:
|
|
581
|
+
raise TypeError(
|
|
582
|
+
f"{_model_name(value)}: expected a concrete model, not an open/abstract family"
|
|
583
|
+
)
|
|
584
|
+
return value
|
typebind/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typebind
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Typed Pydantic model families, plugin discovery, and scoped behavior adapters.
|
|
5
|
+
Keywords: pydantic,discriminator,generics,plugins,adapters
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Dist: pydantic>=2.11.2,<3
|
|
15
|
+
Requires-Dist: typing-extensions>=4.15
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Project-URL: Homepage, https://github.com/nicholas-1vbw/typebind
|
|
18
|
+
Project-URL: Repository, https://github.com/nicholas-1vbw/typebind
|
|
19
|
+
Project-URL: Issues, https://github.com/nicholas-1vbw/typebind/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# typebind
|
|
23
|
+
|
|
24
|
+
Build extensible Pydantic model families with automatic discriminator tags and
|
|
25
|
+
statically typed fields. Bind behavior implementations to models without mixing
|
|
26
|
+
them into the data classes. Select variants explicitly, or optionally discover
|
|
27
|
+
models and implementations supplied by installed plugin packages.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.12 or later is required. This project is preparing its first release and
|
|
32
|
+
has not been published to PyPI. From a local checkout:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
python -m pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The supported Pydantic range and other runtime requirements are declared in
|
|
39
|
+
[pyproject.toml](https://github.com/nicholas-1vbw/typebind/blob/main/pyproject.toml).
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
Declare a family and its variants, then build an adapter for the variants you accept:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from typebind import DiscriminatedModel, build_adapter, model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@model(tag="kind")
|
|
50
|
+
class Message(DiscriminatedModel):
|
|
51
|
+
identifier: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@model(variant="query")
|
|
55
|
+
class Query(Message):
|
|
56
|
+
expression: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@model(variant="ping")
|
|
60
|
+
class Ping(Message):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
adapter = build_adapter(Message, variants=(Query, Ping))
|
|
65
|
+
query = Query(identifier="q", expression="hello") # kind defaults to "query"
|
|
66
|
+
wire = adapter.dump_json(query)
|
|
67
|
+
assert adapter.validate_json(wire) == query
|
|
68
|
+
assert type(adapter.validate_python({"kind": "ping", "identifier": "p"})) is Ping
|
|
69
|
+
print(wire.decode()) # {"kind":"query","identifier":"q","expression":"hello"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`build_adapter` returns a native Pydantic `TypeAdapter`. For nested model fields,
|
|
73
|
+
use [`bind_variants`](https://github.com/nicholas-1vbw/typebind/blob/main/docs/models.md#consumer-types).
|
|
74
|
+
Import public APIs from `typebind`; underscore-prefixed modules are internal.
|
|
75
|
+
|
|
76
|
+
The [`@model` decorator](https://github.com/nicholas-1vbw/typebind/blob/main/docs/models.md#decorator-declarations)
|
|
77
|
+
preserves static checking for declared fields such as `identifier` and `expression`.
|
|
78
|
+
Generated tag attributes such as `kind` are runtime-only: Pyright does not see
|
|
79
|
+
them or accept them as constructor keywords. Use explicit generics when your
|
|
80
|
+
code needs statically typed `Literal` tags:
|
|
81
|
+
|
|
82
|
+
<details>
|
|
83
|
+
<summary>Explicit generics with statically typed tags</summary>
|
|
84
|
+
|
|
85
|
+
This standalone example provides the same validation and JSON round trip:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from typing import Any, Literal
|
|
89
|
+
|
|
90
|
+
from typebind import DiscriminatedModel, build_adapter, tag_field
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Message[Kind: str](DiscriminatedModel):
|
|
94
|
+
kind: Kind = tag_field()
|
|
95
|
+
identifier: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Query(Message[Literal["query"]]):
|
|
99
|
+
expression: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Ping(Message[Literal["ping"]]):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
adapter = build_adapter(Message[Any], variants=(Query, Ping))
|
|
107
|
+
query = Query(identifier="q", expression="hello") # kind defaults to "query"
|
|
108
|
+
wire = adapter.dump_json(query)
|
|
109
|
+
assert adapter.validate_json(wire) == query
|
|
110
|
+
assert type(adapter.validate_python({"kind": "ping", "identifier": "p"})) is Ping
|
|
111
|
+
print(wire.decode()) # {"kind":"query","identifier":"q","expression":"hello"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`Message[Any]` keeps common fields typed while allowing runtime-selected tags;
|
|
115
|
+
concrete variants retain their `Literal` tags. For example, `query.kind` has the
|
|
116
|
+
static type `Literal["query"]`.
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
## Usage notes
|
|
121
|
+
|
|
122
|
+
- Constructors can omit fixed tags, but incoming data routed through an adapter
|
|
123
|
+
must contain them. Keep tags in persisted JSON and bind the reader's accepted variants.
|
|
124
|
+
- Ordinary dumps retain tags. If you need `exclude_unset=True`, use
|
|
125
|
+
`IdentityDiscriminatedModel` to retain omitted tags. Explicit tag exclusions and
|
|
126
|
+
`exclude_defaults=True` can still remove them; see the
|
|
127
|
+
[serialization contract](https://github.com/nicholas-1vbw/typebind/blob/main/docs/models.md#json-serialization-contract).
|
|
128
|
+
- Strict Pyright is verified. Discriminator aliases, additional payload type
|
|
129
|
+
parameters and multiple model-family parents are unsupported; see
|
|
130
|
+
[typing and compatibility boundaries](https://github.com/nicholas-1vbw/typebind/blob/main/docs/models.md#current-boundaries).
|
|
131
|
+
|
|
132
|
+
## Documentation and examples
|
|
133
|
+
|
|
134
|
+
- [Runnable examples](https://github.com/nicholas-1vbw/typebind/blob/main/examples/README.md): setup and commands for model families, manual behavior assembly, decorators and cross-package discovery.
|
|
135
|
+
- [Models](https://github.com/nicholas-1vbw/typebind/blob/main/docs/models.md): declarations, nested binding, serialization and copying.
|
|
136
|
+
- [Behaviors](https://github.com/nicholas-1vbw/typebind/blob/main/docs/behaviors.md): typed interfaces, assembly and dispatch contracts.
|
|
137
|
+
- [Discovery](https://github.com/nicholas-1vbw/typebind/blob/main/docs/discovery.md): package layout, entry points and scoped selection. Discovery is optional for all model and behavior APIs.
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
See [Contributing](https://github.com/nicholas-1vbw/typebind/blob/main/CONTRIBUTING.md)
|
|
142
|
+
for development setup and checks, and
|
|
143
|
+
[Releasing](https://github.com/nicholas-1vbw/typebind/blob/main/docs/releasing.md)
|
|
144
|
+
for publishing. Report vulnerabilities through the
|
|
145
|
+
[security policy](https://github.com/nicholas-1vbw/typebind/blob/main/SECURITY.md).
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[MIT](https://github.com/nicholas-1vbw/typebind/blob/main/LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
typebind/__init__.py,sha256=S_JmjKHSTuB-HPLRRV8zrBuUYIucEUpsPRlJ9VYM-lg,807
|
|
2
|
+
typebind/_behavior.py,sha256=vR9oconNtZCv3r_VDN2rM4NiU3B0HJ1J_rP71KJbUUU,8922
|
|
3
|
+
typebind/_discovery.py,sha256=K1CeEWmHVlTnnGHHtJkEjl4C1CsGQHi1IBkM8k77VBQ,7793
|
|
4
|
+
typebind/_models.py,sha256=fUrowyS450ejnoumAlLar3V8QapS8TnDtddEZrvrdZw,27180
|
|
5
|
+
typebind/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
typebind-0.1.0a1.dist-info/licenses/LICENSE,sha256=XSIMPgjY97-adiNpJef7TvWCgIcxJBMhyhkfrHy3CvY,1078
|
|
7
|
+
typebind-0.1.0a1.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
8
|
+
typebind-0.1.0a1.dist-info/METADATA,sha256=mZuSLgfQo2dpO3MzZCvb-u3KvxcioCktqqDp3sgDYOc,5943
|
|
9
|
+
typebind-0.1.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 typebind contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|