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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.