typebind 0.1.0a1__tar.gz → 0.1.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {typebind-0.1.0a1 → typebind-0.1.0a2}/PKG-INFO +1 -1
  2. {typebind-0.1.0a1 → typebind-0.1.0a2}/docs/behaviors.md +4 -0
  3. {typebind-0.1.0a1 → typebind-0.1.0a2}/pyproject.toml +1 -1
  4. {typebind-0.1.0a1 → typebind-0.1.0a2}/pyproject.toml.orig +1 -1
  5. {typebind-0.1.0a1 → typebind-0.1.0a2}/src/typebind/_behavior.py +20 -4
  6. {typebind-0.1.0a1 → typebind-0.1.0a2}/src/typebind/_models.py +7 -2
  7. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_behavior.py +115 -1
  8. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_models.py +66 -0
  9. {typebind-0.1.0a1 → typebind-0.1.0a2}/uv.lock +1 -1
  10. {typebind-0.1.0a1 → typebind-0.1.0a2}/.python-version +0 -0
  11. {typebind-0.1.0a1 → typebind-0.1.0a2}/CONTRIBUTING.md +0 -0
  12. {typebind-0.1.0a1 → typebind-0.1.0a2}/LICENSE +0 -0
  13. {typebind-0.1.0a1 → typebind-0.1.0a2}/README.md +0 -0
  14. {typebind-0.1.0a1 → typebind-0.1.0a2}/SECURITY.md +0 -0
  15. {typebind-0.1.0a1 → typebind-0.1.0a2}/benchmarks/behavior.py +0 -0
  16. {typebind-0.1.0a1 → typebind-0.1.0a2}/benchmarks/models.py +0 -0
  17. {typebind-0.1.0a1 → typebind-0.1.0a2}/docs/discovery.md +0 -0
  18. {typebind-0.1.0a1 → typebind-0.1.0a2}/docs/models.md +0 -0
  19. {typebind-0.1.0a1 → typebind-0.1.0a2}/docs/releasing.md +0 -0
  20. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/README.md +0 -0
  21. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/commands.py +0 -0
  22. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/decorators.py +0 -0
  23. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/discovery.py +0 -0
  24. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/manual.py +0 -0
  25. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/models.py +0 -0
  26. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/core/pyproject.toml +0 -0
  27. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/core/src/typebind_example_core/__init__.py +0 -0
  28. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/core/src/typebind_example_core/py.typed +0 -0
  29. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/extra/pyproject.toml +0 -0
  30. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/extra/src/typebind_example_extra/__init__.py +0 -0
  31. {typebind-0.1.0a1 → typebind-0.1.0a2}/examples/plugins/extra/src/typebind_example_extra/py.typed +0 -0
  32. {typebind-0.1.0a1 → typebind-0.1.0a2}/scripts/audit_dependencies.py +0 -0
  33. {typebind-0.1.0a1 → typebind-0.1.0a2}/scripts/check_dist.py +0 -0
  34. {typebind-0.1.0a1 → typebind-0.1.0a2}/scripts/check_release.py +0 -0
  35. {typebind-0.1.0a1 → typebind-0.1.0a2}/src/typebind/__init__.py +0 -0
  36. {typebind-0.1.0a1 → typebind-0.1.0a2}/src/typebind/_discovery.py +0 -0
  37. {typebind-0.1.0a1 → typebind-0.1.0a2}/src/typebind/py.typed +0 -0
  38. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_behavior_typing.py +0 -0
  39. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_check_dist.py +0 -0
  40. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_decorators.py +0 -0
  41. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_discovery.py +0 -0
  42. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_discovery_install.py +0 -0
  43. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_model_policies.py +0 -0
  44. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_release_version.py +0 -0
  45. {typebind-0.1.0a1 → typebind-0.1.0a2}/tests/test_typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typebind
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: Typed Pydantic model families, plugin discovery, and scoped behavior adapters.
5
5
  Keywords: pydantic,discriminator,generics,plugins,adapters
6
6
  License-Expression: MIT
@@ -50,6 +50,10 @@ family interface. One implementation can implement multiple interfaces, even
50
50
  when those interfaces bind to different ancestor families. Native generic
51
51
  forwarding through intermediate implementation bases is supported; there must
52
52
  ultimately be one concrete model target. Models do not inherit these interfaces.
53
+ This includes forwarding a tag parameter inside a model: a base such as
54
+ `Shared[K: str](Implementation[Message[K]])` specialized as
55
+ `Shared[Literal["query"]]` targets `Message[Literal["query"]]`. Assembly uses
56
+ Pydantic's native model specialization; the target retains its exact class identity.
53
57
 
54
58
  Assembly resolves the `model` property's return annotation with `get_type_hints`.
55
59
  With `from __future__ import annotations`, a family defined only inside a function
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typebind"
3
- version = "0.1.0a1"
3
+ version = "0.1.0a2"
4
4
  description = "Typed Pydantic model families, plugin discovery, and scoped behavior adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typebind"
3
- version = "0.1.0a1"
3
+ version = "0.1.0a2"
4
4
  description = "Typed Pydantic model families, plugin discovery, and scoped behavior adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -45,6 +45,20 @@ def _implementation_class(value: object) -> type[Implementation[Any]]:
45
45
  return cast(type[Implementation[Any]], value)
46
46
 
47
47
 
48
+ def _substitute_argument(argument: object, substitutions: dict[TypeVar, object]) -> object:
49
+ """Bind model parameters through native Pydantic specialization at assembly time."""
50
+ if isinstance(argument, TypeVar):
51
+ return substitutions.get(argument, argument)
52
+ if isinstance(argument, type) and issubclass(argument, DiscriminatedModel):
53
+ parameters = argument.__pydantic_generic_metadata__["parameters"]
54
+ resolved = tuple(substitutions.get(parameter, parameter) for parameter in parameters)
55
+ if resolved != parameters:
56
+ # Pydantic models are classes, not typing aliases. Let their native
57
+ # specialization substitute fields and preserve cached class identity.
58
+ return cast(Any, argument)[resolved]
59
+ return cast(object, argument)
60
+
61
+
48
62
  def _implementation_model(implementation: type[Implementation[Any]]) -> type[DiscriminatedModel]:
49
63
  """Resolve native generic bases, including type-variable forwarding through mixins."""
50
64
  targets: set[type[DiscriminatedModel]] = set()
@@ -54,10 +68,7 @@ def _implementation_model(implementation: type[Implementation[Any]]) -> type[Dis
54
68
  origin: object = get_origin(base) or base
55
69
  if not isinstance(origin, type):
56
70
  continue
57
- arguments = tuple(
58
- substitutions.get(arg, arg) if isinstance(arg, TypeVar) else arg
59
- for arg in get_args(base)
60
- )
71
+ arguments = tuple(_substitute_argument(arg, substitutions) for arg in get_args(base))
61
72
  if origin is Implementation:
62
73
  if len(arguments) != 1:
63
74
  raise TypeError(
@@ -68,6 +79,11 @@ def _implementation_model(implementation: type[Implementation[Any]]) -> type[Dis
68
79
  elif issubclass(origin, Implementation):
69
80
  parent = cast(type[Implementation[Any]], origin)
70
81
  parameters = cast(tuple[TypeVar, ...], getattr(parent, "__parameters__", ()))
82
+ if len(arguments) != len(parameters):
83
+ raise TypeError(
84
+ f"{_name(implementation)}: expected explicit type arguments "
85
+ f"for {_name(parent)}"
86
+ )
71
87
  visit(parent, dict(zip(parameters, arguments, strict=True)))
72
88
 
73
89
  visit(implementation, {})
@@ -230,6 +230,7 @@ class DiscriminatedModel(BaseModel):
230
230
  annotation = cls.model_fields[field_name].annotation
231
231
  if not _is_open_tag(annotation):
232
232
  _literal_tag(cls, field_name)
233
+ cls._check_tag_field(field_name)
233
234
  return
234
235
  parent_discriminator = _open_discriminator(parent)
235
236
  if parent_discriminator is not None and not _decorated_field(
@@ -450,7 +451,7 @@ def _branches(
450
451
  Unspecialized ancestors identify the family boundaries. All tag values come
451
452
  from the supplied leaf's resolved Pydantic fields, not generic arguments or
452
453
  stored paths. Repeated classes are harmless; conflicting parent/tag claims
453
- raise ValueError. Open families and untagged models raise TypeError.
454
+ raise ValueError. Abstract models, open families and untagged models raise TypeError.
454
455
  """
455
456
  branches: dict[type[DiscriminatedModel], dict[str, type[DiscriminatedModel]]] = {}
456
457
  seen: set[type[DiscriminatedModel]] = set()
@@ -460,7 +461,11 @@ def _branches(
460
461
  if variant in seen:
461
462
  continue
462
463
  seen.add(variant)
463
- if _open_discriminator(variant) is not None or not _tag_fields(variant):
464
+ if (
465
+ isabstract(variant)
466
+ or _open_discriminator(variant) is not None
467
+ or not _tag_fields(variant)
468
+ ):
464
469
  raise TypeError(f"{_model_name(variant)} must be a tagged leaf model")
465
470
  families = [
466
471
  base
@@ -1,8 +1,9 @@
1
1
  """Behavior assembly validates declarations once and binds ordinary objects."""
2
2
 
3
+ import sys
3
4
  from abc import abstractmethod
4
5
  from importlib import metadata
5
- from typing import Annotated, Any, Literal, Protocol, cast
6
+ from typing import Annotated, Any, Literal, Protocol, assert_type, cast
6
7
 
7
8
  import pytest
8
9
  from pydantic import BaseModel, TypeAdapter
@@ -159,6 +160,69 @@ def test_generic_forwarding_and_inherited_implementation() -> None:
159
160
  )
160
161
 
161
162
 
163
+ @pytest.mark.parametrize("through_middle", [False, True])
164
+ def test_nested_model_type_argument_forwarding(through_middle: bool) -> None:
165
+ class Shared[K: str](Implementation[Message[K]], Describable):
166
+ def describe(self) -> str:
167
+ return self.model.identifier
168
+
169
+ class Direct(Shared[Literal["nested"]]):
170
+ pass
171
+
172
+ class Middle[K: str](Shared[K]):
173
+ pass
174
+
175
+ class Forwarded(Middle[Literal["nested"]]):
176
+ pass
177
+
178
+ class Inherited(Forwarded):
179
+ pass
180
+
181
+ leaf = Message[Literal["nested"]]
182
+ sample = leaf(identifier="nested")
183
+ assert_type(Direct(sample).model, Message[Literal["nested"]])
184
+ assert_type(Inherited(sample).model, Message[Literal["nested"]])
185
+ implementation = Inherited if through_middle else Direct
186
+ runtime = assemble(
187
+ variants=[leaf], implementations=[implementation], required_interfaces=[Describable]
188
+ )
189
+ view = runtime.adapt(sample, Describable)
190
+ assert type(view) is implementation
191
+ assert view.model is sample
192
+ assert view.describe() == "nested"
193
+
194
+
195
+ def test_nested_model_argument_through_whole_model_forwarding() -> None:
196
+ class Whole[T: DiscriminatedModel](Implementation[T]):
197
+ pass
198
+
199
+ class Shared[K: str](Whole[Request[K]], Describable):
200
+ def describe(self) -> str:
201
+ return f"{self.model.kind}/{self.model.method}"
202
+
203
+ class Concrete(Shared[Literal["nested"]]):
204
+ pass
205
+
206
+ leaf = Request[Literal["nested"]]
207
+ sample = leaf(identifier="q")
208
+ assert_type(Concrete(sample).model, Request[Literal["nested"]])
209
+ runtime = assemble(
210
+ variants=[leaf], implementations=[Concrete], required_interfaces=[Describable]
211
+ )
212
+ view = runtime.adapt(sample, Describable)
213
+ assert view.model is sample
214
+ assert view.describe() == "request/nested"
215
+
216
+ class Open(Shared[str]):
217
+ pass
218
+
219
+ with pytest.raises(TypeError, match="expected a concrete model"):
220
+ assemble(variants=[leaf], implementations=[Open])
221
+
222
+ with pytest.raises(TypeError, match="expected a concrete model"):
223
+ assemble(variants=[leaf], implementations=[Shared])
224
+
225
+
162
226
  def test_no_reflection_discovery_or_validation_in_hot_path(monkeypatch: pytest.MonkeyPatch) -> None:
163
227
  runtime = assemble(
164
228
  variants=[Query], implementations=[QueryObject], required_interfaces=[Describable]
@@ -220,6 +284,56 @@ def test_unspecialized_implementation_is_rejected(bare: type[object]) -> None:
220
284
  assert f"{bare.__module__}.{bare.__qualname__}" in str(error.value)
221
285
 
222
286
 
287
+ def test_unspecialized_intermediate_implementation_is_rejected() -> None:
288
+ class Shared[T: DiscriminatedModel](Implementation[T]):
289
+ pass
290
+
291
+ # Deliberately omit the generic arguments, as an unchecked plugin may do.
292
+ bare = type("Unparameterized", (Shared,), {})
293
+ with pytest.raises(TypeError, match="expected explicit type arguments for .*Shared") as error:
294
+ assemble(variants=[Query], implementations=[bare])
295
+ assert "Unparameterized" in str(error.value)
296
+ assert (
297
+ f"Behavior implementation: {bare.__module__}.{bare.__qualname__}" in error.value.__notes__
298
+ )
299
+
300
+
301
+ @pytest.mark.skipif(
302
+ sys.version_info < (3, 13), reason="Native type parameter defaults require 3.13"
303
+ )
304
+ def test_implementation_type_parameter_defaults() -> None:
305
+ namespace: dict[str, Any] = {
306
+ "__name__": __name__,
307
+ "DiscriminatedModel": DiscriminatedModel,
308
+ "Implementation": Implementation,
309
+ "Query": Query,
310
+ "Describable": Describable,
311
+ }
312
+ # Keep the test module importable on Python 3.12.
313
+ exec(
314
+ """
315
+ class Shared[T: DiscriminatedModel = Query](Implementation[T]):
316
+ pass
317
+ class Bare(Shared):
318
+ pass
319
+ class Explicit(Shared[()], Describable):
320
+ def describe(self) -> str:
321
+ return self.model.expression
322
+ """,
323
+ namespace,
324
+ )
325
+ with pytest.raises(TypeError, match="Bare.*expected explicit type arguments for .*Shared"):
326
+ assemble(variants=[Query], implementations=[namespace["Bare"]])
327
+
328
+ runtime = assemble(
329
+ variants=[Query], implementations=[namespace["Explicit"]], required_interfaces=[Describable]
330
+ )
331
+ query = Query(identifier="q", expression="default")
332
+ view = runtime.adapt(query, Describable)
333
+ assert view.model is query
334
+ assert view.describe() == "default"
335
+
336
+
223
337
  def test_abstract_and_invalid_declarations() -> None:
224
338
  class Missing(Implementation[Query], Describable):
225
339
  pass
@@ -1,5 +1,6 @@
1
1
  """Exercise declarations, dispatch, isolation, and serialization boundaries."""
2
2
 
3
+ from abc import ABC, abstractmethod
3
4
  from datetime import UTC, datetime
4
5
  from types import SimpleNamespace
5
6
  from typing import Annotated, Any, Literal, Self, assert_type, cast, get_type_hints
@@ -292,6 +293,35 @@ def test_invalid_variant_inputs_report_the_value(value: object) -> None:
292
293
  assert repr(value) in str(error.value)
293
294
 
294
295
 
296
+ @pytest.mark.parametrize("binding", [False, True], ids=["adapter", "binding"])
297
+ def test_abstract_tagged_variants_are_rejected(binding: bool) -> None:
298
+ class AbstractQuery(Request[Literal["abstract"]], ABC):
299
+ @abstractmethod
300
+ def execute(self) -> str: ...
301
+
302
+ with pytest.raises(TypeError, match="AbstractQuery.*tagged leaf model"):
303
+ if binding:
304
+ bind_variants([AbstractQuery])
305
+ else:
306
+ build_adapter(Message[Any], variants=[AbstractQuery])
307
+
308
+ class ConcreteQuery(AbstractQuery):
309
+ def execute(self) -> str:
310
+ return self.identifier
311
+
312
+ annotation = Annotated[Message[Any], bind_variants([ConcreteQuery])]
313
+ adapter = (
314
+ TypeAdapter[Message[Any]](annotation)
315
+ if binding
316
+ else build_adapter(Message[Any], variants=[ConcreteQuery])
317
+ )
318
+ result = adapter.validate_python(
319
+ {"kind": "request", "method": "abstract", "identifier": "q", "sequence": 1}
320
+ )
321
+ assert isinstance(result, ConcreteQuery)
322
+ assert result.execute() == "q"
323
+
324
+
295
325
  def test_variant_bindings_are_explicit_independent_snapshots() -> None:
296
326
  before = Message.model_json_schema()
297
327
  variants: list[type[DiscriminatedModel]] = [Query]
@@ -551,6 +581,42 @@ def test_conditional_tag_exclusion_is_rejected_at_declaration() -> None:
551
581
  assert "Unsupported settings: exclude_if" in str(error.value)
552
582
 
553
583
 
584
+ @pytest.mark.parametrize(
585
+ "setting, value",
586
+ [
587
+ ("alias", "k"),
588
+ ("validation_alias", "k"),
589
+ ("serialization_alias", "k"),
590
+ ("exclude", True),
591
+ pytest.param(
592
+ "exclude_if",
593
+ bool,
594
+ marks=pytest.mark.skipif(
595
+ not hasattr(Field(), "exclude_if"), reason="Requires Pydantic's exclude_if"
596
+ ),
597
+ ),
598
+ ],
599
+ )
600
+ def test_specialization_preserves_tag_field_restrictions(setting: str, value: Any) -> None:
601
+ options = {setting: value}
602
+
603
+ class Native[Tag: str](BaseModel):
604
+ kind: Tag
605
+
606
+ native = Native[Annotated[Literal["hidden"], Field(**options)]]
607
+ if getattr(native.model_fields["kind"], setting) is None:
608
+ # Older Pydantic releases ignore these settings in generic type arguments.
609
+ # They remain safe only if tags are still exported and routable.
610
+ leaf = Message[Annotated[Literal["hidden"], Field(**options)]]
611
+ sample = leaf(identifier="q")
612
+ adapter = build_adapter(Message[Any], variants=[leaf])
613
+ assert sample.model_dump(by_alias=True) == {"kind": "hidden", "identifier": "q"}
614
+ assert adapter.validate_json(adapter.dump_json(sample, by_alias=True)) == sample
615
+ return
616
+ with pytest.raises(TypeError, match=f"Unsupported settings: .*{setting}"):
617
+ Message[Annotated[Literal["hidden"], Field(**options)]]
618
+
619
+
554
620
  def test_tag_field_is_the_single_source_of_the_discriminator() -> None:
555
621
  class Packet[Tag: str](DiscriminatedModel, extra="forbid"):
556
622
  operation: Annotated[Tag, Field(description="Wire operation")] = tag_field()
@@ -1250,7 +1250,7 @@ wheels = [
1250
1250
 
1251
1251
  [[package]]
1252
1252
  name = "typebind"
1253
- version = "0.1.0a1"
1253
+ version = "0.1.0a2"
1254
1254
  source = { editable = "." }
1255
1255
  dependencies = [
1256
1256
  { name = "pydantic" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes