1import math
2import sys
3import types
4from dataclasses import dataclass
5from datetime import tzinfo
6from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union
7
8if sys.version_info < (3, 8):
9 from typing_extensions import Protocol, runtime_checkable
10else:
11 from typing import Protocol, runtime_checkable
12
13if sys.version_info < (3, 9):
14 from typing_extensions import Annotated, Literal
15else:
16 from typing import Annotated, Literal
17
18if sys.version_info < (3, 10):
19 EllipsisType = type(Ellipsis)
20 KW_ONLY = {}
21 SLOTS = {}
22else:
23 from types import EllipsisType
24
25 KW_ONLY = {"kw_only": True}
26 SLOTS = {"slots": True}
27
28
29__all__ = (
30 'BaseMetadata',
31 'GroupedMetadata',
32 'Gt',
33 'Ge',
34 'Lt',
35 'Le',
36 'Interval',
37 'MultipleOf',
38 'MinLen',
39 'MaxLen',
40 'Len',
41 'Timezone',
42 'Predicate',
43 'LowerCase',
44 'UpperCase',
45 'IsDigits',
46 'IsFinite',
47 'IsNotFinite',
48 'IsNan',
49 'IsNotNan',
50 'IsInfinite',
51 'IsNotInfinite',
52 'doc',
53 'DocInfo',
54 '__version__',
55)
56
57__version__ = '0.7.0'
58
59
60T = TypeVar('T')
61
62
63# arguments that start with __ are considered
64# positional only
65# see https://peps.python.org/pep-0484/#positional-only-arguments
66
67
68class SupportsGt(Protocol):
69 def __gt__(self: T, __other: T) -> bool:
70 ...
71
72
73class SupportsGe(Protocol):
74 def __ge__(self: T, __other: T) -> bool:
75 ...
76
77
78class SupportsLt(Protocol):
79 def __lt__(self: T, __other: T) -> bool:
80 ...
81
82
83class SupportsLe(Protocol):
84 def __le__(self: T, __other: T) -> bool:
85 ...
86
87
88class SupportsMod(Protocol):
89 def __mod__(self: T, __other: T) -> T:
90 ...
91
92
93class SupportsDiv(Protocol):
94 def __div__(self: T, __other: T) -> T:
95 ...
96
97
98class BaseMetadata:
99 """Base class for all metadata.
100
101 This exists mainly so that implementers
102 can do `isinstance(..., BaseMetadata)` while traversing field annotations.
103 """
104
105 __slots__ = ()
106
107
108@dataclass(frozen=True, **SLOTS)
109class Gt(BaseMetadata):
110 """Gt(gt=x) implies that the value must be greater than x.
111
112 It can be used with any type that supports the ``>`` operator,
113 including numbers, dates and times, strings, sets, and so on.
114 """
115
116 gt: SupportsGt
117
118
119@dataclass(frozen=True, **SLOTS)
120class Ge(BaseMetadata):
121 """Ge(ge=x) implies that the value must be greater than or equal to x.
122
123 It can be used with any type that supports the ``>=`` operator,
124 including numbers, dates and times, strings, sets, and so on.
125 """
126
127 ge: SupportsGe
128
129
130@dataclass(frozen=True, **SLOTS)
131class Lt(BaseMetadata):
132 """Lt(lt=x) implies that the value must be less than x.
133
134 It can be used with any type that supports the ``<`` operator,
135 including numbers, dates and times, strings, sets, and so on.
136 """
137
138 lt: SupportsLt
139
140
141@dataclass(frozen=True, **SLOTS)
142class Le(BaseMetadata):
143 """Le(le=x) implies that the value must be less than or equal to x.
144
145 It can be used with any type that supports the ``<=`` operator,
146 including numbers, dates and times, strings, sets, and so on.
147 """
148
149 le: SupportsLe
150
151
152@runtime_checkable
153class GroupedMetadata(Protocol):
154 """A grouping of multiple objects, like typing.Unpack.
155
156 `GroupedMetadata` on its own is not metadata and has no meaning.
157 All of the constraints and metadata should be fully expressable
158 in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
159
160 Concrete implementations should override `GroupedMetadata.__iter__()`
161 to add their own metadata.
162 For example:
163
164 >>> @dataclass
165 >>> class Field(GroupedMetadata):
166 >>> gt: float | None = None
167 >>> description: str | None = None
168 ...
169 >>> def __iter__(self) -> Iterable[object]:
170 >>> if self.gt is not None:
171 >>> yield Gt(self.gt)
172 >>> if self.description is not None:
173 >>> yield Description(self.gt)
174
175 Also see the implementation of `Interval` below for an example.
176
177 Parsers should recognize this and unpack it so that it can be used
178 both with and without unpacking:
179
180 - `Annotated[int, Field(...)]` (parser must unpack Field)
181 - `Annotated[int, *Field(...)]` (PEP-646)
182 """ # noqa: trailing-whitespace
183
184 @property
185 def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
186 return True
187
188 def __iter__(self) -> Iterator[object]:
189 ...
190
191 if not TYPE_CHECKING:
192 __slots__ = () # allow subclasses to use slots
193
194 def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
195 # Basic ABC like functionality without the complexity of an ABC
196 super().__init_subclass__(*args, **kwargs)
197 if cls.__iter__ is GroupedMetadata.__iter__:
198 raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
199
200 def __iter__(self) -> Iterator[object]: # noqa: F811
201 raise NotImplementedError # more helpful than "None has no attribute..." type errors
202
203
204@dataclass(frozen=True, **KW_ONLY, **SLOTS)
205class Interval(GroupedMetadata):
206 """Interval can express inclusive or exclusive bounds with a single object.
207
208 It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
209 are interpreted the same way as the single-bound constraints.
210 """
211
212 gt: Union[SupportsGt, None] = None
213 ge: Union[SupportsGe, None] = None
214 lt: Union[SupportsLt, None] = None
215 le: Union[SupportsLe, None] = None
216
217 def __iter__(self) -> Iterator[BaseMetadata]:
218 """Unpack an Interval into zero or more single-bounds."""
219 if self.gt is not None:
220 yield Gt(self.gt)
221 if self.ge is not None:
222 yield Ge(self.ge)
223 if self.lt is not None:
224 yield Lt(self.lt)
225 if self.le is not None:
226 yield Le(self.le)
227
228
229@dataclass(frozen=True, **SLOTS)
230class MultipleOf(BaseMetadata):
231 """MultipleOf(multiple_of=x) might be interpreted in two ways:
232
233 1. Python semantics, implying ``value % multiple_of == 0``, or
234 2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
235
236 We encourage users to be aware of these two common interpretations,
237 and libraries to carefully document which they implement.
238 """
239
240 multiple_of: Union[SupportsDiv, SupportsMod]
241
242
243@dataclass(frozen=True, **SLOTS)
244class MinLen(BaseMetadata):
245 """
246 MinLen() implies minimum inclusive length,
247 e.g. ``len(value) >= min_length``.
248 """
249
250 min_length: Annotated[int, Ge(0)]
251
252
253@dataclass(frozen=True, **SLOTS)
254class MaxLen(BaseMetadata):
255 """
256 MaxLen() implies maximum inclusive length,
257 e.g. ``len(value) <= max_length``.
258 """
259
260 max_length: Annotated[int, Ge(0)]
261
262
263@dataclass(frozen=True, **SLOTS)
264class Len(GroupedMetadata):
265 """
266 Len() implies that ``min_length <= len(value) <= max_length``.
267
268 Upper bound may be omitted or ``None`` to indicate no upper length bound.
269 """
270
271 min_length: Annotated[int, Ge(0)] = 0
272 max_length: Optional[Annotated[int, Ge(0)]] = None
273
274 def __iter__(self) -> Iterator[BaseMetadata]:
275 """Unpack a Len into zone or more single-bounds."""
276 if self.min_length > 0:
277 yield MinLen(self.min_length)
278 if self.max_length is not None:
279 yield MaxLen(self.max_length)
280
281
282@dataclass(frozen=True, **SLOTS)
283class Timezone(BaseMetadata):
284 """Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
285
286 ``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
287 ``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be
288 tz-aware but any timezone is allowed.
289
290 You may also pass a specific timezone string or tzinfo object such as
291 ``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
292 you only allow a specific timezone, though we note that this is often
293 a symptom of poor design.
294 """
295
296 tz: Union[str, tzinfo, EllipsisType, None]
297
298
299@dataclass(frozen=True, **SLOTS)
300class Unit(BaseMetadata):
301 """Indicates that the value is a physical quantity with the specified unit.
302
303 It is intended for usage with numeric types, where the value represents the
304 magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
305 or ``speed: Annotated[float, Unit('m/s')]``.
306
307 Interpretation of the unit string is left to the discretion of the consumer.
308 It is suggested to follow conventions established by python libraries that work
309 with physical quantities, such as
310
311 - ``pint`` : <https://pint.readthedocs.io/en/stable/>
312 - ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
313
314 For indicating a quantity with a certain dimensionality but without a specific unit
315 it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
316 Note, however, ``annotated_types`` itself makes no use of the unit string.
317 """
318
319 unit: str
320
321
322@dataclass(frozen=True, **SLOTS)
323class Predicate(BaseMetadata):
324 """``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
325
326 Users should prefer statically inspectable metadata, but if you need the full
327 power and flexibility of arbitrary runtime predicates... here it is.
328
329 We provide a few predefined predicates for common string constraints:
330 ``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and
331 ``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which
332 can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
333
334 Some libraries might have special logic to handle certain predicates, e.g. by
335 checking for `str.isdigit` and using its presence to both call custom logic to
336 enforce digit-only strings, and customise some generated external schema.
337
338 We do not specify what behaviour should be expected for predicates that raise
339 an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
340 skip invalid constraints, or statically raise an error; or it might try calling it
341 and then propagate or discard the resulting exception.
342 """
343
344 func: Callable[[Any], bool]
345
346 def __repr__(self) -> str:
347 if getattr(self.func, "__name__", "<lambda>") == "<lambda>":
348 return f"{self.__class__.__name__}({self.func!r})"
349 if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and (
350 namespace := getattr(self.func.__self__, "__name__", None)
351 ):
352 return f"{self.__class__.__name__}({namespace}.{self.func.__name__})"
353 if isinstance(self.func, type(str.isascii)): # method descriptor
354 return f"{self.__class__.__name__}({self.func.__qualname__})"
355 return f"{self.__class__.__name__}({self.func.__name__})"
356
357
358@dataclass
359class Not:
360 func: Callable[[Any], bool]
361
362 def __call__(self, __v: Any) -> bool:
363 return not self.func(__v)
364
365
366_StrType = TypeVar("_StrType", bound=str)
367
368LowerCase = Annotated[_StrType, Predicate(str.islower)]
369"""
370Return True if the string is a lowercase string, False otherwise.
371
372A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
373""" # noqa: E501
374UpperCase = Annotated[_StrType, Predicate(str.isupper)]
375"""
376Return True if the string is an uppercase string, False otherwise.
377
378A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
379""" # noqa: E501
380IsDigit = Annotated[_StrType, Predicate(str.isdigit)]
381IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63
382"""
383Return True if the string is a digit string, False otherwise.
384
385A string is a digit string if all characters in the string are digits and there is at least one character in the string.
386""" # noqa: E501
387IsAscii = Annotated[_StrType, Predicate(str.isascii)]
388"""
389Return True if all characters in the string are ASCII, False otherwise.
390
391ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
392"""
393
394_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
395IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
396"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
397IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
398"""Return True if x is one of infinity or NaN, and False otherwise"""
399IsNan = Annotated[_NumericType, Predicate(math.isnan)]
400"""Return True if x is a NaN (not a number), and False otherwise."""
401IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
402"""Return True if x is anything but NaN (not a number), and False otherwise."""
403IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
404"""Return True if x is a positive or negative infinity, and False otherwise."""
405IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
406"""Return True if x is neither a positive or negative infinity, and False otherwise."""
407
408try:
409 from typing_extensions import DocInfo, doc # type: ignore [attr-defined]
410except ImportError:
411
412 @dataclass(frozen=True, **SLOTS)
413 class DocInfo: # type: ignore [no-redef]
414 """ "
415 The return value of doc(), mainly to be used by tools that want to extract the
416 Annotated documentation at runtime.
417 """
418
419 documentation: str
420 """The documentation string passed to doc()."""
421
422 def doc(
423 documentation: str,
424 ) -> DocInfo:
425 """
426 Add documentation to a type annotation inside of Annotated.
427
428 For example:
429
430 >>> def hi(name: Annotated[int, doc("The name of the user")]) -> None: ...
431 """
432 return DocInfo(documentation)