diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d6570..908784f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12-dev'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ubuntu-latest diff --git a/README.md b/README.md index f9db11b..5c54d7b 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,56 @@ See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) fo 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, -though we note that this is often a symptom of fragile design. +timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) +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. + +#### Changed in v0.x.x + +* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of + `timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries. + +### Unit + +`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of +a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]` +would be a float representing a velocity in meters per second. + +Please note that `annotated_types` itself makes no attempt to parse or validate +the unit string in any way. That is left entirely to downstream libraries, +such as [`pint`](https://pint.readthedocs.io) or +[`astropy.units`](https://docs.astropy.org/en/stable/units/). + +An example of how a library might use this metadata: + +```python +from annotated_types import Unit +from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args + +# given a type annotated with a unit: +Meters = Annotated[float, Unit("m")] + + +# you can cast the annotation to a specific unit type with any +# callable that accepts a string and returns the desired type +T = TypeVar("T") +def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None: + if get_origin(tp) is Annotated: + for arg in get_args(tp): + if isinstance(arg, Unit): + return unit_cls(arg.unit) + return None + + +# using `pint` +import pint +pint_unit = cast_unit(Meters, pint.Unit) + + +# using `astropy.units` +import astropy.units as u +astropy_unit = cast_unit(Meters, u.Unit) +``` ### Predicate @@ -127,17 +174,20 @@ though we note that this is often a symptom of fragile design. Users should prefer the statically inspectable metadata above, but if you need the full power and flexibility of arbitrary runtime predicates... here it is. -We provide a few predefined predicates for common string constraints: +For some common constraints, we provide generic types: + +* `IsLower = Annotated[T, Predicate(str.islower)]` +* `IsUpper = Annotated[T, Predicate(str.isupper)]` +* `IsDigit = Annotated[T, Predicate(str.isdigit)]` +* `IsFinite = Annotated[T, Predicate(math.isfinite)]` +* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]` +* `IsNan = Annotated[T, Predicate(math.isnan)]` +* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]` +* `IsInfinite = Annotated[T, Predicate(math.isinf)]` +* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]` -* `IsLower = Predicate(str.islower)` -* `IsUpper = Predicate(str.isupper)` -* `IsDigit = Predicate(str.isdigit)` -* `IsFinite = Predicate(math.isfinite)` -* `IsNotFinite = Predicate(Not(math.isfinite))` -* `IsNan = Predicate(math.isnan)` -* `IsNotNan = Predicate(Not(math.isnan))` -* `IsInfinite = Predicate(math.isinf)` -* `IsNotInfinite = Predicate(Not(math.isinf))` +so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer +(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`. Some libraries might have special logic to handle known or understandable predicates, for example by checking for `str.isdigit` and using its presence to both call custom @@ -150,7 +200,7 @@ To enable basic negation of commonly used predicates like `math.isnan` without i We do not specify what behaviour should be expected for predicates that raise an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently skip invalid constraints, or statically raise an error; or it might try calling it -and then propogate or discard the resulting +and then propagate 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. diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py index 2f98950..74e0dee 100644 --- a/annotated_types/__init__.py +++ b/annotated_types/__init__.py @@ -1,7 +1,8 @@ import math import sys +import types from dataclasses import dataclass -from datetime import timezone +from datetime import tzinfo from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union if sys.version_info < (3, 8): @@ -53,7 +54,7 @@ '__version__', ) -__version__ = '0.6.0' +__version__ = '0.7.0' T = TypeVar('T') @@ -150,10 +151,10 @@ class Le(BaseMetadata): @runtime_checkable class GroupedMetadata(Protocol): - """A grouping of multiple BaseMetadata objects. + """A grouping of multiple objects, like typing.Unpack. `GroupedMetadata` on its own is not metadata and has no meaning. - All it the the constraint and metadata should be fully expressable + All of the constraints and metadata should be fully expressable in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`. Concrete implementations should override `GroupedMetadata.__iter__()` @@ -165,7 +166,7 @@ class GroupedMetadata(Protocol): >>> gt: float | None = None >>> description: str | None = None ... - >>> def __iter__(self) -> Iterable[BaseMetadata]: + >>> def __iter__(self) -> Iterable[object]: >>> if self.gt is not None: >>> yield Gt(self.gt) >>> if self.description is not None: @@ -184,7 +185,7 @@ class GroupedMetadata(Protocol): def __is_annotated_types_grouped_metadata__(self) -> Literal[True]: return True - def __iter__(self) -> Iterator[BaseMetadata]: + def __iter__(self) -> Iterator[object]: ... if not TYPE_CHECKING: @@ -196,7 +197,7 @@ def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: if cls.__iter__ is GroupedMetadata.__iter__: raise TypeError("Can't subclass GroupedMetadata without implementing __iter__") - def __iter__(self) -> Iterator[BaseMetadata]: # noqa: F811 + def __iter__(self) -> Iterator[object]: # noqa: F811 raise NotImplementedError # more helpful than "None has no attribute..." type errors @@ -286,13 +287,36 @@ class Timezone(BaseMetadata): ``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be tz-aware but any timezone is allowed. - You may also pass a specific timezone string or timezone object such as + You may also pass a specific timezone string or tzinfo 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 poor design. """ - tz: Union[str, timezone, EllipsisType, None] + tz: Union[str, tzinfo, EllipsisType, None] + + +@dataclass(frozen=True, **SLOTS) +class Unit(BaseMetadata): + """Indicates that the value is a physical quantity with the specified unit. + + It is intended for usage with numeric types, where the value represents the + magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]`` + or ``speed: Annotated[float, Unit('m/s')]``. + + Interpretation of the unit string is left to the discretion of the consumer. + It is suggested to follow conventions established by python libraries that work + with physical quantities, such as + + - ``pint`` : + - ``astropy.units``: + + For indicating a quantity with a certain dimensionality but without a specific unit + it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`. + Note, however, ``annotated_types`` itself makes no use of the unit string. + """ + + unit: str @dataclass(frozen=True, **SLOTS) @@ -304,7 +328,7 @@ class Predicate(BaseMetadata): We provide a few predefined predicates for common string constraints: ``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and - ``IsDigit = Predicate(str.isdigit)``. Users are encouraged to use methods which + ``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which can be given special handling, and avoid indirection like ``lambda s: s.lower()``. Some libraries might have special logic to handle certain predicates, e.g. by @@ -314,11 +338,22 @@ class Predicate(BaseMetadata): We do not specify what behaviour should be expected for predicates that raise an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently skip invalid constraints, or statically raise an error; or it might try calling it - and then propogate or discard the resulting exception. + and then propagate or discard the resulting exception. """ func: Callable[[Any], bool] + def __repr__(self) -> str: + if getattr(self.func, "__name__", "") == "": + return f"{self.__class__.__name__}({self.func!r})" + if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and ( + namespace := getattr(self.func.__self__, "__name__", None) + ): + return f"{self.__class__.__name__}({namespace}.{self.func.__name__})" + if isinstance(self.func, type(str.isascii)): # method descriptor + return f"{self.__class__.__name__}({self.func.__qualname__})" + return f"{self.__class__.__name__}({self.func.__name__})" + @dataclass class Not: @@ -342,7 +377,8 @@ def __call__(self, __v: Any) -> bool: A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string. """ # noqa: E501 -IsDigits = Annotated[_StrType, Predicate(str.isdigit)] +IsDigit = Annotated[_StrType, Predicate(str.isdigit)] +IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63 """ Return True if the string is a digit string, False otherwise. diff --git a/annotated_types/test_cases.py b/annotated_types/test_cases.py index f54df70..d9164d6 100644 --- a/annotated_types/test_cases.py +++ b/annotated_types/test_cases.py @@ -117,11 +117,15 @@ def cases() -> Iterable[Case]: [datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))], ) + # Quantity + + yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m')) + # predicate types yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom']) yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC']) - yield Case(at.IsDigits[str], ['123'], ['', 'ab', 'a1b2']) + yield Case(at.IsDigit[str], ['123'], ['', 'ab', 'a1b2']) 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]) diff --git a/pyproject.toml b/pyproject.toml index ced7e0f..f843008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,8 @@ name = "annotated-types" description = "Reusable constraint types to use with typing.Annotated" authors = [ - {name = "Samuel Colvin", email = "s@muelcolvin.com"}, {name = "Adrian Garcia Badaracco", email = "1755071+adriangb@users.noreply.github.com"}, + {name = "Samuel Colvin", email = "s@muelcolvin.com"}, {name = "Zac Hatfield-Dodds", email = "zac@zhd.dev"}, ] readme = "README.md" @@ -32,6 +32,11 @@ requires-python = ">=3.8" dependencies = ["typing-extensions>=4.0.0; python_version<'3.9'"] dynamic = ["version"] +[project.urls] +Homepage = "https://github.com/annotated-types/annotated-types" +Source = "https://github.com/annotated-types/annotated-types" +Changelog = "https://github.com/annotated-types/annotated-types/releases" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/test_main.py b/tests/test_main.py index 3cfe616..6219475 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import math import sys from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Type, Union @@ -76,6 +77,11 @@ def check_timezone(constraint: Constraint, val: Any) -> bool: return val.tzinfo is not None +def check_quantity(constraint: Constraint, val: Any) -> bool: + assert isinstance(constraint, annotated_types.Unit) + return isinstance(val, (float, int)) + + Validator = Callable[[Constraint, Any], bool] @@ -89,6 +95,7 @@ def check_timezone(constraint: Constraint, val: Any) -> bool: annotated_types.MinLen: check_min_len, annotated_types.MaxLen: check_max_len, annotated_types.Timezone: check_timezone, + annotated_types.Unit: check_quantity, } @@ -101,7 +108,7 @@ def get_constraints(tp: type) -> Iterator[Constraint]: if isinstance(arg, annotated_types.BaseMetadata): yield arg elif isinstance(arg, annotated_types.GroupedMetadata): - yield from arg + yield from arg # type: ignore elif isinstance(arg, slice): yield from annotated_types.Len(arg.start or 0, arg.stop) @@ -135,3 +142,21 @@ def test_valid_cases(annotation: type, example: Any) -> None: ) def test_invalid_cases(annotation: type, example: Any) -> None: assert is_valid(annotation, example) is False + + +def a_predicate_fn(x: object) -> bool: + return not x + + +@pytest.mark.parametrize( + "pred, repr_", + [ + (annotated_types.Predicate(func=a_predicate_fn), "Predicate(a_predicate_fn)"), + (annotated_types.Predicate(func=str.isascii), "Predicate(str.isascii)"), + (annotated_types.Predicate(func=math.isfinite), "Predicate(math.isfinite)"), + (annotated_types.Predicate(func=bool), "Predicate(bool)"), + (annotated_types.Predicate(func := lambda _: True), f"Predicate({func!r})"), + ], +) +def test_predicate_repr(pred: annotated_types.Predicate, repr_: str) -> None: + assert repr(pred) == repr_