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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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.3.0

Changed (the stored form of some fields changes; there is no reader for the old form):
- A dataclass field whose declared type admits several classes is stored as `{tag: fields}`, and `dataclass_from_mapping` reads the class from the tag. Before, the member of a union was guessed from which fields were present: two members with the same fields could not be told apart and the stored value did not say which class it was. Three declarations form such a family (`MemberFamily.declared_by`): a union with two or more dataclass members (other arms such as `None` or `str` stay untagged), an abstract dataclass (its concrete subclasses are the members), and a class whose metaclass is `AutoRegisterMeta`. The tag is the member's registry key when it is registered, so it survives a module move or a rename; otherwise it is the class name. The rule holds inside `tuple`, `list`, `Sequence`, `Mapping`, optional and `Annotated` declarations.
- An unknown tag is a `ValueError` naming the known members. A value whose class is not a member of the declared family is a `TypeError` when encoding. Two members with the same tag are a `TypeError`.
- `to_jsonable` reads the declared field types of every dataclass it encodes, so those annotations must resolve at run time (a name imported only under `TYPE_CHECKING` no longer encodes).

## 0.2.8

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.8"
version = "0.3.0"
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
4 changes: 3 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.8"
__version__ = "0.3.0"

from .signature_analyzer import (
SignatureAnalyzer,
Expand Down Expand Up @@ -57,6 +57,7 @@
JsonScalar,
JsonValue,
RegisteredClassChoices,
MemberFamily,
registered_class_from_key,
to_jsonable,
)
Expand Down Expand Up @@ -135,6 +136,7 @@
"JsonObject",
"JsonScalar",
"JsonValue",
"MemberFamily",
"registered_class_from_key",
"to_jsonable",
# Module public surfaces
Expand Down
26 changes: 24 additions & 2 deletions src/python_introspect/dataclass_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from metaclass_registry import AutoRegisterMeta

from .annotation_types import is_union_type, resolved_class_annotations
from .jsonable import registered_class_from_key
from .jsonable import MemberFamily, registered_class_from_key
from .validation import validate_annotated_dataclass, validate_annotation_value

DataclassT = TypeVar("DataclassT")
Expand Down Expand Up @@ -157,10 +157,32 @@ def _mapping_value_for_annotation(
if not isinstance(value, (list, tuple)):
raise TypeError(f"{path} must be an array.")
return tuple(value)
family = MemberFamily.declared_by(annotation)
if family is not None and isinstance(value, Mapping):
if len(value) != 1:
raise ValueError(
f"{path} must be one member of {family.name} stored as "
f"{{tag: fields}}; got the keys {sorted(map(str, value))}."
)
((tag, member_fields),) = value.items()
try:
member = family.member_tagged(tag)
except ValueError as error:
raise ValueError(f"{path}: {error}") from error
if not isinstance(member_fields, Mapping):
raise TypeError(f"{path}.{tag} must be an object.")
return dataclass_from_mapping(member, member_fields)
if is_union_type(annotation):
successes: list[object] = []
errors: list[Exception] = []
for member_type in get_args(annotation):
untagged_arms = tuple(
arm
for arm in get_args(annotation)
if family is None or not (isinstance(arm, type) and is_dataclass(arm))
)
if family is not None and isinstance(value, family.members()):
return value
for member_type in untagged_arms:
try:
converted = _mapping_value_for_annotation(
member_type,
Expand Down
238 changes: 233 additions & 5 deletions src/python_introspect/jsonable.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@
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.

A dataclass field whose declared type admits several classes (``MemberFamily``:
a union of dataclasses, an abstract dataclass, or a registered class) holds its
value as ``{tag: fields}``. The tag is the member's registry key when its
metaclass registers it and its class name otherwise, so the stored form names
the class instead of leaving the decoder to guess it from the fields present.
"""

from __future__ import annotations

import inspect
from collections.abc import Callable, Mapping
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence, Set
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 typing import Annotated, ForwardRef, TypeAlias, get_args, get_origin
from weakref import WeakKeyDictionary

from metaclass_registry import AutoRegisterMeta

from .annotation_types import is_union_type, resolved_class_annotations
from .choices import AnnotationChoices
from .signature_analyzer import signature_analysis_target

Expand All @@ -40,15 +49,94 @@
JsonObject: TypeAlias = Mapping[str, JsonValue]


_FIELD_TYPES_BY_DATACLASS: WeakKeyDictionary[type, Mapping[str, object]] = WeakKeyDictionary()


def _declared_field_types(owner: type) -> Mapping[str, object]:
if owner not in _FIELD_TYPES_BY_DATACLASS:
try:
_FIELD_TYPES_BY_DATACLASS[owner] = resolved_class_annotations(owner)
except NameError as error:
raise TypeError(
f"{owner.__qualname__} cannot be projected: a field type does not resolve "
f"at run time ({error}). A field's declared type decides its stored form, "
"so import the name outside TYPE_CHECKING."
) from error
return _FIELD_TYPES_BY_DATACLASS[owner]


def _jsonable_dataclass(value: object) -> JsonValue:
"""Project fields without ``asdict`` deep-copying immutable containers."""
"""Project each field by its declared type, without ``asdict`` deep copies."""

declared_types = _declared_field_types(type(value))
return {
declared_field.name: to_jsonable(getattr(value, declared_field.name))
declared_field.name: _jsonable_as_declared(
declared_types[declared_field.name], getattr(value, declared_field.name)
)
for declared_field in fields(value)
}


def _projects_by_declared_fields(value: object) -> bool:
"""Whether ``to_jsonable`` writes ``value`` as its dataclass fields.

A type with its own registered projection owns its whole stored form,
including how that form names the class.
"""

return (
is_dataclass(value)
and not isinstance(value, type)
and to_jsonable.dispatch(type(value)) in (to_jsonable.registry[object], _jsonable_callable)
)


def _jsonable_as_declared(annotation: object, value: object) -> JsonValue:
"""Project ``value`` as a field declared ``annotation`` stores it.

The declaration is followed through optional, annotated and container types
to the places where it admits several classes; a value there is wrapped in
its member tag. Everything else projects by its own type.
"""

if value is None:
return None
origin = get_origin(annotation)
if origin is Annotated:
return _jsonable_as_declared(get_args(annotation)[0], value)
family = MemberFamily.declared_by(annotation)
if family is not None and _projects_by_declared_fields(value):
return {family.tag_of(type(value)): to_jsonable(value)}
if is_union_type(annotation):
arms = tuple(
arm
for arm in get_args(annotation)
if isinstance(runtime_class := get_origin(arm) or arm, type)
and runtime_class is not type(None)
and isinstance(value, runtime_class)
)
return _jsonable_as_declared(arms[0], value) if len(arms) == 1 else to_jsonable(value)
item_types = get_args(annotation)
if (
isinstance(origin, type)
and item_types
and isinstance(value, origin)
and not isinstance(value, (str, bytes, bytearray))
):
if issubclass(origin, Mapping) and len(item_types) == 2:
return {
str(to_jsonable(key)): _jsonable_as_declared(item_types[1], item)
for key, item in value.items()
}
if origin is tuple and Ellipsis not in item_types and len(item_types) == len(value):
return [
_jsonable_as_declared(item_type, item) for item_type, item in zip(item_types, value)
]
if issubclass(origin, (Sequence, Set)):
return [_jsonable_as_declared(item_types[0], item) for item in value]
return to_jsonable(value)


@singledispatch
def to_jsonable(value: object) -> JsonValue:
"""Project dataclasses and registered values into JSON-native data."""
Expand Down Expand Up @@ -100,7 +188,7 @@ def _jsonable_registered_type(value: AutoRegisterMeta) -> JsonValue:
)
if declaring_owner is None:
raise TypeError(
f"Registered type {value.__qualname__} has no declared " f"{key_attribute!r} key."
f"Registered type {value.__qualname__} has no declared {key_attribute!r} key."
)
key = vars(declaring_owner)[key_attribute]
if key is None:
Expand Down Expand Up @@ -149,6 +237,146 @@ def _registered_class_from_registry(family: AutoRegisterMeta, key: JsonValue) ->
return matches[0]


class MemberFamily(ABC):
"""The classes a declared field type admits, each named by one tag.

A value of such a field is stored as ``{tag: fields}``. Three declarations
form a family: a union with two or more dataclass members, an abstract
dataclass (its concrete subclasses are the members) and a class registered
by ``AutoRegisterMeta`` (the registry's classes are the members).
"""

@staticmethod
def declared_by(annotation: object) -> MemberFamily | None:
"""The family ``annotation`` declares, or None when it names one class."""

if is_union_type(annotation):
members = tuple(
member
for member in get_args(annotation)
if isinstance(member, type) and is_dataclass(member)
)
return UnionMembers(members) if len(members) > 1 else None
if isinstance(annotation, AutoRegisterMeta):
return RegisteredMembers(annotation)
if (
isinstance(annotation, type)
and is_dataclass(annotation)
and inspect.isabstract(annotation)
):
return ConcreteSubclasses(annotation)
return None

@property
@abstractmethod
def name(self) -> str:
"""How error messages refer to the family."""

@abstractmethod
def members(self) -> tuple[type, ...]:
"""The classes a value of the field may be."""

def member_tagged(self, tag: str) -> type:
"""The member a stored tag names."""

members = self._members_by_tag()
if tag not in members:
raise ValueError(f"{tag!r} is not a member of {self.name}; members: {sorted(members)}.")
return members[tag]

def tag_of(self, member: type) -> str:
"""The tag that names ``member`` in stored data."""

tags = {known: tag for tag, known in self._members_by_tag().items()}
if member not in tags:
raise TypeError(
f"{member.__qualname__} is not a member of {self.name}; "
f"members: {sorted(tags.values())}."
)
return tags[member]

def _members_by_tag(self) -> dict[str, type]:
members: dict[str, type] = {}
for member in self.members():
tag = class_tag(member)
if members.setdefault(tag, member) is not member:
raise TypeError(
f"{self.name} has two members tagged {tag!r}: "
f"{members[tag].__qualname__} and {member.__qualname__}."
)
return members


def class_tag(member: type) -> str:
"""The tag naming ``member``: its registry key if it is registered, else its name."""

tag = to_jsonable(member) if isinstance(member, AutoRegisterMeta) else member.__name__
if not isinstance(tag, str):
raise TypeError(
f"{member.__qualname__} has the registry key {tag!r}; a member tag is a string."
)
return tag


@dataclass(frozen=True)
class UnionMembers(MemberFamily):
"""The dataclasses listed in a union; its other arms are stored untagged.

An arm that is itself a family contributes that family's members.
"""

listed: tuple[type, ...]

@property
def name(self) -> str:
return " | ".join(member.__qualname__ for member in self.listed)

def members(self) -> tuple[type, ...]:
members: list[type] = []
for arm in self.listed:
family = MemberFamily.declared_by(arm)
members.extend((arm,) if family is None else family.members())
return tuple(members)


@dataclass(frozen=True)
class ConcreteSubclasses(MemberFamily):
"""The concrete subclasses of an abstract dataclass, as loaded when asked."""

base: type

@property
def name(self) -> str:
return self.base.__qualname__

def members(self) -> tuple[type, ...]:
found: dict[type, None] = {}
pending = list(self.base.__subclasses__())
while pending:
subclass = pending.pop(0)
pending.extend(subclass.__subclasses__())
if not inspect.isabstract(subclass):
found[subclass] = None
return tuple(found)


@dataclass(frozen=True)
class RegisteredMembers(ConcreteSubclasses):
"""The classes a registered family's registry holds, tagged by registry key.

A stored tag is read by ``registered_class_from_key``, so a family with its
own spelling decodes tags the way it decodes ``type[Family]`` fields.
"""

def members(self) -> tuple[type, ...]:
return tuple(
member for member in self.base.__registry__.values() if issubclass(member, self.base)
)

def member_tagged(self, tag: str) -> type:
return registered_class_from_key(self.base, tag)


@dataclass(frozen=True)
class RegisteredClassChoices(AnnotationChoices):
"""A field holding one class of a registered family.
Expand Down
17 changes: 0 additions & 17 deletions tests/test_dataclass_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,6 @@ class NumericValue:
assert type(dataclass_from_mapping(NumericValue, {"value": 1}).value) is int


def test_dataclass_mapping_rejects_ambiguous_structured_unions() -> None:
@dataclass(frozen=True)
class Left:
value: int

@dataclass(frozen=True)
class Right:
value: int

@dataclass(frozen=True)
class Envelope:
payload: Left | Right

with pytest.raises(TypeError, match="ambiguously matches multiple members"):
dataclass_from_mapping(Envelope, {"payload": {"value": 1}})


@dataclass(frozen=True)
class AnnotatedSequenceEnvelope:
nodes: Annotated[Sequence["RecursiveSequenceNode"], "display text"]
Expand Down
Loading
Loading