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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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.6"
version = "0.2.7"
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
6 changes: 5 additions & 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.6"
__version__ = "0.2.7"

from .signature_analyzer import (
SignatureAnalyzer,
Expand Down Expand Up @@ -56,6 +56,8 @@
JsonObject,
JsonScalar,
JsonValue,
RegisteredClassChoices,
registered_class_from_key,
to_jsonable,
)
from .public_api import (
Expand Down Expand Up @@ -122,6 +124,7 @@
"validate_annotated_dataclass",
"validate_annotation_value",
"AnnotationChoices",
"RegisteredClassChoices",
"declared_annotation_choices",
"FieldLabel",
"declared_field_label",
Expand All @@ -132,6 +135,7 @@
"JsonObject",
"JsonScalar",
"JsonValue",
"registered_class_from_key",
"to_jsonable",
# Module public surfaces
"declared_public_names",
Expand Down
13 changes: 13 additions & 0 deletions src/python_introspect/dataclass_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.")
Expand Down
60 changes: 59 additions & 1 deletion src/python_introspect/jsonable.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,27 @@
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
from typing import ForwardRef, TypeAlias

from metaclass_registry import AutoRegisterMeta

from .choices import AnnotationChoices
from .signature_analyzer import signature_analysis_target

JsonScalar: TypeAlias = str | int | float | bool | None
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions tests/test_jsonable.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from python_introspect import (
JsonObject,
JsonValue,
RegisteredClassChoices,
dataclass_from_mapping,
registered_class_from_key,
to_jsonable,
)

Expand Down Expand Up @@ -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)
Loading