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

Added:
- `AnnotationChoices`: `Annotated` metadata declaring the finite values a field may hold. Annotation validation checks each value (or each item of a sequence value) against the declared choices, and `declared_annotation_choices` finds the declaration through `Annotated`, `Optional` and homogeneous containers for form builders; `enum_input_values` returns choice labels.
- `type[X]` annotations are validated: the value must be a class and a subclass of `X`.

## 0.2.2

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

from .signature_analyzer import (
SignatureAnalyzer,
Expand Down Expand Up @@ -39,6 +39,7 @@
is_enableable,
mark_enableable,
)
from .choices import AnnotationChoices, declared_annotation_choices
from .validation import (
AnnotatedDataclassValidationMixin,
AnnotationValidationError,
Expand Down Expand Up @@ -119,6 +120,8 @@
"overlay_non_none_dataclass",
"validate_annotated_dataclass",
"validate_annotation_value",
"AnnotationChoices",
"declared_annotation_choices",
# Dataclass projection
"dataclass_from_mapping",
"project_dataclass",
Expand Down
9 changes: 8 additions & 1 deletion src/python_introspect/annotation_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,16 @@ def enum_input_values(annotation: object) -> tuple[str, ...]:

String-valued members expose their value. Members whose values are not
strings expose their name, which keeps JSON and command-line projections
unambiguous, including enums whose concrete value is ``None``.
unambiguous, including enums whose concrete value is ``None``. A field
declaring :class:`~python_introspect.choices.AnnotationChoices` exposes its
choice labels.
"""

from .choices import declared_annotation_choices

choices = declared_annotation_choices(annotation)
if choices is not None:
return tuple(choices.label(choice) for choice in choices.choices())
enum_type = declared_enum_type(annotation)
if enum_type is None:
return ()
Expand Down
65 changes: 65 additions & 0 deletions src/python_introspect/choices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Finite value sets declared as ``Annotated`` metadata."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Annotated, get_args, get_origin

from .annotation_types import is_union_type


class AnnotationChoices(ABC):
"""``Annotated`` metadata declaring the finite values a field may hold.

The declaring package owns the choice set, which may be computed when it is
asked for. Validation checks each value (or each item of a sequence value)
against it, and form builders render the choices with their labels.
``None`` is never a choice: an ``Optional`` annotation admits it separately.
"""

@abstractmethod
def choices(self) -> tuple[object, ...]:
"""Return the admissible values in display order."""

def label(self, choice: object) -> str:
"""Return the display label for one admissible value."""

return str(choice)

def choice_for_label(self, label: str) -> object:
"""Decode one display label back to its admissible value."""

for choice in self.choices():
if self.label(choice) == label:
return choice
raise ValueError(f"{label!r} is not one of {[self.label(c) for c in self.choices()]}.")


def declared_annotation_choices(annotation: object) -> AnnotationChoices | None:
"""Return the choice set declared on an annotation.

The metadata is found through ``Annotated``, ``Optional`` and homogeneous
containers, mirroring how enum declarations are recognised.
"""

if get_origin(annotation) is Annotated:
base, *metadata = get_args(annotation)
for item in metadata:
if isinstance(item, AnnotationChoices):
return item
return declared_annotation_choices(base)
members = get_args(annotation)
if is_union_type(annotation):
found = [
choices
for member in members
if member is not type(None)
if (choices := declared_annotation_choices(member)) is not None
]
return found[0] if len(found) == 1 else None
origin = get_origin(annotation)
if origin in {list, set, frozenset} and len(members) == 1:
return declared_annotation_choices(members[0])
if origin is tuple and len(members) == 2 and members[1] is Ellipsis:
return declared_annotation_choices(members[0])
return None
33 changes: 33 additions & 0 deletions src/python_introspect/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from annotated_types import Ge, Gt, Interval, Le, Len, Lt, MaxLen, MinLen, Predicate

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


class AnnotationValidationError(ValueError):
Expand Down Expand Up @@ -146,6 +147,9 @@ def validate_annotation_value(
if origin in {dict, Mapping}:
_validate_mapping(annotation, value, path)
return
if origin is type:
_validate_class_value(annotation, value, path)
return
if origin is Callable:
if not callable(value):
raise TypeError(f"{path} must be callable; got {type(value).__name__}.")
Expand Down Expand Up @@ -205,6 +209,21 @@ def _validate_sequence(annotation: object, value: object, path: str) -> None:
validate_annotation_value(members[0], item, path=f"{path}[{index}]")


def _validate_class_value(annotation: object, value: object, path: str) -> None:
if not isinstance(value, type):
raise TypeError(f"{path} must be a class; got {type(value).__name__}.")
members = get_args(annotation)
if not members or members[0] is Any:
return
bounds = get_args(members[0]) if is_union_type(members[0]) else (members[0],)
if not any(isinstance(bound, type) and issubclass(value, bound) for bound in bounds):
raise TypeError(
f"{path} must be a subclass of "
f"{' | '.join(_annotation_label(bound) for bound in bounds)}; "
f"got {value.__qualname__}."
)


def _validate_mapping(annotation: object, value: object, path: str) -> None:
if not isinstance(value, Mapping):
raise TypeError(f"{path} must be a mapping; got {type(value).__name__}.")
Expand All @@ -226,6 +245,20 @@ def _validate_constraint(metadata: object, value: object, path: str) -> None:
"""Ignore annotation metadata that does not declare a runtime constraint."""


@_validate_constraint.register
def _(metadata: AnnotationChoices, value: object, path: str) -> None:
if value is None:
return
choices = metadata.choices()
items = value if isinstance(value, (list, tuple, frozenset, set)) else (value,)
for item in items:
if item not in choices:
raise AnnotationValidationError(
f"{path} must hold values from "
f"{[metadata.label(choice) for choice in choices]}; got {item!r}."
)


@_validate_constraint.register
def _(metadata: Ge, value: object, path: str) -> None:
if value < metadata.ge:
Expand Down
61 changes: 61 additions & 0 deletions tests/test_annotation_choices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from dataclasses import dataclass, field
from typing import Annotated, Optional

import pytest

from python_introspect import (
AnnotatedDataclassValidationMixin,
AnnotationChoices,
AnnotationValidationError,
declared_annotation_choices,
enum_input_values,
)


class _Kind:
pass


class _Alpha(_Kind):
pass


class _Beta(_Kind):
pass


class _Other:
pass


class _KindChoices(AnnotationChoices):
def choices(self):
return (_Alpha, _Beta)

def label(self, choice):
return choice.__name__.strip("_").lower()


_CHOICES = _KindChoices()


@dataclass(frozen=True)
class _Declared(AnnotatedDataclassValidationMixin):
kind: Annotated[Optional[type[_Kind]], _CHOICES] = None
kinds: Annotated[list[type[_Kind]], _CHOICES] = field(default_factory=list)


def test_choices_are_found_through_optional_and_containers():
assert declared_annotation_choices(Optional[Annotated[list[type[_Kind]], _CHOICES]]) is _CHOICES
assert declared_annotation_choices(Annotated[Optional[type[_Kind]], _CHOICES]) is _CHOICES
assert declared_annotation_choices(list[int]) is None
assert enum_input_values(Annotated[list[type[_Kind]], _CHOICES]) == ("alpha", "beta")
assert _CHOICES.choice_for_label("beta") is _Beta


def test_validation_admits_only_declared_choices_and_class_bounds():
_Declared(kind=_Alpha, kinds=[_Beta])
with pytest.raises(AnnotationValidationError):
_Declared(kinds=[_Kind])
with pytest.raises(TypeError):
_Declared(kind=_Other)
Loading