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_