Timing code¶
Everything is timed with one class, TimerContext. This page
covers the ways to use it.
With a with block¶
import time
from execution_timer import TimerContext
with TimerContext("load_data"):
time.sleep(0.01)
The section’s time is added when the block exits, even if the block raises. The exception propagates as usual; the timer never swallows or replaces it.
from execution_timer import get_execution_timings
try:
with TimerContext("risky"):
raise ValueError("bad input")
except ValueError:
pass
assert ("risky",) in get_execution_timings()
As a decorator¶
Decorating a function times every call to it and adds them all up in one section:
@TimerContext("preprocess")
def preprocess(rows: list[str]) -> list[str]:
return [row.strip() for row in rows]
preprocess([" a ", " b "])
The decorated function keeps its name, docstring, signature and return value. A decorated
call made inside another section nests under it, exactly like a with block.
Async functions¶
Decorating an async def times the whole await, including everything the coroutine
awaits along the way, not just the creation of the coroutine object:
import asyncio
@TimerContext("fetch", category="io")
async def fetch(delay: float) -> str:
await asyncio.sleep(delay)
return "payload"
asyncio.run(fetch(0.01))
This also works when another decorator sits between TimerContext and the async def, as
long as that decorator returns the coroutine, like most retry, caching and tracing
decorators do:
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@TimerContext("fetch_logged", category="io")
@logged
async def fetch_logged() -> None:
await asyncio.sleep(0.01)
asyncio.run(fetch_logged())
The section then covers both the call and the await.
Generator functions can’t be decorated¶
Decorating a generator function, or an async generator function, raises TypeError. A
wrapper could only time creating the generator object, which takes microseconds however
long the iteration takes. Time the loop that consumes the generator instead:
def numbers(n: int):
for i in range(n):
yield i * i
with TimerContext("consume_numbers"):
total = sum(numbers(1_000))
Threads, asyncio and generators explains what happens to sections opened inside a generator.
Reusing a context¶
A TimerContext holds no timing state of its own; the registry does. So one context can be
used any number of times, nested inside itself, or shared between threads and tasks. In a
tight loop, create it once:
step_timer = TimerContext("step")
for item in range(100):
with step_timer:
_ = item * item
A decorator already works this way: it creates one context and reuses it for every call.
Repeated and recursive calls¶
Entering the same section again, at the same place in the tree, adds to its total. The report shows one line with the accumulated time.
Recursion is different: each level runs inside the previous one, so each level is a new,
deeper section. A recursive function decorated with TimerContext shows one line per
depth:
from execution_timer import clear_execution_timings, get_execution_times_report
clear_execution_timings()
@TimerContext("walk")
def walk(depth: int) -> None:
time.sleep(0.01)
if depth:
walk(depth - 1)
walk(2)
print(get_execution_times_report())
Total time: 34.704 ms.
walk: 34.704 ms (100.00%)
.. walk: 24.161 ms (69.62%)
.. .. walk: 11.603 ms (33.44%)
For deep recursion, time the top-level call only, for example with a with block around
it.
Names and paths¶
A section is identified by its path: the names of the enclosing sections followed by its
own name. In reports and in get_execution_timings(), the section
above is ("walk", "walk"), and step inside solve is ("solve", "step"). Two sections
with the same name in different places are separate entries.