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.
@@ -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)