diff --git a/CHANGELOG.md b/CHANGELOG.md index c50dd64..19d88eb 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.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: diff --git a/pyproject.toml b/pyproject.toml index 6e98454..518697d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index 432de68..adc25a8 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.2" +__version__ = "0.2.3" from .signature_analyzer import ( SignatureAnalyzer, @@ -39,6 +39,7 @@ is_enableable, mark_enableable, ) +from .choices import AnnotationChoices, declared_annotation_choices from .validation import ( AnnotatedDataclassValidationMixin, AnnotationValidationError, @@ -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", diff --git a/src/python_introspect/annotation_types.py b/src/python_introspect/annotation_types.py index a70a034..ff1a09a 100644 --- a/src/python_introspect/annotation_types.py +++ b/src/python_introspect/annotation_types.py @@ -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 () diff --git a/src/python_introspect/choices.py b/src/python_introspect/choices.py new file mode 100644 index 0000000..e9d976a --- /dev/null +++ b/src/python_introspect/choices.py @@ -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 diff --git a/src/python_introspect/validation.py b/src/python_introspect/validation.py index 257b74d..bb3e816 100644 --- a/src/python_introspect/validation.py +++ b/src/python_introspect/validation.py @@ -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): @@ -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__}.") @@ -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__}.") @@ -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: diff --git a/tests/test_annotation_choices.py b/tests/test_annotation_choices.py new file mode 100644 index 0000000..9f32687 --- /dev/null +++ b/tests/test_annotation_choices.py @@ -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)