Skip to content

Commit 58a2b79

Browse files
trissimclaude
andcommitted
Decode registered classes from their registry key; RegisteredClassChoices; 0.2.6
to_jsonable already writes a class of an AutoRegisterMeta family as its registry key. dataclass_from_mapping now reads it back for fields annotated type[Family], through registered_class_from_key, which dispatches on the family's metaclass so a family with its own boundary spelling registers its decoder. RegisteredClassChoices is the form-choice view of a registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4bb7d13 commit 58a2b79

6 files changed

Lines changed: 170 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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.
44

5+
## 0.2.6
6+
7+
Added:
8+
- `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.
9+
- `RegisteredClassChoices(family)`: the `AnnotationChoices` of a field holding one class of a registered family, labelled by registry key.
10+
511
## 0.2.5
612

713
Fixed:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-introspect"
7-
version = "0.2.5"
7+
version = "0.2.6"
88
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎src/python_introspect/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
type resolution for framework-specific types (lazy configs, proxies, etc.)
1010
"""
1111

12-
__version__ = "0.2.5"
12+
__version__ = "0.2.6"
1313

1414
from .signature_analyzer import (
1515
SignatureAnalyzer,
@@ -55,6 +55,8 @@
5555
JsonObject,
5656
JsonScalar,
5757
JsonValue,
58+
RegisteredClassChoices,
59+
registered_class_from_key,
5860
to_jsonable,
5961
)
6062
from .public_api import (
@@ -121,6 +123,7 @@
121123
"validate_annotated_dataclass",
122124
"validate_annotation_value",
123125
"AnnotationChoices",
126+
"RegisteredClassChoices",
124127
"declared_annotation_choices",
125128
# Dataclass projection
126129
"dataclass_from_mapping",
@@ -129,6 +132,7 @@
129132
"JsonObject",
130133
"JsonScalar",
131134
"JsonValue",
135+
"registered_class_from_key",
132136
"to_jsonable",
133137
# Module public surfaces
134138
"declared_public_names",

‎src/python_introspect/dataclass_projection.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
get_origin,
1616
)
1717

18+
from metaclass_registry import AutoRegisterMeta
19+
1820
from .annotation_types import is_union_type, resolved_class_annotations
21+
from .jsonable import registered_class_from_key
1922
from .validation import validate_annotated_dataclass, validate_annotation_value
2023

2124
DataclassT = TypeVar("DataclassT")
@@ -262,6 +265,16 @@ def _mapping_value_for_annotation(
262265
)
263266
for key, item in value.items()
264267
}
268+
if origin is type:
269+
bounds = get_args(annotation)
270+
if (
271+
not isinstance(value, type)
272+
and len(bounds) == 1
273+
and isinstance(bounds[0], AutoRegisterMeta)
274+
):
275+
value = registered_class_from_key(bounds[0], value)
276+
validate_annotation_value(annotation, value, path=path)
277+
return value
265278
if annotation is type(None):
266279
if value is not None:
267280
raise TypeError(f"{path} must be null.")

‎src/python_introspect/jsonable.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,27 @@
44
dataclass projects through its declared fields, and the decoder rebuilds it
55
from the same declarations. Further value types join through
66
``to_jsonable.register``.
7+
8+
A class registered in an ``AutoRegisterMeta`` family travels as its registry
9+
key; ``registered_class_from_key`` is the inverse the decoder uses for fields
10+
annotated ``type[Family]``. A family whose boundary spelling is not its
11+
registry (for example one resolved through an active subset) registers its own
12+
decoder on its metaclass.
713
"""
814

915
from __future__ import annotations
1016

1117
import inspect
1218
from collections.abc import Callable, Mapping
13-
from dataclasses import fields, is_dataclass
19+
from dataclasses import dataclass, fields, is_dataclass
1420
from enum import Enum
1521
from functools import singledispatch
1622
from pathlib import Path
1723
from typing import ForwardRef, TypeAlias
1824

1925
from metaclass_registry import AutoRegisterMeta
2026

27+
from .choices import AnnotationChoices
2128
from .signature_analyzer import signature_analysis_target
2229

2330
JsonScalar: TypeAlias = str | int | float | bool | None
@@ -101,6 +108,57 @@ def _jsonable_registered_type(value: AutoRegisterMeta) -> JsonValue:
101108
return to_jsonable(key)
102109

103110

111+
@singledispatch
112+
def registered_class_from_key(family: type, key: JsonValue) -> type:
113+
"""Return the member of ``family`` that ``to_jsonable`` spells as ``key``.
114+
115+
Dispatches on the family's metaclass, mirroring how ``to_jsonable``
116+
dispatches a registered class on its metaclass.
117+
"""
118+
119+
raise TypeError(
120+
f"{family.__qualname__} is not a registered family; "
121+
f"cannot decode {key!r} into one of its classes."
122+
)
123+
124+
125+
@registered_class_from_key.register(AutoRegisterMeta)
126+
def _registered_class_from_registry(family: AutoRegisterMeta, key: JsonValue) -> type:
127+
keys_by_member = {
128+
member: to_jsonable(registered_key)
129+
for registered_key, member in family.__registry__.items()
130+
if issubclass(member, family)
131+
}
132+
matches = [member for member, member_key in keys_by_member.items() if member_key == key]
133+
if len(matches) != 1:
134+
raise ValueError(
135+
f"{key!r} is not a registered {family.__qualname__}; "
136+
f"registered: {sorted(map(str, keys_by_member.values()))}."
137+
)
138+
return matches[0]
139+
140+
141+
@dataclass(frozen=True)
142+
class RegisteredClassChoices(AnnotationChoices):
143+
"""A field holding one class of a registered family.
144+
145+
The choices are the family's registered members in registration order, each
146+
labelled by its JSON spelling (its registry key).
147+
"""
148+
149+
family: AutoRegisterMeta
150+
151+
def choices(self) -> tuple[object, ...]:
152+
return tuple(
153+
member
154+
for member in self.family.__registry__.values()
155+
if issubclass(member, self.family)
156+
)
157+
158+
def label(self, choice: object) -> str:
159+
return str(to_jsonable(choice))
160+
161+
104162
@to_jsonable.register(Enum)
105163
def _jsonable_enum(value: Enum) -> JsonValue:
106164
return to_jsonable(value.value)

‎tests/test_jsonable.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from python_introspect import (
1313
JsonObject,
1414
JsonValue,
15+
RegisteredClassChoices,
1516
dataclass_from_mapping,
17+
registered_class_from_key,
1618
to_jsonable,
1719
)
1820

@@ -128,3 +130,87 @@ def _celsius(value: Celsius) -> JsonValue:
128130
return {"celsius": value.degrees}
129131

130132
assert to_jsonable({"t": Celsius(21.0)}) == {"t": {"celsius": 21.0}}
133+
134+
135+
class Shape(metaclass=AutoRegisterMeta):
136+
__registry_key__ = "shape_name"
137+
__skip_if_no_key__ = True
138+
shape_name = None
139+
140+
141+
class Circle(Shape):
142+
shape_name = "circle"
143+
144+
145+
class Square(Shape):
146+
shape_name = "square"
147+
148+
149+
class Unrelated(metaclass=AutoRegisterMeta):
150+
__registry_key__ = "shape_name"
151+
__skip_if_no_key__ = True
152+
shape_name = None
153+
154+
155+
class Triangle(Unrelated):
156+
shape_name = "triangle"
157+
158+
159+
@dataclass(frozen=True)
160+
class Drawing:
161+
shape: type[Shape]
162+
outline: type[Shape] | None = None
163+
layers: tuple[type[Shape], ...] = ()
164+
165+
166+
class AliasMeta(AutoRegisterMeta):
167+
"""A family whose boundary spelling is not its registry key."""
168+
169+
170+
class Colour(metaclass=AliasMeta):
171+
__registry_key__ = "colour_name"
172+
__skip_if_no_key__ = True
173+
colour_name = None
174+
175+
176+
class Red(Colour):
177+
colour_name = "red"
178+
179+
180+
@registered_class_from_key.register(AliasMeta)
181+
def _colour_from_alias(family: AliasMeta, key: JsonValue) -> type:
182+
return {"r": Red}[key]
183+
184+
185+
@dataclass(frozen=True)
186+
class Paint:
187+
colour: type[Colour]
188+
189+
190+
def test_registered_classes_round_trip_through_their_registry_key():
191+
drawing = Drawing(shape=Circle, outline=Square, layers=(Square, Circle))
192+
encoded = to_jsonable(drawing)
193+
194+
assert encoded == {"shape": "circle", "outline": "square", "layers": ["square", "circle"]}
195+
assert dataclass_from_mapping(Drawing, encoded) == drawing
196+
assert dataclass_from_mapping(Drawing, {"shape": Square}).shape is Square
197+
198+
199+
def test_registered_class_keys_decode_only_within_their_family():
200+
with pytest.raises(ValueError, match="'triangle' is not a registered Shape"):
201+
dataclass_from_mapping(Drawing, {"shape": "triangle"})
202+
with pytest.raises(TypeError, match="must be a subclass of Shape"):
203+
dataclass_from_mapping(Drawing, {"shape": Triangle})
204+
205+
206+
def test_family_with_its_own_spelling_registers_a_decoder_on_its_metaclass():
207+
assert dataclass_from_mapping(Paint, {"colour": "r"}).colour is Red
208+
209+
210+
def test_registered_class_choices_list_members_by_their_key():
211+
choices = RegisteredClassChoices(Shape)
212+
213+
assert choices.choices() == (Circle, Square)
214+
assert [choices.label(choice) for choice in choices.choices()] == ["circle", "square"]
215+
assert choices.choice_for_label("square") is Square
216+
assert choices == RegisteredClassChoices(Shape)

0 commit comments

Comments
 (0)