From 2e5c18b0cde40888e819d259978ab553e0eabe65 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 16 Jul 2022 21:54:43 -0500 Subject: [PATCH 1/7] Fix Timezone docs syntax --- README.md | 8 ++++---- annotated_types/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 339c3b9..72719df 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ pip install annotated-types ```python from typing import Annotated -from annotated_types import Gt, Len +from annotated_types import Gt, Len, Predicate class MyClass: age: Annotated[int, Gt(18)] # Valid: 19, 20, ... @@ -109,11 +109,11 @@ Implementors: note that Len() should always have an integer value for ### Timezone `Timezone` can be used with a `datetime` or a `time` to express which timezones -are allowed. `Annotated[datetime, Timezone[None]]` must be a naive datetime. +are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime. `Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis)) expresses that any timezone-aware datetime is allowed. You may also pass a specific -timezone string or `timezone` object such as `Timezone[timezone.utc]` or -`Timezone["Africa/Abidjan"]` to express that you only allow a specific timezone, +timezone string or `timezone` object such as `Timezone(timezone.utc)` or +`Timezone("Africa/Abidjan")` to express that you only allow a specific timezone, though we note that this is often a symptom of fragile design. ### Predicate diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index 02ffbc0..f0cc98d 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -194,12 +194,12 @@ class Len(BaseMetadata): class Timezone(BaseMetadata): """Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive). - ``Annotated[datetime, Timezone[None]]`` must be a naive datetime. + ``Annotated[datetime, Timezone(None)]`` must be a naive datetime. ``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be tz-aware bug any timezone is allowed. You may also pass a specific timezone string or timezone object such as - ``Timezone[timezone.utc]`` or ``Timezone["Africa/Abidjan"]`` to express that + ``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that you only allow a specific timezone, though we note that this is often a symptom of poor design. """ From 12855f70f07731af63405a54d76a0ff0822da6e9 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 25 Jul 2022 01:30:10 -0500 Subject: [PATCH 2/7] Remove regex from tests (#13) --- tests/test_main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index dbff810..ffb9267 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,3 @@ -import re import sys from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Type, Union @@ -16,7 +15,7 @@ import annotated_types from annotated_types.test_cases import Case, cases -Constraint = Union[annotated_types.BaseMetadata, slice, "re.Pattern[bytes]", "re.Pattern[str]"] +Constraint = Union[annotated_types.BaseMetadata, slice] def check_gt(constraint: Constraint, val: Any) -> bool: @@ -97,7 +96,7 @@ def get_constraints(tp: type) -> Iterator[Constraint]: args = iter(get_args(tp)) next(args) for arg in args: - if isinstance(arg, (annotated_types.BaseMetadata, re.Pattern, slice)): + if isinstance(arg, (annotated_types.BaseMetadata, slice)): if isinstance(arg, annotated_types.Interval): for case in arg: yield case From f59cf6d1b5255a0fe359b93896759a180bec30ae Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 25 Jul 2022 08:28:46 -0500 Subject: [PATCH 3/7] add GroupedMetadata as a base class for Interval (#12) Co-authored-by: Zac Hatfield-Dodds --- annotated_types/__init__.py | 51 ++++++++++++++++++++++++++++++++++--- tests/test_main.py | 13 +++++----- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index f0cc98d..fb8fb0d 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -1,4 +1,5 @@ import sys +from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import timezone from typing import Any, Callable, Iterator, Optional, TypeVar, Union @@ -82,7 +83,13 @@ def __div__(self: T, __other: T) -> T: class BaseMetadata: - pass + """Base class for all metadata. + + This exists mainly so that implementers + can do `isinstance(..., BaseMetadata)` while traversing field annotations. + """ + + __slots__ = () @dataclass(frozen=True, **SLOTS) @@ -129,8 +136,46 @@ class Le(BaseMetadata): le: SupportsLe +class GroupedMetadata(ABC): + """A grouping of multiple BaseMetadata objects. + + `GroupedMetadata` on its own is not metadata and has no meaning. + All it the the constraint and metadata should be fully expressable + in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`. + + Concrete implementations should override `GroupedMetadata.__iter__()` + to add their own metadata. + For example: + + >>> @dataclass + >>> class Field(GroupedMetadata): + >>> gt: float | None = None + >>> description: str | None = None + ... + >>> def __iter__(self) -> Iterable[BaseMetadata]: + >>> if self.gt is not None: + >>> yield Gt(self.gt) + >>> if self.description is not None: + >>> yield Description(self.gt) + + Also see the implementation of `Interval` below for an example. + + Parsers should recognize this and unpack it so that it can be used + both with and without unpacking: + + - `Annotated[int, Field(...)]` (parser must unpack Field) + - `Annotated[int, *Field(...)]` (PEP-646) + """ # noqa: trailing-whitespace + + __slots__ = () + + @abstractmethod + def __iter__(self) -> Iterator[BaseMetadata]: # pragma: no cover + pass + + @dataclass(frozen=True, **KW_ONLY, **SLOTS) -class Interval(BaseMetadata): +class Interval(GroupedMetadata): """Interval can express inclusive or exclusive bounds with a single object. It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which @@ -143,7 +188,7 @@ class Interval(BaseMetadata): le: Union[SupportsLe, None] = None def __iter__(self) -> Iterator[BaseMetadata]: - """Unpack an Interval into zero or more single-bounds, as per PEP-646.""" + """Unpack an Interval into zero or more single-bounds.""" if self.gt is not None: yield Gt(self.gt) if self.ge is not None: diff --git a/tests/test_main.py b/tests/test_main.py index ffb9267..1ee9b4f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import re import sys from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Type, Union @@ -15,7 +16,7 @@ import annotated_types from annotated_types.test_cases import Case, cases -Constraint = Union[annotated_types.BaseMetadata, slice] +Constraint = Union[annotated_types.BaseMetadata, slice, "re.Pattern[bytes]", "re.Pattern[str]"] def check_gt(constraint: Constraint, val: Any) -> bool: @@ -96,12 +97,10 @@ def get_constraints(tp: type) -> Iterator[Constraint]: args = iter(get_args(tp)) next(args) for arg in args: - if isinstance(arg, (annotated_types.BaseMetadata, slice)): - if isinstance(arg, annotated_types.Interval): - for case in arg: - yield case - else: - yield arg + if isinstance(arg, (annotated_types.BaseMetadata, re.Pattern, slice)): + yield arg + elif isinstance(arg, annotated_types.GroupedMetadata): + yield from arg def is_valid(tp: type, value: Any) -> bool: From 2864944e67bc4e5be3fa69c355116fa806c8eb0e Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 25 Jul 2022 12:13:50 -0500 Subject: [PATCH 4/7] Remove regex from tests (#13) (#14) --- tests/test_main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 1ee9b4f..112c104 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,3 @@ -import re import sys from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Type, Union @@ -16,7 +15,7 @@ import annotated_types from annotated_types.test_cases import Case, cases -Constraint = Union[annotated_types.BaseMetadata, slice, "re.Pattern[bytes]", "re.Pattern[str]"] +Constraint = Union[annotated_types.BaseMetadata, slice] def check_gt(constraint: Constraint, val: Any) -> bool: @@ -97,7 +96,7 @@ def get_constraints(tp: type) -> Iterator[Constraint]: args = iter(get_args(tp)) next(args) for arg in args: - if isinstance(arg, (annotated_types.BaseMetadata, re.Pattern, slice)): + if isinstance(arg, (annotated_types.BaseMetadata, slice)): yield arg elif isinstance(arg, annotated_types.GroupedMetadata): yield from arg From 1089b0e7cd05c8787dc15466f40b6f60441152f3 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 25 Jul 2022 13:06:08 -0500 Subject: [PATCH 5/7] use __init_subclass__ instead of ABC (#16) * use __init_subclass__ instead of ABC * add test * remove pragma --- annotated_types/__init__.py | 13 ++++++++----- tests/test_grouped_metadata.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 tests/test_grouped_metadata.py diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index fb8fb0d..466aab9 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -1,5 +1,4 @@ import sys -from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import timezone from typing import Any, Callable, Iterator, Optional, TypeVar, Union @@ -136,7 +135,7 @@ class Le(BaseMetadata): le: SupportsLe -class GroupedMetadata(ABC): +class GroupedMetadata: """A grouping of multiple BaseMetadata objects. `GroupedMetadata` on its own is not metadata and has no meaning. @@ -169,9 +168,13 @@ class GroupedMetadata(ABC): __slots__ = () - @abstractmethod - def __iter__(self) -> Iterator[BaseMetadata]: # pragma: no cover - pass + def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: + super().__init_subclass__(*args, **kwargs) + if cls.__iter__ is GroupedMetadata.__iter__: + raise TypeError("Can't subclass GroupedMetadata without implementing __iter__") + + def __iter__(self) -> Iterator[BaseMetadata]: + raise NotImplementedError @dataclass(frozen=True, **KW_ONLY, **SLOTS) diff --git a/tests/test_grouped_metadata.py b/tests/test_grouped_metadata.py new file mode 100644 index 0000000..ea25a43 --- /dev/null +++ b/tests/test_grouped_metadata.py @@ -0,0 +1,20 @@ +from typing import Iterator + +import pytest + +from annotated_types import BaseMetadata, GroupedMetadata + + +def test_subclass_without_implementing_iter() -> None: + with pytest.raises(TypeError): + + class Foo1(GroupedMetadata): + pass + + class Foo2(GroupedMetadata): + def __iter__(self) -> Iterator[BaseMetadata]: + return super().__iter__() + + with pytest.raises(NotImplementedError): + for _ in Foo2(): + pass From 7351d1bf7c1ea1a9828f92356aea7b0096c50601 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 28 Jul 2022 23:52:19 -0500 Subject: [PATCH 6/7] add docs for GroupedMetadata and BaseMetadata (#15) Co-authored-by: Samuel Colvin Co-authored-by: Zac Hatfield-Dodds --- README.md | 50 ++++++++++++++++++++++++++++++----- annotated_types/__init__.py | 1 + annotated_types/test_cases.py | 9 ++++++- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 72719df..0c1e565 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependen the `annotated-types` package. To be explicit, these types have the following meanings: + * `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum * `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum * `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum @@ -92,12 +93,12 @@ We encourage libraries to carefully document which interpretation they implement We recommend that libraries interpret `slice` objects identically to `Len()`, making all the following cases equivalent: -- `Annotated[list, :10]` -- `Annotated[list, 0:10]` -- `Annotated[list, None:10]` -- `Annotated[list, slice(0, 10)]` -- `Annotated[list, Len(0, 10)]` -- `Annotated[list, Len(max_exclusive=10)]` +* `Annotated[list, :10]` +* `Annotated[list, 0:10]` +* `Annotated[list, None:10]` +* `Annotated[list, slice(0, 10)]` +* `Annotated[list, Len(0, 10)]` +* `Annotated[list, Len(max_exclusive=10)]` And of course you can describe lists of three or more elements (`Len(min_inclusive=3)`), four, five, or six elements (`Len(4, 7)` - note exclusive-maximum!) or *exactly* @@ -138,6 +139,43 @@ and then propogate or discard the resulting `TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object` exception. We encourage libraries to document the behaviour they choose. +### Integrating downstream types with `GroupedMetadata` + +Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata. +This can help reduce verbosity and cognitive overhead for users. +For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata: + +```python +from dataclasses import dataclass +from typing import Iterator +from annotated_types import GroupedMetadata, Ge + +@dataclass +class Field(GroupedMetadata): + ge: int | None = None + description: str | None = None + + def __iter__(self) -> Iterator[object]: + # Iterating over a GroupedMetadata object should yield annotated-types + # constraint metadata objects which describe it as fully as possible, + # and may include other unknown objects too. + if self.ge is not None: + yield Ge(self.ge) + if self.description is not None: + yield Description(self.description) +``` + +Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently. + +Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. + +### Consuming metadata + +We intend to not be perspcriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103). + +It is up to the implementer to determine how this metadata is used. +You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases. + ## Design & History This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index 466aab9..f371fb0 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -25,6 +25,7 @@ __all__ = ( + 'GroupedMetadata', 'Gt', 'Ge', 'Lt', diff --git a/annotated_types/test_cases.py b/annotated_types/test_cases.py index 1217fd4..9c2d9c3 100644 --- a/annotated_types/test_cases.py +++ b/annotated_types/test_cases.py @@ -1,7 +1,7 @@ import sys from datetime import date, datetime, timedelta, timezone from decimal import Decimal -from typing import Any, Dict, Iterable, List, NamedTuple, Set, Tuple +from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple if sys.version_info < (3, 9): from typing_extensions import Annotated @@ -126,3 +126,10 @@ def cases() -> Iterable[Case]: yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀']) yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5]) + + # custom GroupedMetadata + class MyCustomGroupedMetadata(at.GroupedMetadata): + def __iter__(self) -> Iterator[at.Predicate]: + yield at.Predicate(lambda x: float(x).is_integer()) + + yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5]) From bacee2d374396d7e649d9d6bfbe30785762ec7a4 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Sat, 24 Sep 2022 19:32:39 -0500 Subject: [PATCH 7/7] Bump version to 0.3.0 (#18) --- annotated_types/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index f371fb0..50f21c9 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -41,7 +41,7 @@ '__version__', ) -__version__ = '0.2.0' +__version__ = '0.3.0' T = TypeVar('T')