Instrumentation
This is a powerful feature that allows you to do a run-time check on any given function.
Easiest way to explain this is by example.
from predicate import instrument_function, Spec, is_int_p
def max_int_with_bug(x, y):
return x if x > y else f"{y}"
spec: Spec = {
"args": {"x": is_int_p, "y": is_int_p},
"ret": is_int_p,
"fn": lambda x, y, ret: ret >= x and ret >= y,
}
instrument_function(max_int_with_bug, spec=spec)
The function max_int_with_bug contains an annoying bug. As long as x is the largest value,
everything is fine, but if y is the maximum, then suddenly a string is returned instead of
an integer.
Spec keys
argsPredicates for each parameter. If the argument is annotated, a predicate will be derived automatically from the annotation — for example
x: intbecomesis_int_p.ret(optional)Predicate that the return value is evaluated against. Omit if you only want to constrain arguments or the relationship between inputs and output.
fn(optional)A callable that receives all arguments plus
retas keyword arguments and returns abool. Use this to express how inputs and the return value relate to each other."fn": lambda x, y, ret: ret >= x and ret >= y
fn_p(optional)A callable that receives the arguments and returns a
Predicatewhich is then applied to the return value. Use this when the expected return predicate depends on the input values."fn_p": lambda x, y: ge_p(x + y)
raises(optional)One exception type, or a tuple of exception types, that the function is allowed to raise. When the function raises an exception that matches, it propagates to the caller normally. When the function raises an exception that does not match,
on_erroris called and the unexpected exception is re-raised.If
raisesis absent and the function raises, the exception propagates unchanged — there is no validation."raises": ValueError # or multiple types: "raises": (ValueError, KeyError)
Using the decorator
As an alternative to instrument_function, the instrument decorator applies a spec
directly to a function definition:
from predicate import instrument, Spec, is_int_p
spec: Spec = {
"args": {"x": is_int_p, "y": is_int_p},
"ret": is_int_p,
"fn": lambda x, y, ret: ret >= x and ret >= y,
}
@instrument(spec)
def max_int(x: int, y: int) -> int:
return x if x >= y else y
Both forms are equivalent.
Examples
Given the instrumented max_int_with_bug, calling it with valid inputs:
result = max_int_with_bug(3, 4)
is fine — the parameters and return value all satisfy the spec.
However:
result = max_int_with_bug(4, 3)
triggers the bug and raises:
ValueError: Return predicate for function max_int_with_bug failed. Reason: 3 is not an instance of type int
Passing a wrong argument type:
result = max_int_with_bug(4, False)
raises:
ValueError: Parameter predicate for function max_int_with_bug failed. Reason: False is not an instance of type int
Note that argument predicates are checked before the function executes, so no side effects occur when an argument is invalid.
Specifying expected exceptions
Use the raises key to declare which exception types a function may raise:
from predicate import instrument, Spec, is_int_p
spec: Spec = {
"args": {"n": is_int_p},
"raises": ValueError,
}
@instrument(spec)
def checked_sqrt(n: int) -> float:
if n < 0:
raise ValueError(f"Cannot take square root of {n}")
return n ** 0.5
Calling with a negative number re-raises the ValueError as expected:
checked_sqrt(-1) # raises ValueError: Cannot take square root of -1
If the function raises a different exception type, on_error is called first:
spec: Spec = {"args": {}, "raises": ValueError}
@instrument(spec)
def f() -> int:
raise TypeError("unexpected")
f()
# raises ValueError: Unexpected exception TypeError for function f
# followed by the original TypeError
To allow several exception types, pass a tuple:
from predicate import instrument, Spec, is_str_p
spec: Spec = {
"args": {"key": is_str_p},
"raises": (KeyError, ValueError),
}
Async support
instrument and instrument_function work transparently with async def functions.
The same spec keys apply; argument checking happens before the coroutine is awaited and
return / constraint checking happens after:
import asyncio
from predicate import instrument, Spec, is_int_p
spec: Spec = {
"args": {"x": is_int_p, "y": is_int_p},
"ret": is_int_p,
"fn": lambda x, y, ret: ret >= x and ret >= y,
}
@instrument(spec)
async def async_max(x: int, y: int) -> int:
return x if x >= y else y
result = asyncio.run(async_max(3, 7)) # returns 7
Async functions that raise are handled identically to sync functions — use the raises
key in the spec to declare expected exception types:
from predicate import instrument, Spec, is_str_p
spec: Spec = {
"args": {"key": is_str_p},
"raises": KeyError,
}
@instrument(spec)
async def fetch(key: str) -> str:
raise KeyError(key)
asyncio.run(fetch("missing")) # raises KeyError as expected
Instrumenting instance methods
@instrument can be applied directly to an instance method inside a class body.
self is automatically ignored — it has no annotation so it is never added to the spec.
from predicate import instrument
class Adder:
@instrument
def add(self, x: int, y: int) -> int:
return x + y
Adder().add(1, 2) # returns 3
Adder().add(1, "x") # raises ValueError: Parameter predicate …
instrument_function works the same way when passed the unbound method:
from predicate import instrument_function, Spec, is_int_p
class Adder:
def add(self, x: int, y: int) -> int:
return x + y
spec: Spec = {"args": {"x": is_int_p, "y": is_int_p}, "ret": is_int_p}
instrument_function(Adder.add, spec)
Note on fn / fn_p constraints and self
When using fn, self is included in the keyword arguments passed to the callable,
so the lambda must accept it:
spec: Spec = {
"args": {},
"fn": lambda self, x, y, ret: ret == x + y,
}
Instrumenting whole classes
@instrument can be applied to an entire class, instrumenting all its instance methods
at once:
from predicate import instrument
@instrument
class Calculator:
def add(self, x: int, y: int) -> int:
return x + y
def negate(self, x: int) -> int:
return -x
calc = Calculator()
calc.add(1, 2) # returns 3
calc.add(1, "x") # raises ValueError: Parameter predicate …
instrument_class is available for the post-hoc form:
from predicate import instrument_class
instrument_class(Calculator)
An optional pattern argument (fnmatch-style) limits which methods are instrumented:
instrument_class(Calculator, pattern="get_*")
classmethod and staticmethod members are not affected.
Combining class-level and method-level instrumentation
@instrument on a class and @instrument(spec) on a specific method can be used
together. Methods that already carry a spec are skipped by the class-level instrumentation,
so the more specific per-method spec always wins:
from predicate import instrument, Spec, is_int_p
@instrument
class Calculator:
@instrument({"args": {}, "fn": lambda self, x, y, ret: ret == x + y})
def add(self, x: int, y: int) -> int:
return x + y # validated by the explicit fn constraint
def negate(self, x: int) -> int:
return -x # validated by annotation-derived spec only
In this example add is instrumented only once with its custom spec, while negate
receives the generic annotation-based spec from the class decorator.
Instrumenting whole modules
instrument_module applies an empty spec (derived entirely from annotations) to every
function in a module:
import mymodule
from predicate import instrument_module
instrument_module(mymodule)
An optional pattern argument (fnmatch-style) limits which functions are instrumented:
instrument_module(mymodule, pattern="get_*")
Custom error handling
By default, spec violations raise ValueError. Pass on_error to override:
from predicate import instrument
errors = []
@instrument(on_error=errors.append)
def add(x: int, y: int) -> int:
return x + y
add("oops", 1) # on_error called, but function still runs
print(errors) # ['Parameter predicate for function add failed. Reason: ...']
The same on_error parameter is accepted by instrument_function,
instrument_class, and instrument_module.