Most ad-hoc timing code in Python looks like this:
start = time.perf_counter()
result = do_work()
elapsed = time.perf_counter() - start
stats[name].append(elapsed)
It works, until do_work() raises. Then the line that records the timing never runs, the exception propagates, and the one call that was probably slowest — the one that failed — is silently missing from your stats. If you're using timing data to find what's expensive, the failing case is exactly the one you can least afford to lose.
timerx is a small, dependency-free Python timing library — a decorator, a context manager, and named stopwatches, all backed by one stats store. The one rule that shapes the whole implementation: a timing gets recorded whether or not the timed code raised.
Decision 1: finally, everywhere, no exceptions to the rule
@functools.wraps(target)def wrapper(*args: Any, **kwargs: Any) -> Any:
started = self._clock()
try:
return target(*args, **kwargs)
finally:
elapsed = self._clock() - started
with self._lock:
self._record(label, elapsed)
return wrapper
The async wrapper is the identical shape with await added. The context manager (_Lap) does the same thing structurally, just split across __enter__/__exit__ instead of try/finally:
def __exit__(self, *exc_info: object) -> bool:
if self._started is None:
raise RuntimeError("timerx lap exited before it was entered")
elapsed = self._timer._clock() - self._started
with self._timer._lock:
self._timer._record(self._name, elapsed)
return False
Note the return False — __exit__ deliberately never swallows the exception. It records the timing and lets the exception continue propagating unchanged, because a timing library has exactly one job here: observe, not intervene. A version that suppressed exceptions to "clean up" would be actively dangerous to drop into someone else's codebase.
Three entry points — decorator, context manager, stopwatch — and all three guarantee the same thing, because all three route through the same _record call inside a finally (or its structural equivalent). There's no code path in the library where a timing can be silently dropped because the timed code failed.
Decision 2: start/stop names are a stack, not a single slot
Named stopwatches need to answer a question decorators and context managers don't: what happens if you call start("x") twice before calling stop("x")? A single-slot implementation (self._running[name] = t) would let the second start silently overwrite the first — the first call's start time is gone, and its eventual stop measures the wrong duration.
def start(self, name: str) -> None:
t = self._clock()
with self._lock:
self._running.setdefault(name, []).append(t)
def stop(self, name: str) -> float:
t = self._clock()
try:
with self._lock:
stack = self._running[name]
started = stack.pop()
if not stack:
del self._running[name]
except KeyError as exc:
raise KeyError(f"timerx stopwatch {name!r} was not started") from exc
elapsed = t - started
with self._lock:
self._record(name, elapsed)
return elapsed
_running[name] is a list used as a stack: each start pushes, each stop pops the most recent start. This means recursive or re-entrant timing under the same name — nested retries, a recursive function you're profiling — behaves correctly without the caller having to invent unique names for each nesting level. It also means stop() on a name that was never started raises KeyError with a clear message rather than a TypeError from popping None, or worse, silently returning a garbage duration.
Decision 3: Global convenience functions and isolated instances share one implementation
class TimerX:
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._clock = clock or time.perf_counter
self._records: dict[str, _Record] = {}
self._running: dict[str, list[float]] = {}
self._lock = threading.Lock()
The module-level timerx.track, timerx.lap, timerx.start, timerx.stop that most users reach for are just methods on one process-wide TimerX() instance created at import time. from timerx import TimerX; tx = TimerX() gives you a completely separate instance with its own _records, its own _running stack, its own lock.
This matters for exactly one reason: a library that imports timerx to profile its own internals shouldn't pollute the timing stats of the application that imports the same library. If timerx only exposed the global functions, every consumer would share one namespace, and a library's @timerx.track on some internal helper would show up in the application's timerx.summary() output next to the application's own timings, with no way to separate them. Building the instance-based API first and deriving the global convenience functions from a single default instance — rather than building the global API first and bolting on isolation later — is what makes that separation free instead of an afterward patch.
Decision 4: _clock is injectable, and that's what makes the test suite honest
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._clock = clock or time.perf_counter
Every place that needs "now" calls self._clock(), never time.perf_counter() directly. That single seam is what lets the 33-test suite assert exact elapsed durations — tx = TimerX(clock=fake_clock) where fake_clock returns a scripted sequence of values — instead of asserting "elapsed is roughly 0.2 seconds, give or take scheduler jitter" and occasionally flaking in CI under load. A timing library whose own test suite has to tolerate timing noise to test its timing logic would be a bad sign; injecting the clock sidesteps that entirely.
What I'd take away
-
A timing library's one non-negotiable invariant is "always record, even on failure." Once you decide that,
try/finally(or its__enter__/__exit__equivalent) isn't an implementation detail — it's the whole point, and it should appear identically in every entry point, not just the common-case one. - Model "start twice, stop twice" as a stack from the start. Retrofitting stack semantics onto a single-slot dict later is a breaking API change; building it as a stack from the first line costs nothing and quietly supports recursion nobody explicitly designed for.
- Make time itself an injectable dependency. It's a few characters at the constructor, and it's the difference between a test suite that asserts exact values and one that asserts approximate ranges and hopes the CI runner isn't busy.
The full library — decorator, context manager, stopwatches, formatted summaries, isolated instances — is at github.com/abhijatchaturvedi/timerx, and on PyPI as pip install timerx. Try decorating a function that raises on every third call and check timerx.get_stats() afterward — the failed calls are all still there.
Top comments (0)