diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e716f..525f4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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.7 + +Added: +- `dataclass_from_mapping` decodes a field annotated `type[Family]`, where `Family` is an `AutoRegisterMeta` family, from the registry key that `to_jsonable` writes for the class. A key outside the family fails loudly. Decoding goes through `registered_class_from_key`, which dispatches on the family's metaclass, so a family whose boundary spelling is not its registry key registers its own decoder. +- `RegisteredClassChoices(family)`: the `AnnotationChoices` of a field holding one class of a registered family, labelled by registry key. + ## 0.2.6 Added: diff --git a/pyproject.toml b/pyproject.toml index d0ea8da..5227ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.6" +version = "0.2.7" 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 b619c4c..8922268 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.6" +__version__ = "0.2.7" from .signature_analyzer import ( SignatureAnalyzer, @@ -56,6 +56,8 @@ JsonObject, JsonScalar, JsonValue, + RegisteredClassChoices, + registered_class_from_key, to_jsonable, ) from .public_api import ( @@ -122,6 +124,7 @@ "validate_annotated_dataclass", "validate_annotation_value", "AnnotationChoices", + "RegisteredClassChoices", "declared_annotation_choices", "FieldLabel", "declared_field_label", @@ -132,6 +135,7 @@ "JsonObject", "JsonScalar", "JsonValue", + "registered_class_from_key", "to_jsonable", # Module public surfaces "declared_public_names", diff --git a/src/python_introspect/dataclass_projection.py b/src/python_introspect/dataclass_projection.py index 2981241..2a8ec9d 100644 --- a/src/python_introspect/dataclass_projection.py +++ b/src/python_introspect/dataclass_projection.py @@ -15,7 +15,10 @@ get_origin, ) +from metaclass_registry import AutoRegisterMeta + from .annotation_types import is_union_type, resolved_class_annotations +from .jsonable import registered_class_from_key from .validation import validate_annotated_dataclass, validate_annotation_value DataclassT = TypeVar("DataclassT") @@ -262,6 +265,16 @@ def _mapping_value_for_annotation( ) for key, item in value.items() } + if origin is type: + bounds = get_args(annotation) + if ( + not isinstance(value, type) + and len(bounds) == 1 + and isinstance(bounds[0], AutoRegisterMeta) + ): + value = registered_class_from_key(bounds[0], value) + validate_annotation_value(annotation, value, path=path) + return value if annotation is type(None): if value is not None: raise TypeError(f"{path} must be null.") diff --git a/src/python_introspect/jsonable.py b/src/python_introspect/jsonable.py index ba97e3d..2fc5053 100644 --- a/src/python_introspect/jsonable.py +++ b/src/python_introspect/jsonable.py @@ -4,13 +4,19 @@ dataclass projects through its declared fields, and the decoder rebuilds it from the same declarations. Further value types join through ``to_jsonable.register``. + +A class registered in an ``AutoRegisterMeta`` family travels as its registry +key; ``registered_class_from_key`` is the inverse the decoder uses for fields +annotated ``type[Family]``. A family whose boundary spelling is not its +registry (for example one resolved through an active subset) registers its own +decoder on its metaclass. """ from __future__ import annotations import inspect from collections.abc import Callable, Mapping -from dataclasses import fields, is_dataclass +from dataclasses import dataclass, fields, is_dataclass from enum import Enum from functools import singledispatch from pathlib import Path @@ -18,6 +24,7 @@ from metaclass_registry import AutoRegisterMeta +from .choices import AnnotationChoices from .signature_analyzer import signature_analysis_target JsonScalar: TypeAlias = str | int | float | bool | None @@ -101,6 +108,57 @@ def _jsonable_registered_type(value: AutoRegisterMeta) -> JsonValue: return to_jsonable(key) +@singledispatch +def registered_class_from_key(family: type, key: JsonValue) -> type: + """Return the member of ``family`` that ``to_jsonable`` spells as ``key``. + + Dispatches on the family's metaclass, mirroring how ``to_jsonable`` + dispatches a registered class on its metaclass. + """ + + raise TypeError( + f"{family.__qualname__} is not a registered family; " + f"cannot decode {key!r} into one of its classes." + ) + + +@registered_class_from_key.register(AutoRegisterMeta) +def _registered_class_from_registry(family: AutoRegisterMeta, key: JsonValue) -> type: + keys_by_member = { + member: to_jsonable(registered_key) + for registered_key, member in family.__registry__.items() + if issubclass(member, family) + } + matches = [member for member, member_key in keys_by_member.items() if member_key == key] + if len(matches) != 1: + raise ValueError( + f"{key!r} is not a registered {family.__qualname__}; " + f"registered: {sorted(map(str, keys_by_member.values()))}." + ) + return matches[0] + + +@dataclass(frozen=True) +class RegisteredClassChoices(AnnotationChoices): + """A field holding one class of a registered family. + + The choices are the family's registered members in registration order, each + labelled by its JSON spelling (its registry key). + """ + + family: AutoRegisterMeta + + def choices(self) -> tuple[object, ...]: + return tuple( + member + for member in self.family.__registry__.values() + if issubclass(member, self.family) + ) + + def label(self, choice: object) -> str: + return str(to_jsonable(choice)) + + @to_jsonable.register(Enum) def _jsonable_enum(value: Enum) -> JsonValue: return to_jsonable(value.value) diff --git a/tests/test_jsonable.py b/tests/test_jsonable.py index a99d479..9660f5f 100644 --- a/tests/test_jsonable.py +++ b/tests/test_jsonable.py @@ -12,7 +12,9 @@ from python_introspect import ( JsonObject, JsonValue, + RegisteredClassChoices, dataclass_from_mapping, + registered_class_from_key, to_jsonable, ) @@ -128,3 +130,87 @@ def _celsius(value: Celsius) -> JsonValue: return {"celsius": value.degrees} assert to_jsonable({"t": Celsius(21.0)}) == {"t": {"celsius": 21.0}} + + +class Shape(metaclass=AutoRegisterMeta): + __registry_key__ = "shape_name" + __skip_if_no_key__ = True + shape_name = None + + +class Circle(Shape): + shape_name = "circle" + + +class Square(Shape): + shape_name = "square" + + +class Unrelated(metaclass=AutoRegisterMeta): + __registry_key__ = "shape_name" + __skip_if_no_key__ = True + shape_name = None + + +class Triangle(Unrelated): + shape_name = "triangle" + + +@dataclass(frozen=True) +class Drawing: + shape: type[Shape] + outline: type[Shape] | None = None + layers: tuple[type[Shape], ...] = () + + +class AliasMeta(AutoRegisterMeta): + """A family whose boundary spelling is not its registry key.""" + + +class Colour(metaclass=AliasMeta): + __registry_key__ = "colour_name" + __skip_if_no_key__ = True + colour_name = None + + +class Red(Colour): + colour_name = "red" + + +@registered_class_from_key.register(AliasMeta) +def _colour_from_alias(family: AliasMeta, key: JsonValue) -> type: + return {"r": Red}[key] + + +@dataclass(frozen=True) +class Paint: + colour: type[Colour] + + +def test_registered_classes_round_trip_through_their_registry_key(): + drawing = Drawing(shape=Circle, outline=Square, layers=(Square, Circle)) + encoded = to_jsonable(drawing) + + assert encoded == {"shape": "circle", "outline": "square", "layers": ["square", "circle"]} + assert dataclass_from_mapping(Drawing, encoded) == drawing + assert dataclass_from_mapping(Drawing, {"shape": Square}).shape is Square + + +def test_registered_class_keys_decode_only_within_their_family(): + with pytest.raises(ValueError, match="'triangle' is not a registered Shape"): + dataclass_from_mapping(Drawing, {"shape": "triangle"}) + with pytest.raises(TypeError, match="must be a subclass of Shape"): + dataclass_from_mapping(Drawing, {"shape": Triangle}) + + +def test_family_with_its_own_spelling_registers_a_decoder_on_its_metaclass(): + assert dataclass_from_mapping(Paint, {"colour": "r"}).colour is Red + + +def test_registered_class_choices_list_members_by_their_key(): + choices = RegisteredClassChoices(Shape) + + assert choices.choices() == (Circle, Square) + assert [choices.label(choice) for choice in choices.choices()] == ["circle", "square"] + assert choices.choice_for_label("square") is Square + assert choices == RegisteredClassChoices(Shape)