Lightweight, dependency-free runtime argument validation for Python via a single decorator. See Changelog.md for version history.
typekeeper instruments your functions with comprehensive runtime checks
through one decorator, @validate_args. With no code changes beyond the
decorator, you get:
- Type checking against rich
typingannotations. - Warnings for mismatched or mutable default values.
- Inclusive numeric-range and length-range constraints via a tiny spec
string, including nested-path targeting with
:and wildcards. - Recursive
_validate()traversal of nested data structures. - Precise source locations (file + line) for every warning, both at decoration time and call time.
- Global, per-decorator, and contextual on/off switches.
- Type validation — recursively checks the common
typingforms: built-in generics (list[int],dict[str, float]),Optional,Union/ PEP 604 unions,Literal,Type[T]/type[T],Annotated,Final,NewType,TypeVar(constraints and bounds),Callable(callability and positional arity),TypedDict(structural dict check),Protocol(structural attribute check for both@runtime_checkableand plain protocols),Self, and abstract base classes such asSequence,Mapping, andIterable. Unresolved string forward references are treated as unchecked;ForwardRefobjects are resolved against the typing namespace when possible. - Default-value checks — warns when a default does not match its
annotation, and detects mutable defaults (suppressible per decorator
via
ignore_defaults=True). - Range & length specs — pass
constraints; each spec isname=token1,token2,...where a token is either a single numberNor an inclusive rangemin-max. Whitespace around the inner-is tolerated. Use:to traverse nested mappings and sequences, with integer segments selecting sequence elements (negative indices follow normal Python rules). Wildcards (*or a trailing:) match every element under that point; later specs override earlier ones for the same target. Missing mapping keys yield no matches (no spurious warnings). - Recursive
_validate()— every argument is walked. Any object with a callable_validate()is invoked; falsy returns or raised exceptions are reported with the path inside the original structure. Mappings (includingUserDict/MappingProxyType) are recursed viacollections.abc.Mapping. Iterators and generators are never consumed by validation. - Context-aware warnings — messages embed both the decoration
location (
file:line) and the call site. - Global control —
set_arg_checks(False)disables decoration entirely;set_stop_on_error(True)turns every warning into aValueError;suspended_arg_checks()is a context manager that switches checks off inside awithblock (overhead drops to under half a microsecond per call).
pip install typekeeperpip install -e ".[dev]"
python -m pytest tests/ --cov=typekeeperThe test suite covers 100% of typekeeper/typekeeper.py and exercises a
broad slice of typing features end-to-end.
| API | Effect |
|---|---|
set_arg_checks(enabled: bool) |
Turn all argument checking on/off globally. Disabling at decoration time skips wrapping entirely (zero overhead). |
set_stop_on_error(stop: bool) |
When True, every validation failure raises ValueError instead of warning. |
suspended_arg_checks() |
Context manager that disables checks within a with block (decorator stays in place). |
def validate_args(
_func=None,
constraints: str | None = None,
*,
ignore_defaults: bool = False,
stop_on_error: bool | None = None,
) -> Callable: ...constraints— numeric/length spec string covering both numeric ranges and sequence-length ranges (see syntax below).ignore_defaults— skip the mutable-default warning ifTrue.stop_on_error— per-decorator override of the globalset_stop_on_errorflag; warnings from this decorator raiseValueErrorregardless of the global setting.
Constraint syntax — a semicolon-separated list:
name=token1,token2,...
Each token is N (exact match) or min-max (inclusive range).
Nested targets use : after the name: users:tables:headers=100,
rows:0:score=0-100, data:*=1-5. Whitespace inside ranges is
tolerated (1 - 3 parses identically to 1-3).
Inside path segments, the following backslash escapes are honoured:
| Escape | Meaning |
|---|---|
\: |
Literal : inside a segment. |
\\ |
Literal backslash. |
\* |
Literal * segment (no wildcard expansion). |
\! |
Literal empty segment — addresses dict keys that are literally "" without firing the wildcard branch. |
A bare empty segment (e.g. trailing :) or * still means every entry
at this level; specs whose first segment is empty (":x=1-3") emit a
parse-time warning instead of being silently dropped.
Full runnable scripts live in examples/. Each script
prints its own validation overhead via examples/_bench.py.
| File | Topic |
|---|---|
basic.py |
Type + default checks. |
range_specs.py |
Numeric and length ranges. |
path_indices.py |
Integer index segments (incl. negatives). |
nested_override.py |
Wildcards and spec overrides. |
custom_validate.py |
_validate() recursion + mutable defaults. |
complex_typing.py |
Union, Literal, Callable (arity), TypeVar, NewType, Annotated. |
typeddicts.py |
Structural TypedDict checks (total=True / False). |
protocols.py |
@runtime_checkable and plain Protocol (structural attribute check). |
forward_refs.py |
from __future__ import annotations and forward references. |
globals_and_context.py |
set_arg_checks, set_stop_on_error, suspended_arg_checks. |
# Snapshot of basic.py
from typekeeper import validate_args
@validate_args()
def greet(name: str, times: int = 1) -> str:
return " ".join([name] * times)
greet("Alice")
greet(42)
# UserWarning: Arg 'name' to '<function greet ...>' mismatches <class 'str'>;
# expected <class 'str'>, got <class 'int'>Run tools/profile.py to reproduce these numbers.
They are representative of a single CPython 3.14 run on a desktop
x86-64 machine (50,000 iterations per scenario, after a warm-up burst):
| Scenario | Bare (µs/call) | Checked (µs/call) | Suspended (µs/call) | Overhead (µs/call) |
|---|---|---|---|---|
simple (x: int, y: str) |
0.08 | 10.9 | 0.19 | 10.8 |
nested_generic (list[dict[str, int]], 3 dicts) |
0.35 | 39.5 | 0.66 | 39.1 |
constraints (numeric + length spec) |
0.07 | 23.0 | 0.18 | 22.9 |
path_spec (rows:*:score=0-100, 5 rows) |
0.05 | 60.6 | 0.25 | 60.6 |
varargs (*values: int, 6 args) |
0.07 | 20.6 | 0.53 | 20.6 |
custom_validate (8-element list with _validate) |
0.05 | 25.3 | 0.21 | 25.2 |
Run it yourself:
python tools/profile.py --iterations 50000 # scenario benchmark
python tools/profile.py --cprofile # cProfile breakdown
python tools/profile.py --iterations 50000 --json results.jsonKey takeaways:
- The decorator itself (suspended mode) costs well under 1 µs per call (typically 0.2 µs for fixed-arity signatures).
- Scalar arguments add roughly 10 µs per call for type checking.
- Per-element checking dominates the cost on collection-shaped signatures; expect ~5–10 µs / leaf in the worst case.
- Path-spec wildcards walk every leaf — prefer indexed paths
(
rows:0:v=...) when you only care about a few cells. - The
_recursive_validate()walk runs on every argument; if your hot path passes large structures and you don't need it, hoist the validated call out of the loop or wrap it inwith suspended_arg_checks(): ....
Contributions are welcome:
- Fork the repository.
- Create a feature branch.
- Run
python -m pytest tests/ --cov=typekeeper(target stays at 100%). - Submit a pull request.
MIT License.
Parth Mittal — parth@privatepanda.co