From baeacaca2afd19396c1713fda5df2188b41dfb71 Mon Sep 17 00:00:00 2001 From: Eli Date: Wed, 2 Sep 2026 11:22:04 -0400 Subject: [PATCH] Expose and document `Encodable.register` The encoding registry was designed to be extensible so that a type the library serializes badly, or not at all, can be given an encoding without a library-level fix. That hook was only reachable as `TypeToPydanticType.register` on the internal handler class, so the escape hatch was effectively undiscoverable from the public interface. `register` is now a classmethod on `Encodable`, documented with a doctest that starts from the failure a user actually hits and ends with the type encoding, decoding and schematizing. It takes a `TypeForm` rather than a `type`, since the registry also accepts unions and typing special forms, and returns a decorator that preserves the function it decorates. The internal registrations keep calling `TypeToPydanticType.register`; the library registering into its own handler class is the right layering. The developer-facing pointer in `_NoEncoding`'s error message still names the internal class, deliberately: #770 deletes that block outright, so editing it here would buy nothing but a merge conflict. `Encodable` stays a `TYPE_CHECKING`-only alias, so `@Encodable.register` in typed code needs `# type: ignore[attr-defined]`, as the example shows. That is forced, not incidental: mypy resolves a name as either a generic alias (usable in annotations) or an object with attributes, never both. Replacing the alias with a class of any shape -- plain, generic, Protocol, or a `TypeForm`-typed metaclass `__getitem__` -- makes `register` check but breaks `field: Encodable[T]`, and mypy ignores `__class_getitem__` entirely, so no subscript signature can rescue it. Under that ignore the call is `Any`, so the signature documents rather than checks. Closes #769 Co-Authored-By: Claude Opus 5 (1M context) --- effectful/handlers/llm/types.py | 57 ++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/effectful/handlers/llm/types.py b/effectful/handlers/llm/types.py index 572025576..adaed3f66 100644 --- a/effectful/handlers/llm/types.py +++ b/effectful/handlers/llm/types.py @@ -52,6 +52,8 @@ import typing import uuid +import typing_extensions + import effectful.ops.types __all__ = ["Agent", "Skill", "Template", "Tool", "Encodable"] @@ -562,13 +564,60 @@ class Encodable: already has the declared type. Custom types register their JSON representation with - `TypeToPydanticType`. Because the - encoding is derived from the *type*, it is the single source of truth - for both the schema shown to the model and the validation applied to - its output. + `Encodable.register`. Because the encoding is derived from the *type*, + it is the single source of truth for both the schema shown to the model + and the validation applied to its output. """ def __class_getitem__(cls, item): from effectful.handlers.llm.harness.serialization import TypeToPydanticType return TypeToPydanticType().evaluate(item) + + @classmethod + def register[F: collections.abc.Callable[..., typing.Any]]( + cls, ty: typing_extensions.TypeForm + ) -> collections.abc.Callable[[F], F]: + """Give a type an encoding, or replace the one it has. + + The decorated function receives a type expression whose arguments + are already encoded, and returns a Pydantic-compatible annotation + of that same type -- adding validators, a serializer and a JSON + schema, never changing what the annotation denotes. + + >>> import typing, pydantic + >>> from effectful.handlers.llm import Encodable + >>> class Money: + ... def __init__(self, cents: int): + ... self.cents = cents + + Pydantic cannot build a schema for `Money`, so nothing the model + writes decodes to one: + + >>> pydantic.TypeAdapter(Money) # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for Money + + >>> @Encodable.register(Money) # type: ignore[attr-defined] + ... def _encode_money(ty): + ... return typing.Annotated[ + ... ty, + ... pydantic.InstanceOf, + ... pydantic.BeforeValidator( + ... lambda v: v if isinstance(v, Money) else Money(v) + ... ), + ... pydantic.PlainSerializer(lambda money: money.cents), + ... pydantic.WithJsonSchema({"type": "integer"}), + ... ] + >>> adapter = pydantic.TypeAdapter(Encodable[Money]) + >>> adapter.json_schema() + {'type': 'integer'} + >>> adapter.dump_python(Money(250), mode="json") + 250 + >>> adapter.validate_python(250).cents + 250 + """ + from effectful.handlers.llm.harness.serialization import TypeToPydanticType + + return TypeToPydanticType.register(ty)