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, diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py index 2fc5053..ec5943f 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) } @@ -158,6 +169,44 @@ 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, + 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 9660f5f..80b4ecb 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"], "title": "Shape", "type": "string"} + with pytest.raises(pydantic.ValidationError): + adapter.validate_python("triangle") + with pytest.raises(pydantic.ValidationError): + adapter.validate_python(Triangle)