From 9543848af8ad8a79996706c62e43ad288e75a443 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 10:39:49 -0400 Subject: [PATCH 1/4] RegisteredClassChoices validates registry keys in pydantic A field annotated with RegisteredClassChoices(family) now accepts a member class or its registry key, emits the key, and publishes the keys as a JSON schema enum, so pydantic-built tool schemas (MCP) accept family-valued fields. Co-Authored-By: Claude Opus 5.5 --- src/python_introspect/jsonable.py | 20 ++++++++++++++++++++ tests/test_jsonable.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py index 2fc5053..c79c79a 100644 --- a/src/python_introspect/jsonable.py +++ b/src/python_introspect/jsonable.py @@ -158,6 +158,26 @@ def choices(self) -> tuple[object, ...]: def label(self, choice: object) -> str: return str(to_jsonable(choice)) + def __get_pydantic_core_schema__(self, source_type, handler): + """Let pydantic accept a member or its registry key and emit the key.""" + + from pydantic_core import core_schema + + def member(value: object) -> type: + if isinstance(value, type): + if issubclass(value, self.family): + return value + raise ValueError(f"{value!r} is not a {self.family.__qualname__}.") + return registered_class_from_key(self.family, value) + + return core_schema.no_info_plain_validator_function( + member, + json_schema_input_schema=core_schema.literal_schema( + [to_jsonable(choice) for choice in self.choices()] + ), + serialization=core_schema.plain_serializer_function_ser_schema(to_jsonable), + ) + @to_jsonable.register(Enum) def _jsonable_enum(value: Enum) -> JsonValue: diff --git a/tests/test_jsonable.py b/tests/test_jsonable.py index 9660f5f..55850e3 100644 --- a/tests/test_jsonable.py +++ b/tests/test_jsonable.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from enum import Enum from pathlib import Path, PurePath +from typing import Annotated import pytest from metaclass_registry import AutoRegisterMeta @@ -214,3 +215,17 @@ def test_registered_class_choices_list_members_by_their_key(): assert [choices.label(choice) for choice in choices.choices()] == ["circle", "square"] assert choices.choice_for_label("square") is Square assert choices == RegisteredClassChoices(Shape) + + +def test_registered_class_choices_validate_keys_in_pydantic(): + pydantic = pytest.importorskip("pydantic") + adapter = pydantic.TypeAdapter(Annotated[type[Shape], RegisteredClassChoices(Shape)]) + + assert adapter.validate_python("square") is Square + assert adapter.validate_python(Circle) is Circle + assert adapter.dump_python(Square, mode="json") == "square" + assert adapter.json_schema() == {"enum": ["circle", "square"], "type": "string"} + with pytest.raises(pydantic.ValidationError): + adapter.validate_python("triangle") + with pytest.raises(pydantic.ValidationError): + adapter.validate_python(Triangle) From 0d075a9e1681c5385c119bb2f558d9121a7a2671 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 10:58:42 -0400 Subject: [PATCH 2/4] 0.2.8: RegisteredClassChoices in pydantic Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- src/python_introspect/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 525f4b9..f5035a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 0.2.2 is the first 0.2.x release published to PyPI. The 0.2.0 and 0.2.1 tags exist in git, but their publish runs were cancelled, so upgrading from PyPI goes straight from 0.1.16 to 0.2.2. The notes below cover every change in that jump. +## 0.2.8 + +Added: +- `RegisteredClassChoices` validates in pydantic: a field annotated with it accepts a member class or its registry key, serializes to the key, and publishes the keys as a JSON-schema enum, so pydantic-built tool schemas (for example MCP) accept family-valued fields. + ## 0.2.7 Added: diff --git a/pyproject.toml b/pyproject.toml index 5227ed1..1fa66e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.7" +version = "0.2.8" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index 8922268..8f09aa4 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.2.7" +__version__ = "0.2.8" from .signature_analyzer import ( SignatureAnalyzer, From be47ec8d8d2379e19964f9b36a76d17d5b3e5f7b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 13:15:42 -0400 Subject: [PATCH 3/4] Decode registry keys without running the encoder registered_class_from_key compared each member's to_jsonable spelling, so decoding a family-valued field ran the encoder; scalar registry keys are their own spelling and are read directly. Co-Authored-By: Claude Opus 5.5 --- src/python_introspect/jsonable.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py index c79c79a..28eb408 100644 --- a/src/python_introspect/jsonable.py +++ b/src/python_introspect/jsonable.py @@ -122,10 +122,21 @@ def registered_class_from_key(family: type, key: JsonValue) -> type: ) +def _json_key(registered_key: object) -> JsonValue: + """A registry key as ``to_jsonable`` spells it; scalar keys are their own spelling. + + Decoding reads scalar keys directly so it never runs the encoder. + """ + + if isinstance(registered_key, (str, int, float, bool)): + return registered_key + return to_jsonable(registered_key) + + @registered_class_from_key.register(AutoRegisterMeta) def _registered_class_from_registry(family: AutoRegisterMeta, key: JsonValue) -> type: keys_by_member = { - member: to_jsonable(registered_key) + member: _json_key(registered_key) for registered_key, member in family.__registry__.items() if issubclass(member, family) } From 47e33a2587bff0e193e4f4dd3cee6ae2034464df Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 13:18:47 -0400 Subject: [PATCH 4/4] RegisteredClassChoices publishes a named enum definition in JSON schema The field's schema is a $ref to a definition titled with the family's name, described by its docstring's first paragraph, listing the registry keys. Co-Authored-By: Claude Opus 5.5 --- src/python_introspect/jsonable.py | 18 ++++++++++++++++++ tests/test_jsonable.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py index 28eb408..ec5943f 100644 --- a/src/python_introspect/jsonable.py +++ b/src/python_introspect/jsonable.py @@ -183,12 +183,30 @@ def member(value: object) -> type: return core_schema.no_info_plain_validator_function( member, + ref=f"{self.family.__module__}.{self.family.__qualname__}", json_schema_input_schema=core_schema.literal_schema( [to_jsonable(choice) for choice in self.choices()] ), serialization=core_schema.plain_serializer_function_ser_schema(to_jsonable), ) + def __get_pydantic_json_schema__(self, core_schema, handler): + """Publish the family as a named string enum of its registry keys.""" + + description = (self.family.__doc__ or "").strip().split("\n\n")[0] + schema = { + "enum": [to_jsonable(choice) for choice in self.choices()], + "title": self.family.__name__, + "type": "string", + } + if description: + schema["description"] = " ".join(description.split()) + reference = handler(core_schema) + definition = handler.resolve_ref_schema(reference) + definition.clear() + definition.update(schema) + return reference + @to_jsonable.register(Enum) def _jsonable_enum(value: Enum) -> JsonValue: diff --git a/tests/test_jsonable.py b/tests/test_jsonable.py index 55850e3..80b4ecb 100644 --- a/tests/test_jsonable.py +++ b/tests/test_jsonable.py @@ -224,7 +224,7 @@ def test_registered_class_choices_validate_keys_in_pydantic(): assert adapter.validate_python("square") is Square assert adapter.validate_python(Circle) is Circle assert adapter.dump_python(Square, mode="json") == "square" - assert adapter.json_schema() == {"enum": ["circle", "square"], "type": "string"} + assert adapter.json_schema() == {"enum": ["circle", "square"], "title": "Shape", "type": "string"} with pytest.raises(pydantic.ValidationError): adapter.validate_python("triangle") with pytest.raises(pydantic.ValidationError):