Skip to content
Snippets Groups Projects
Commit fdce8275 authored by Edward Betts's avatar Edward Betts
Browse files

Update upstream source from tag 'upstream/0.7.0'

Update to upstream version '0.7.0'
with Debian dir aa93815768fa68c4aa68a4b536375837a75f664f
parents 30c4c3ed 468adca4
Branches
Tags
No related merge requests found
......@@ -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
......
......@@ -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.
......
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 @@ __all__ = (
'__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 @@ class GroupedMetadata(Protocol):
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`` : <https://pint.readthedocs.io/en/stable/>
- ``astropy.units``: <https://docs.astropy.org/en/stable/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__", "<lambda>") == "<lambda>":
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 @@ Return True if the string is an uppercase string, False otherwise.
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.
......
......@@ -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])
......
......@@ -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"
......
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 @@ VALIDATORS: Dict[Type[Constraint], Validator] = {
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_
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment