Build a Decorator Toolkit / mini-DSL
Build a small, reusable toolkit out of the four metaprogramming tools from the
module — a parametrized @retry(times=, backoff=) decorator, a @timed decorator,
a context manager, and a descriptor-based validated field — and then compose
them into a tiny fluent client API. The payoff is seeing how the building blocks
that frameworks are made of fit together in one file you wrote yourself.
The finished toolkit lets you write this:
from toolkit import retry, timed, span, Bounded
class ApiClient: timeout = Bounded(min=0.1, max=30.0) # descriptor-validated field retries = Bounded(min=0, max=10)
def __init__(self, timeout: float = 5.0, retries: int = 3) -> None: self.timeout = timeout # validated through the descriptor self.retries = retries
@timed @retry(times=3, backoff=0.05, exceptions=(ConnectionError,)) def fetch(self, url: str) -> dict[str, object]: with span("http.get"): # context manager times the block return _flaky_get(url)If you’ve written an Express middleware stack, a Go http.RoundTripper wrapper, or
a NestJS interceptor, this is the same instinct — cross-cutting behavior factored
out of the function body — expressed with Python’s decorator/descriptor/context-
manager machinery.
What you’ll practice
Section titled “What you’ll practice”- A parametrized decorator factory (
@retry(...)) withfunctools.wrapsand PEP 695[**P, R]typing so the wrapped signature survives. - A simple
@timeddecorator that works on any function. - A
@contextmanagergenerator (span) for timing/instrumenting a block. - A reusable descriptor (
Bounded) with__set_name__,__get__,__set__. - Composing them — stacking decorators, using the descriptor and context manager together in one class.
Requirements
Section titled “Requirements”retry(times, backoff, exceptions)— retries on the listed exceptions with exponential backoff; re-raises the last exception when attempts run out.timed— logs elapsed wall-clock time; preserves the function’s metadata.span(label)— a context manager that times a block and records it.Bounded(min, max)— a descriptor that validatesmin <= value <= maxon assignment, reused across multiple fields.- A
ApiClient-style class composing all four, with a demomain. - Tests proving retry count, backoff, metadata preservation, and validation.
The worked solution
Section titled “The worked solution”A single uv project. The toolkit lives in one module; a demo and a test file
exercise it.
Directorydecorators-dsl/
- pyproject.toml uv project + ruff/ty config
Directorysrc/
Directorytoolkit/
- init .py re-exports the public API
- decorators.py retry + timed
- context.py the span context manager
- descriptors.py the Bounded descriptor
- client.py the composed ApiClient demo
Directorytests/
- test_toolkit.py behavior tests
pyproject.toml
Section titled “pyproject.toml”A plain uv library project targeting Python 3.13+. The only runtime dependency is
structlog for structured timing output; pytest is a dev dependency.
[project]name = "decorators-dsl"version = "0.1.0"requires-python = ">=3.13"dependencies = ["structlog>=24.4"]
[dependency-groups]dev = ["pytest>=8.3", "ty>=0.0.1"]
[build-system]requires = ["uv_build>=0.8"]build-backend = "uv_build"
[tool.ruff]target-version = "py313"
[tool.ruff.lint]select = ["E", "F", "I", "UP", "B"] # pyflakes, pycodestyle, isort, pyupgrade, bugbearCreate it and add deps with uv — no pip, no manual virtualenv:
uv init --lib decorators-dslcd decorators-dsluv add structloguv add --dev pytest tydecorators.py — the retry factory and timed
Section titled “decorators.py — the retry factory and timed”retry is the three-layer factory from the module: retry(...) captures config and
returns decorator; decorator(fn) returns wrapper; wrapper runs the retry
loop. PEP 695 [**P, R] keeps the wrapped signature exact for ty/mypy, and
functools.wraps keeps __name__/__doc__/__wrapped__.
from __future__ import annotations
import functoolsimport timefrom collections.abc import Callable
import structlog
log = structlog.get_logger()
def retry[**P, R]( times: int = 3, backoff: float = 0.1, exceptions: tuple[type[Exception], ...] = (Exception,),) -> Callable[[Callable[P, R]], Callable[P, R]]: """Retry `fn` on the given exceptions with exponential backoff."""
def decorator(fn: Callable[P, R]) -> Callable[P, R]: @functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: last_exc: Exception | None = None for attempt in range(times): try: return fn(*args, **kwargs) except exceptions as exc: last_exc = exc delay = backoff * (2**attempt) log.warning( "retry", fn=fn.__name__, attempt=attempt + 1, of=times, error=str(exc), ) if attempt < times - 1: time.sleep(delay) assert last_exc is not None raise last_exc
return wrapper
return decorator
def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]: """Log the wall-clock time a function takes. Metadata-preserving."""
@functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: start = time.perf_counter() try: return fn(*args, **kwargs) finally: elapsed_ms = (time.perf_counter() - start) * 1000 log.info("timed", fn=fn.__name__, ms=round(elapsed_ms, 2))
return wrappercontext.py — the span context manager
Section titled “context.py — the span context manager”span is the generator form of a context manager. Everything before yield is
setup, the yielded value is what as binds, and the finally is teardown. It
times the block and yields a small mutable record you can read afterward.
from __future__ import annotations
import timefrom collections.abc import Iteratorfrom contextlib import contextmanagerfrom dataclasses import dataclass, field
import structlog
log = structlog.get_logger()
@dataclassclass Span: label: str elapsed_ms: float = field(default=0.0)
@contextmanagerdef span(label: str) -> Iterator[Span]: """Time a block of code. `with span("x") as s: ...; s.elapsed_ms` is set.""" record = Span(label=label) start = time.perf_counter() try: yield record # the `with span(...) as record` value finally: record.elapsed_ms = (time.perf_counter() - start) * 1000 log.info("span", label=label, ms=round(record.elapsed_ms, 2))descriptors.py — the Bounded validated field
Section titled “descriptors.py — the Bounded validated field”Bounded is a reusable data descriptor. __set_name__ captures the attribute’s own
name at class-creation time, so one descriptor instance per field stores its value
in a private slot without you repeating the name. __set__ validates on every
assignment; __get__ returns the descriptor itself when accessed on the class.
from __future__ import annotations
from typing import Any, overload
class Bounded: """Reusable descriptor enforcing min <= value <= max on assignment."""
def __init__(self, *, min: float, max: float) -> None: if min > max: raise ValueError("min must be <= max") self.min = min self.max = max self.private = ""
def __set_name__(self, owner: type, name: str) -> None: # Called once at class-body evaluation; `name` is the attribute name. self.private = f"_{name}"
@overload def __get__(self, obj: None, objtype: type | None = None) -> Bounded: ... @overload def __get__(self, obj: object, objtype: type | None = None) -> float: ... def __get__(self, obj: Any, objtype: type | None = None) -> Any: if obj is None: # accessed on the class itself return self return getattr(obj, self.private)
def __set__(self, obj: Any, value: float) -> None: if not (self.min <= value <= self.max): raise ValueError( f"{self.private[1:]} must be in [{self.min}, {self.max}], got {value}" ) setattr(obj, self.private, value)__init__.py — the public API
Section titled “__init__.py — the public API”Re-export the toolkit so callers write from toolkit import retry, timed, span, Bounded.
from toolkit.context import Span, spanfrom toolkit.decorators import retry, timedfrom toolkit.descriptors import Bounded
__all__ = ["Bounded", "Span", "retry", "span", "timed"]client.py — composing the toolkit
Section titled “client.py — composing the toolkit”ApiClient uses all four pieces at once: two Bounded descriptor fields, a method
stacked with @timed over @retry(...), and a span inside the method body. The
_flaky_get helper fails the first two times to exercise retry/backoff.
from __future__ import annotations
from toolkit import Bounded, retry, span, timed
_attempts = {"n": 0}
def _flaky_get(url: str) -> dict[str, object]: """Simulate a flaky endpoint: fails twice, then succeeds.""" _attempts["n"] += 1 if _attempts["n"] < 3: raise ConnectionError(f"transient failure #{_attempts['n']}") return {"url": url, "status": 200, "attempt": _attempts["n"]}
class ApiClient: timeout = Bounded(min=0.1, max=30.0) # descriptor-validated fields retries = Bounded(min=0, max=10)
def __init__(self, timeout: float = 5.0, retries: int = 3) -> None: self.timeout = timeout # validated via Bounded.__set__ self.retries = retries
@timed # 2nd: times the whole retry loop @retry(times=3, backoff=0.02, exceptions=(ConnectionError,)) # 1st def fetch(self, url: str) -> dict[str, object]: with span("http.get") as s: # times just this block result = _flaky_get(url) result["block_ms"] = round(s.elapsed_ms, 2) return result
def main() -> None: client = ApiClient(timeout=5.0, retries=3) print("fetch result:", client.fetch("https://example.com/api"))
try: client.timeout = 99.0 # out of bounds → ValueError except ValueError as exc: print("rejected:", exc)
if __name__ == "__main__": main()When you run this, fetch fails twice (you’ll see two retry warnings), succeeds
on the third attempt, the span logs the inner block time, and timed logs the
total. Then the out-of-bounds timeout assignment is rejected by the descriptor.
tests/test_toolkit.py
Section titled “tests/test_toolkit.py”The tests are the spec. They assert the retry count and eventual success, that
timed preserves __name__/__doc__, that span records elapsed time, and that
Bounded accepts valid values and rejects out-of-range ones.
from __future__ import annotations
import pytest
from toolkit import Bounded, retry, span, timed
def test_retry_succeeds_after_failures() -> None: calls = {"n": 0}
@retry(times=3, backoff=0.0, exceptions=(ValueError,)) def flaky() -> str: calls["n"] += 1 if calls["n"] < 3: raise ValueError("boom") return "ok"
assert flaky() == "ok" assert calls["n"] == 3 # failed twice, succeeded on the third
def test_retry_reraises_after_exhausting() -> None: @retry(times=2, backoff=0.0, exceptions=(ValueError,)) def always_fails() -> None: raise ValueError("nope")
with pytest.raises(ValueError, match="nope"): always_fails()
def test_timed_preserves_metadata() -> None: @timed def add(a: int, b: int) -> int: """Add two numbers.""" return a + b
assert add.__name__ == "add" # functools.wraps did its job assert add.__doc__ == "Add two numbers." assert add(2, 3) == 5
def test_span_records_elapsed() -> None: with span("work") as s: sum(range(1000)) assert s.elapsed_ms >= 0.0
def test_bounded_validates() -> None: class Server: port = Bounded(min=1, max=65535)
s = Server() s.port = 8080 assert s.port == 8080 # reads back as a plain int
with pytest.raises(ValueError, match=r"port must be in \[1, 65535\]"): s.port = 99999Run it
Section titled “Run it”-
Scaffold the project and add dependencies (no
pip, no manual venv):Terminal window uv init --lib decorators-dslcd decorators-dsluv add structloguv add --dev pytest tyThen drop in the files above under
src/toolkit/andtests/. -
Run the demo — watch the retries, the span, and the descriptor rejection:
Terminal window uv run python -m toolkit.client -
Run the tests (retry counts, metadata, span timing, validation):
Terminal window uv run pytest -q -
Lint, format, and type-check with the Astral toolchain:
Terminal window uvx ruff format .uvx ruff check .uv run ty check # mypy works the same way if you prefer it
The most instructive test is test_retry_succeeds_after_failures: it proves the
factory’s three layers fire correctly — config captured once, wrapper invoked per
call, loop retrying until the third attempt lands.