Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/python_introspect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion src/python_introspect/jsonable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions tests/test_jsonable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading