python-wrap 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Argument validation and runtime type checking.
|
|
2
|
+
|
|
3
|
+
* `validate_args` - run a predicate per argument (``x=lambda x: x > 0``).
|
|
4
|
+
* `check_type` - ``isinstance`` check per argument (``x=int``).
|
|
5
|
+
* `enforce_types` - check arguments and the return value against the function's own
|
|
6
|
+
annotations.
|
|
7
|
+
|
|
8
|
+
All three work on plain functions, ``async def`` functions and methods, can be stacked, and
|
|
9
|
+
keep the wrapped function's signature.
|
|
10
|
+
|
|
11
|
+
**Annotations supported by `enforce_types`.**
|
|
12
|
+
|
|
13
|
+
Checked: plain classes (``int`` also accepts ``bool``; ``float`` accepts ``int`` and
|
|
14
|
+
``complex`` accepts ``int``/``float``, following PEP 484), ``Any``, ``None``,
|
|
15
|
+
``Optional`` / ``Union`` / ``X | Y``, ``Literal``, ``list[T]``, ``set[T]``, ``frozenset[T]``,
|
|
16
|
+
``tuple[T, ...]``, ``tuple[A, B]``, ``dict[K, V]`` (elements are checked),
|
|
17
|
+
``collections.abc`` ABCs such as ``Sequence[T]``, ``MutableSequence[T]``, ``Set[T]``,
|
|
18
|
+
``Mapping[K, V]`` (elements checked) and ``Iterable[T]`` / ``Iterator[T]`` / ``Generator``
|
|
19
|
+
(``isinstance`` only, iterators are never consumed), ``Callable`` (``callable()`` only),
|
|
20
|
+
``type[T]``, ``TypeVar`` (bound or constraints, otherwise anything), ``Annotated[T, ...]``
|
|
21
|
+
(checks ``T``), ``NewType`` (checks the supertype) and ``runtime_checkable`` protocols.
|
|
22
|
+
|
|
23
|
+
Accepted without checking: ``TypedDict``, protocols that are not ``runtime_checkable``,
|
|
24
|
+
forward references that cannot be resolved and any other construct this module does not
|
|
25
|
+
understand. A check that cannot be performed never raises.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import abc
|
|
31
|
+
import collections.abc as cabc
|
|
32
|
+
import functools
|
|
33
|
+
import inspect
|
|
34
|
+
import reprlib
|
|
35
|
+
import types
|
|
36
|
+
import typing
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from typing import Any, TypeVar, Union, cast, overload
|
|
39
|
+
|
|
40
|
+
from python_wrap import _core, errors
|
|
41
|
+
from python_wrap._core import F, P, R
|
|
42
|
+
|
|
43
|
+
__all__ = ["check_type", "enforce_types", "validate_args"]
|
|
44
|
+
|
|
45
|
+
Validator = Callable[[Any], object] | tuple[Callable[[Any], object], str]
|
|
46
|
+
|
|
47
|
+
_NoneType = type(None)
|
|
48
|
+
_UnionType = getattr(types, "UnionType", None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _repr(value: object) -> str:
|
|
52
|
+
return reprlib.repr(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _wrap(
|
|
56
|
+
func: Callable[P, R],
|
|
57
|
+
before: Callable[[tuple[Any, ...], dict[str, Any]], None],
|
|
58
|
+
after: Callable[[Any], None] | None = None,
|
|
59
|
+
) -> Callable[P, R]:
|
|
60
|
+
"""Build a sync or async wrapper that runs ``before`` (and optionally ``after``)."""
|
|
61
|
+
if _core.is_async(func):
|
|
62
|
+
afunc: Any = func
|
|
63
|
+
|
|
64
|
+
@functools.wraps(func)
|
|
65
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
before(args, kwargs)
|
|
67
|
+
result = await afunc(*args, **kwargs)
|
|
68
|
+
if after is not None:
|
|
69
|
+
after(result)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
return async_wrapper # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
@functools.wraps(func)
|
|
75
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
76
|
+
before(args, kwargs)
|
|
77
|
+
result = func(*args, **kwargs)
|
|
78
|
+
if after is not None:
|
|
79
|
+
after(result)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
return wrapper
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_MISSING: Any = object()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plain_layout(sig: inspect.Signature) -> tuple[tuple[str, ...], int, tuple[Any, ...]] | None:
|
|
89
|
+
"""``(names, required count, defaults)`` if every parameter is a plain positional one.
|
|
90
|
+
|
|
91
|
+
Calls that pass only positional arguments to such a function (``min <= len(args) <=
|
|
92
|
+
len(names)``) can be checked without ``Signature.bind``; ``defaults[i]`` is the default
|
|
93
|
+
of parameter ``i`` (``_MISSING`` when it has none). Returns ``None`` when the
|
|
94
|
+
signature has keyword-only or variadic parameters.
|
|
95
|
+
"""
|
|
96
|
+
params = list(sig.parameters.values())
|
|
97
|
+
plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
98
|
+
if any(p.kind not in plain for p in params):
|
|
99
|
+
return None
|
|
100
|
+
defaults = tuple(
|
|
101
|
+
_MISSING if p.default is inspect.Parameter.empty else p.default for p in params
|
|
102
|
+
)
|
|
103
|
+
required = sum(1 for d in defaults if d is _MISSING)
|
|
104
|
+
return tuple(p.name for p in params), required, defaults
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_names(
|
|
108
|
+
func: Callable[..., Any], sig: inspect.Signature, names: typing.Iterable[str], who: str
|
|
109
|
+
) -> None:
|
|
110
|
+
unknown = [n for n in names if n not in sig.parameters]
|
|
111
|
+
if unknown:
|
|
112
|
+
raise TypeError(
|
|
113
|
+
f"{who}() got unknown parameter name(s) {', '.join(map(repr, unknown))} "
|
|
114
|
+
f"for {_core.qualname(func)}{sig}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------------------
|
|
119
|
+
# validate_args
|
|
120
|
+
# --------------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_args(**validators: Validator) -> Callable[[F], F]:
|
|
124
|
+
"""Validate arguments with predicates.
|
|
125
|
+
|
|
126
|
+
Each keyword names a parameter and gives a callable that receives the argument value
|
|
127
|
+
(defaults are applied first, so default values are validated too). A falsy result raises
|
|
128
|
+
`ValidationError`, which is a ``ValueError``. A validator may
|
|
129
|
+
instead raise its own exception, which propagates unchanged. Pass a
|
|
130
|
+
``(callable, "message")`` tuple for a custom error message. Validators for ``*args`` /
|
|
131
|
+
``**kwargs`` parameters receive the whole tuple / dict.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
**validators: ``parameter_name=predicate`` or ``parameter_name=(predicate, message)``.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A decorator that preserves the function's signature.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
TypeError: At decoration time, if a name is not a parameter of the function or a
|
|
141
|
+
validator is not callable.
|
|
142
|
+
python_wrap.errors.ValidationError: At call time, when a validator returns falsy.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
>>> @validate_args(x=lambda x: x > 0, name=(lambda s: s != "", "name must not be empty"))
|
|
146
|
+
... def greet(x, name):
|
|
147
|
+
... return name * x
|
|
148
|
+
>>> greet(2, "ab")
|
|
149
|
+
'abab'
|
|
150
|
+
"""
|
|
151
|
+
normalised: dict[str, tuple[Callable[[Any], object], str | None]] = {}
|
|
152
|
+
for name, spec in validators.items():
|
|
153
|
+
if isinstance(spec, tuple):
|
|
154
|
+
if len(spec) != 2 or not callable(spec[0]) or not isinstance(spec[1], str):
|
|
155
|
+
raise TypeError(f"validator for {name!r} must be a callable or (callable, str)")
|
|
156
|
+
normalised[name] = (spec[0], spec[1])
|
|
157
|
+
elif callable(spec):
|
|
158
|
+
normalised[name] = (spec, None)
|
|
159
|
+
else:
|
|
160
|
+
raise TypeError(f"validator for {name!r} must be callable, got {type(spec).__name__}")
|
|
161
|
+
|
|
162
|
+
def decorate(func: Callable[P, R]) -> Callable[P, R]:
|
|
163
|
+
sig = inspect.signature(func)
|
|
164
|
+
_check_names(func, sig, normalised, "validate_args")
|
|
165
|
+
|
|
166
|
+
layout = _plain_layout(sig)
|
|
167
|
+
n_params = -1
|
|
168
|
+
min_args = 0
|
|
169
|
+
defaults: tuple[Any, ...] = ()
|
|
170
|
+
plan: list[tuple[int, str, Callable[[Any], object], str | None]] = []
|
|
171
|
+
if layout is not None:
|
|
172
|
+
names, min_args, defaults = layout
|
|
173
|
+
n_params = len(names)
|
|
174
|
+
plan = [(names.index(k), k, pred, msg) for k, (pred, msg) in normalised.items()]
|
|
175
|
+
|
|
176
|
+
def fail(name: str, message: str | None, value: Any) -> typing.NoReturn:
|
|
177
|
+
raise errors.ValidationError(
|
|
178
|
+
message or f"parameter {name!r} failed validation (got {_repr(value)})",
|
|
179
|
+
parameter=name,
|
|
180
|
+
value=value,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def before(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
184
|
+
nargs = len(args)
|
|
185
|
+
if not kwargs and min_args <= nargs <= n_params:
|
|
186
|
+
for idx, name, predicate, message in plan:
|
|
187
|
+
value = args[idx] if idx < nargs else defaults[idx]
|
|
188
|
+
if not predicate(value):
|
|
189
|
+
fail(name, message, value)
|
|
190
|
+
return
|
|
191
|
+
bound = sig.bind(*args, **kwargs)
|
|
192
|
+
bound.apply_defaults()
|
|
193
|
+
for name, (predicate, message) in normalised.items():
|
|
194
|
+
value = bound.arguments[name]
|
|
195
|
+
if not predicate(value):
|
|
196
|
+
fail(name, message, value)
|
|
197
|
+
|
|
198
|
+
return _wrap(func, before)
|
|
199
|
+
|
|
200
|
+
return cast("Callable[[F], F]", decorate)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------------------
|
|
204
|
+
# check_type
|
|
205
|
+
# --------------------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _type_name(tp: object) -> str:
|
|
209
|
+
if isinstance(tp, tuple):
|
|
210
|
+
return " | ".join(_type_name(t) for t in tp)
|
|
211
|
+
if isinstance(tp, type):
|
|
212
|
+
return tp.__name__
|
|
213
|
+
return str(tp).replace("typing.", "")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def check_type(**type_hints: type | tuple[type, ...]) -> Callable[[F], F]:
|
|
217
|
+
"""Check argument types with ``isinstance``.
|
|
218
|
+
|
|
219
|
+
Each keyword names a parameter and gives a class (or a tuple of classes). Defaults are
|
|
220
|
+
not checked, only the arguments that are actually passed. For ``*args`` / ``**kwargs``
|
|
221
|
+
parameters the type applies to every element / value.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
**type_hints: ``parameter_name=type`` (anything ``isinstance`` accepts).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A decorator that preserves the function's signature.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
TypeError: At decoration time, if a name is not a parameter of the function.
|
|
231
|
+
python_wrap.errors.ArgumentTypeError: At call time, on a mismatch (a ``TypeError``).
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
>>> @check_type(x=int, y=str)
|
|
235
|
+
... def f(x, y):
|
|
236
|
+
... return y * x
|
|
237
|
+
>>> f(2, "a")
|
|
238
|
+
'aa'
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def decorate(func: Callable[P, R]) -> Callable[P, R]:
|
|
242
|
+
sig = inspect.signature(func)
|
|
243
|
+
_check_names(func, sig, type_hints, "check_type")
|
|
244
|
+
|
|
245
|
+
def check(label: str, value: Any, expected: Any) -> None:
|
|
246
|
+
if not isinstance(value, expected):
|
|
247
|
+
raise errors.ArgumentTypeError(
|
|
248
|
+
f"parameter {label!r} expected {_type_name(expected)}, "
|
|
249
|
+
f"got {type(value).__name__}",
|
|
250
|
+
parameter=label,
|
|
251
|
+
expected=expected,
|
|
252
|
+
value=value,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
layout = _plain_layout(sig)
|
|
256
|
+
n_params = -1
|
|
257
|
+
min_args = 0
|
|
258
|
+
plan: list[tuple[int, str, Any]] = []
|
|
259
|
+
if layout is not None:
|
|
260
|
+
names, min_args, _ = layout
|
|
261
|
+
n_params = len(names)
|
|
262
|
+
plan = [(names.index(k), k, expected) for k, expected in type_hints.items()]
|
|
263
|
+
|
|
264
|
+
def before(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
265
|
+
nargs = len(args)
|
|
266
|
+
if not kwargs and min_args <= nargs <= n_params:
|
|
267
|
+
for idx, name, expected in plan:
|
|
268
|
+
if idx < nargs:
|
|
269
|
+
value = args[idx]
|
|
270
|
+
if not isinstance(value, expected):
|
|
271
|
+
check(name, value, expected)
|
|
272
|
+
return
|
|
273
|
+
bound = sig.bind(*args, **kwargs)
|
|
274
|
+
for name, expected in type_hints.items():
|
|
275
|
+
if name not in bound.arguments:
|
|
276
|
+
continue
|
|
277
|
+
value = bound.arguments[name]
|
|
278
|
+
kind = sig.parameters[name].kind
|
|
279
|
+
if kind is inspect.Parameter.VAR_POSITIONAL:
|
|
280
|
+
for i, item in enumerate(value):
|
|
281
|
+
check(f"{name}[{i}]", item, expected)
|
|
282
|
+
elif kind is inspect.Parameter.VAR_KEYWORD:
|
|
283
|
+
for key, item in value.items():
|
|
284
|
+
check(f"{name}[{key!r}]", item, expected)
|
|
285
|
+
else:
|
|
286
|
+
check(name, value, expected)
|
|
287
|
+
|
|
288
|
+
return _wrap(func, before)
|
|
289
|
+
|
|
290
|
+
return cast("Callable[[F], F]", decorate)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --------------------------------------------------------------------------------------
|
|
294
|
+
# enforce_types
|
|
295
|
+
# --------------------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
_ELEMENT_CHECKED_ABCS: tuple[type, ...] = (
|
|
298
|
+
cabc.Sequence,
|
|
299
|
+
cabc.MutableSequence,
|
|
300
|
+
cabc.Set,
|
|
301
|
+
cabc.MutableSet,
|
|
302
|
+
)
|
|
303
|
+
_MAPPING_ABCS: tuple[type, ...] = (cabc.Mapping, cabc.MutableMapping)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fmt(hint: object) -> str:
|
|
307
|
+
"""Readable name for an annotation, e.g. ``list[int]`` or ``int | None``."""
|
|
308
|
+
if hint is None or hint is _NoneType:
|
|
309
|
+
return "None"
|
|
310
|
+
if isinstance(hint, type) and typing.get_origin(hint) is None:
|
|
311
|
+
return hint.__name__
|
|
312
|
+
if isinstance(hint, typing.ForwardRef):
|
|
313
|
+
return hint.__forward_arg__
|
|
314
|
+
text = repr(hint)
|
|
315
|
+
for prefix in ("typing.", "collections.abc."):
|
|
316
|
+
text = text.replace(prefix, "")
|
|
317
|
+
return text
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _got(value: object) -> str:
|
|
321
|
+
return type(value).__name__
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _nested(outer: str, inner: str, where: str, element: object) -> str:
|
|
325
|
+
if " " not in inner:
|
|
326
|
+
return f"{outer} containing {inner} {where}"
|
|
327
|
+
return f"{outer} containing {_got(element)} {where} ({inner})"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _isinstance(value: object, cls: Any) -> bool:
|
|
331
|
+
try:
|
|
332
|
+
return isinstance(value, cls)
|
|
333
|
+
except TypeError:
|
|
334
|
+
return True # cannot be checked at runtime, so accept
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _check_items(value: Any, hint_args: tuple[Any, ...]) -> str | None:
|
|
338
|
+
"""Element checks for list/set/frozenset/Sequence/Set (``origin`` already matched)."""
|
|
339
|
+
if len(hint_args) != 1:
|
|
340
|
+
return None
|
|
341
|
+
(item_hint,) = hint_args
|
|
342
|
+
if item_hint is Any:
|
|
343
|
+
return None
|
|
344
|
+
is_seq = isinstance(value, cabc.Sequence)
|
|
345
|
+
for i, item in enumerate(value):
|
|
346
|
+
problem = _mismatch(item, item_hint)
|
|
347
|
+
if problem is not None:
|
|
348
|
+
where = f"at index {i}" if is_seq else ""
|
|
349
|
+
return _nested(_got(value), problem, where, item).rstrip()
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _check_mapping(value: Any, hint_args: tuple[Any, ...]) -> str | None:
|
|
354
|
+
if len(hint_args) != 2:
|
|
355
|
+
return None
|
|
356
|
+
key_hint, value_hint = hint_args
|
|
357
|
+
for key, item in value.items():
|
|
358
|
+
problem = _mismatch(key, key_hint)
|
|
359
|
+
if problem is not None:
|
|
360
|
+
return (
|
|
361
|
+
f"{_got(value)} with key {problem}"
|
|
362
|
+
if " " not in problem
|
|
363
|
+
else (f"{_got(value)} with {_got(key)} key {_repr(key)} ({problem})")
|
|
364
|
+
)
|
|
365
|
+
problem = _mismatch(item, value_hint)
|
|
366
|
+
if problem is not None:
|
|
367
|
+
where = f"at key {_repr(key)}"
|
|
368
|
+
if " " not in problem:
|
|
369
|
+
return f"{_got(value)} with {problem} value {where}"
|
|
370
|
+
return f"{_got(value)} with {_got(item)} value {where} ({problem})"
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _check_tuple(value: Any, hint_args: tuple[Any, ...]) -> str | None:
|
|
375
|
+
if len(hint_args) == 2 and hint_args[1] is Ellipsis:
|
|
376
|
+
for i, item in enumerate(value):
|
|
377
|
+
problem = _mismatch(item, hint_args[0])
|
|
378
|
+
if problem is not None:
|
|
379
|
+
return _nested("tuple", problem, f"at index {i}", item)
|
|
380
|
+
return None
|
|
381
|
+
if hint_args == ((),):
|
|
382
|
+
hint_args = ()
|
|
383
|
+
if len(value) != len(hint_args):
|
|
384
|
+
return f"tuple of length {len(value)}"
|
|
385
|
+
for i, (item, item_hint) in enumerate(zip(value, hint_args, strict=True)):
|
|
386
|
+
problem = _mismatch(item, item_hint)
|
|
387
|
+
if problem is not None:
|
|
388
|
+
if " " not in problem:
|
|
389
|
+
return f"tuple with {problem} at index {i}"
|
|
390
|
+
return f"tuple with {_got(item)} at index {i} ({problem})"
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _mismatch(value: Any, hint: Any) -> str | None:
|
|
395
|
+
"""Return a description of what ``value`` is if it violates ``hint``, else ``None``."""
|
|
396
|
+
if hint is Any or hint is object or isinstance(hint, (str, typing.ForwardRef)):
|
|
397
|
+
return None
|
|
398
|
+
if hint is None or hint is _NoneType:
|
|
399
|
+
return None if value is None else _got(value)
|
|
400
|
+
if isinstance(hint, TypeVar):
|
|
401
|
+
if hint.__bound__ is not None:
|
|
402
|
+
return _mismatch(value, hint.__bound__)
|
|
403
|
+
if hint.__constraints__:
|
|
404
|
+
if any(_mismatch(value, c) is None for c in hint.__constraints__):
|
|
405
|
+
return None
|
|
406
|
+
return _got(value)
|
|
407
|
+
return None
|
|
408
|
+
supertype = getattr(hint, "__supertype__", None)
|
|
409
|
+
if supertype is not None: # NewType
|
|
410
|
+
return _mismatch(value, supertype)
|
|
411
|
+
|
|
412
|
+
origin = typing.get_origin(hint)
|
|
413
|
+
args = typing.get_args(hint)
|
|
414
|
+
|
|
415
|
+
if origin is typing.Annotated:
|
|
416
|
+
return _mismatch(value, args[0])
|
|
417
|
+
if origin is Union or (_UnionType is not None and origin is _UnionType):
|
|
418
|
+
members = [a for a in args if a is not _NoneType]
|
|
419
|
+
if any(_mismatch(value, a) is None for a in args):
|
|
420
|
+
return None
|
|
421
|
+
if value is not None and len(members) == 1:
|
|
422
|
+
return _mismatch(value, members[0])
|
|
423
|
+
return _got(value)
|
|
424
|
+
if origin is typing.Literal:
|
|
425
|
+
for lit in args:
|
|
426
|
+
if type(value) is type(lit) and value == lit:
|
|
427
|
+
return None
|
|
428
|
+
return _got(value) if not isinstance(value, (str, int, bytes, bool)) else _repr(value)
|
|
429
|
+
if origin is None:
|
|
430
|
+
if typing.is_typeddict(hint):
|
|
431
|
+
return None
|
|
432
|
+
if hint is typing.Callable or hint is cabc.Callable:
|
|
433
|
+
return None if callable(value) else _got(value)
|
|
434
|
+
if isinstance(hint, type):
|
|
435
|
+
if getattr(hint, "_is_protocol", False):
|
|
436
|
+
if not getattr(hint, "_is_runtime_protocol", False):
|
|
437
|
+
return None
|
|
438
|
+
return None if _isinstance(value, hint) else _got(value)
|
|
439
|
+
if hint is float and isinstance(value, int) and not isinstance(value, bool):
|
|
440
|
+
return None
|
|
441
|
+
if hint is float and isinstance(value, bool):
|
|
442
|
+
return None
|
|
443
|
+
if hint is complex and isinstance(value, (int, float)):
|
|
444
|
+
return None
|
|
445
|
+
return None if _isinstance(value, hint) else _got(value)
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
# Parameterised generics from here on.
|
|
449
|
+
if origin is cabc.Callable:
|
|
450
|
+
return None if callable(value) else _got(value)
|
|
451
|
+
if origin is type:
|
|
452
|
+
if not isinstance(value, type):
|
|
453
|
+
return _got(value)
|
|
454
|
+
if not args:
|
|
455
|
+
return None
|
|
456
|
+
target = args[0]
|
|
457
|
+
if target is Any or isinstance(target, TypeVar):
|
|
458
|
+
return None
|
|
459
|
+
is_union = typing.get_origin(target) in (Union, _UnionType)
|
|
460
|
+
targets = typing.get_args(target) if is_union else (target,)
|
|
461
|
+
for t in targets:
|
|
462
|
+
# On 3.10 ``isinstance(list[int], type)`` is True, so rule out generic aliases too.
|
|
463
|
+
if not isinstance(t, type) or typing.get_origin(t) is not None:
|
|
464
|
+
return None
|
|
465
|
+
if issubclass(value, t):
|
|
466
|
+
return None
|
|
467
|
+
return f"type {value.__name__}"
|
|
468
|
+
if not isinstance(origin, type):
|
|
469
|
+
return None
|
|
470
|
+
if getattr(origin, "_is_protocol", False):
|
|
471
|
+
return None
|
|
472
|
+
if not _isinstance(value, origin):
|
|
473
|
+
return _got(value)
|
|
474
|
+
|
|
475
|
+
if origin in (list, set, frozenset) or origin in _ELEMENT_CHECKED_ABCS:
|
|
476
|
+
return _check_items(value, args)
|
|
477
|
+
if origin is dict or origin in _MAPPING_ABCS:
|
|
478
|
+
return _check_mapping(value, args)
|
|
479
|
+
if origin is tuple:
|
|
480
|
+
return _check_tuple(value, args)
|
|
481
|
+
return None # Iterable/Iterator/other generics: isinstance only
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
_QUICK_METAS: tuple[type, ...] = (type, abc.ABCMeta)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _quick_class(hint: Any) -> Any:
|
|
488
|
+
"""A ``isinstance`` classinfo that is sufficient (not necessary) for ``hint``, or ``None``.
|
|
489
|
+
|
|
490
|
+
If ``isinstance(value, quick)`` holds, `_mismatch` would also accept the value, so the
|
|
491
|
+
full check (and its error message) is only needed when it does not.
|
|
492
|
+
"""
|
|
493
|
+
if hint is None or hint is _NoneType:
|
|
494
|
+
return _NoneType
|
|
495
|
+
if (
|
|
496
|
+
type(hint) not in _QUICK_METAS
|
|
497
|
+
or typing.get_origin(hint) is not None
|
|
498
|
+
or getattr(hint, "_is_protocol", False)
|
|
499
|
+
or typing.is_typeddict(hint)
|
|
500
|
+
or hint is typing.Callable
|
|
501
|
+
or hint is cabc.Callable
|
|
502
|
+
):
|
|
503
|
+
return None
|
|
504
|
+
if hint is float:
|
|
505
|
+
return (float, int) # bool is an int
|
|
506
|
+
if hint is complex:
|
|
507
|
+
return (complex, float, int)
|
|
508
|
+
return hint
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _skippable(hint: Any) -> bool:
|
|
512
|
+
return hint is Any or hint is object or isinstance(hint, (str, typing.ForwardRef))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class _Plan:
|
|
516
|
+
"""Per-function checks compiled from resolved annotations (see `_Checker`)."""
|
|
517
|
+
|
|
518
|
+
__slots__ = ("checks", "hints", "min_args", "n_params", "ret_hint", "ret_quick")
|
|
519
|
+
|
|
520
|
+
def __init__(self, hints: dict[str, Any], layout: tuple[tuple[str, ...], int] | None) -> None:
|
|
521
|
+
self.hints = hints
|
|
522
|
+
self.checks: tuple[tuple[int, str, Any, Any], ...] = ()
|
|
523
|
+
self.n_params = -1
|
|
524
|
+
self.min_args = 0
|
|
525
|
+
if layout is not None:
|
|
526
|
+
names, self.min_args = layout
|
|
527
|
+
self.n_params = len(names)
|
|
528
|
+
self.checks = tuple(
|
|
529
|
+
(i, n, hints[n], _quick_class(hints[n]))
|
|
530
|
+
for i, n in enumerate(names)
|
|
531
|
+
if n in hints and not _skippable(hints[n])
|
|
532
|
+
)
|
|
533
|
+
self.ret_hint = hints.get("return", _MISSING)
|
|
534
|
+
self.ret_quick = None if self.ret_hint is _MISSING else _quick_class(self.ret_hint)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class _Checker:
|
|
538
|
+
"""Lazily resolves annotations on first call and checks calls against them."""
|
|
539
|
+
|
|
540
|
+
def __init__(self, func: Callable[..., Any], check_return: bool) -> None:
|
|
541
|
+
self.func = func
|
|
542
|
+
self.check_return = check_return
|
|
543
|
+
self.sig = inspect.signature(func)
|
|
544
|
+
layout = _plain_layout(self.sig)
|
|
545
|
+
self.layout = None if layout is None else (layout[0], layout[1])
|
|
546
|
+
self.hints: dict[str, Any] | None = None
|
|
547
|
+
self.plan: _Plan | None = None
|
|
548
|
+
|
|
549
|
+
def _get_plan(self) -> _Plan:
|
|
550
|
+
plan = self.plan
|
|
551
|
+
if plan is not None:
|
|
552
|
+
return plan
|
|
553
|
+
try:
|
|
554
|
+
hints = typing.get_type_hints(self.func)
|
|
555
|
+
except Exception:
|
|
556
|
+
# Unresolvable forward reference (so far): fall back to raw annotations, where
|
|
557
|
+
# strings are accepted, and try again on the next call.
|
|
558
|
+
return _Plan(dict(getattr(self.func, "__annotations__", {})), self.layout)
|
|
559
|
+
self.hints = hints
|
|
560
|
+
self.plan = plan = _Plan(hints, self.layout)
|
|
561
|
+
return plan
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _fail(label: str, hint: Any, value: Any, problem: str, what: str) -> typing.NoReturn:
|
|
565
|
+
raise errors.ArgumentTypeError(
|
|
566
|
+
f"{what} {label!r} expected {_fmt(hint)}, got {problem}"
|
|
567
|
+
if what == "parameter"
|
|
568
|
+
else f"{what} expected {_fmt(hint)}, got {problem}",
|
|
569
|
+
parameter=label,
|
|
570
|
+
expected=hint,
|
|
571
|
+
value=value,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def _check_one(self, label: str, value: Any, hint: Any) -> None:
|
|
575
|
+
problem = _mismatch(value, hint)
|
|
576
|
+
if problem is not None:
|
|
577
|
+
self._fail(label, hint, value, problem, "parameter")
|
|
578
|
+
|
|
579
|
+
def before(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
580
|
+
plan = self._get_plan()
|
|
581
|
+
nargs = len(args)
|
|
582
|
+
if not kwargs and plan.min_args <= nargs <= plan.n_params:
|
|
583
|
+
for idx, name, hint, quick in plan.checks:
|
|
584
|
+
if idx < nargs:
|
|
585
|
+
value = args[idx]
|
|
586
|
+
if quick is not None and isinstance(value, quick):
|
|
587
|
+
continue
|
|
588
|
+
problem = _mismatch(value, hint)
|
|
589
|
+
if problem is not None:
|
|
590
|
+
self._fail(name, hint, value, problem, "parameter")
|
|
591
|
+
return
|
|
592
|
+
hints = plan.hints
|
|
593
|
+
if not hints:
|
|
594
|
+
return
|
|
595
|
+
bound = self.sig.bind(*args, **kwargs)
|
|
596
|
+
for name, value in bound.arguments.items():
|
|
597
|
+
if name not in hints:
|
|
598
|
+
continue
|
|
599
|
+
hint = hints[name]
|
|
600
|
+
kind = self.sig.parameters[name].kind
|
|
601
|
+
if kind is inspect.Parameter.VAR_POSITIONAL:
|
|
602
|
+
for i, item in enumerate(value):
|
|
603
|
+
self._check_one(f"{name}[{i}]", item, hint)
|
|
604
|
+
elif kind is inspect.Parameter.VAR_KEYWORD:
|
|
605
|
+
for key, item in value.items():
|
|
606
|
+
self._check_one(f"{name}[{key!r}]", item, hint)
|
|
607
|
+
else:
|
|
608
|
+
self._check_one(name, value, hint)
|
|
609
|
+
|
|
610
|
+
def after(self, result: Any) -> None:
|
|
611
|
+
plan = self._get_plan()
|
|
612
|
+
hint = plan.ret_hint
|
|
613
|
+
if hint is _MISSING:
|
|
614
|
+
return
|
|
615
|
+
quick = plan.ret_quick
|
|
616
|
+
if quick is not None and isinstance(result, quick):
|
|
617
|
+
return
|
|
618
|
+
problem = _mismatch(result, hint)
|
|
619
|
+
if problem is not None:
|
|
620
|
+
self._fail("return", hint, result, problem, "return value")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@overload
|
|
624
|
+
def enforce_types(func: F, /) -> F: ...
|
|
625
|
+
@overload
|
|
626
|
+
def enforce_types(func: None = None, /, *, check_return: bool = True) -> Callable[[F], F]: ...
|
|
627
|
+
def enforce_types(func: Callable[..., Any] | None = None, /, *, check_return: bool = True) -> Any:
|
|
628
|
+
"""Check arguments and the return value against the function's own annotations.
|
|
629
|
+
|
|
630
|
+
Annotations are resolved lazily on the first call with `typing.get_type_hints`, so
|
|
631
|
+
forward references work. Only arguments that are actually passed are checked (defaults
|
|
632
|
+
are not). For ``async def`` functions the awaited result is checked. The supported
|
|
633
|
+
subset of annotations is listed under "Annotations supported by `enforce_types`" in
|
|
634
|
+
the `python_wrap.validation` module docstring (the top of its API reference page) and
|
|
635
|
+
in the [validation guide](../features/correctness/validation.md); anything
|
|
636
|
+
unsupported is accepted rather than rejected.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
func: The function (when used as a bare ``@enforce_types``).
|
|
640
|
+
check_return: Also check the return value against the return annotation.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
The wrapped function, or a decorator when called with options.
|
|
644
|
+
|
|
645
|
+
Raises:
|
|
646
|
+
python_wrap.errors.ArgumentTypeError: On a mismatch (a ``TypeError``), with a message
|
|
647
|
+
such as ``parameter 'items' expected list[int], got list containing str at
|
|
648
|
+
index 2``. ``error.parameter`` is ``"return"`` for the return value.
|
|
649
|
+
|
|
650
|
+
Example:
|
|
651
|
+
>>> @enforce_types
|
|
652
|
+
... def total(items: list[int]) -> int:
|
|
653
|
+
... return sum(items)
|
|
654
|
+
>>> total([1, 2])
|
|
655
|
+
3
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
659
|
+
checker = _Checker(f, check_return)
|
|
660
|
+
return _wrap(f, checker.before, checker.after if check_return else None)
|
|
661
|
+
|
|
662
|
+
return decorate if func is None else decorate(func)
|