Skip to content

Module 04: Function Wrappers and Transparent Decorators

Module Position

flowchart TD
  family["Python Programming"] --> program["Python Metaprogramming"]
  program --> module["Module 04: Function Wrappers and Transparent Decorators"]
  module --> lessons["Lesson pages and worked examples"]
  module --> checkpoints["Exercises and closing criteria"]
  module --> capstone["Related capstone evidence"]
flowchart TD
  purpose["Start with the module purpose and main questions"] --> lesson_map["Use the lesson map to choose reading order"]
  lesson_map --> study["Read the lessons and examples with one review question in mind"]
  study --> proof["Test the idea with exercises and capstone checkpoints"]
  proof --> close["Move on only when the closing criteria feel concrete"]

Read the first diagram as a placement map: this page sits between the course promise, the lesson pages listed below, and the capstone surfaces that pressure-test the module. Read the second diagram as the study route for this page, so the diagrams point you toward the Lesson map, Exercises, and Closing criteria instead of acting like decoration.

Keep These Pages Open

Use these support surfaces while reading so wrapper mechanics stay tied to ownership and proof instead of becoming a generic decorator enthusiasm page:

Carry this question into the module:

What changed at the callable boundary, and what must still remain visible for tools and reviewers to trust the wrapper?

Table of Contents

  1. Introduction
  2. Core 1: Nested Functions → Functions That Return Functions
  3. Core 2: @decorator Syntax Is Just func = decorator(func)
  4. Core 3: First Real Decorators: @timer, @once, @deprecated
  5. Core 4: functools.wraps and Writing Your Own Identity-Preserving Wrapper
  6. Synthesis: Controlled Transformation of First-Class Functions
  7. Capstone: @cache - Didactic Memoization from Scratch
  8. Power Ladder Checkpoint
  9. Glossary (Module 4)

Back to top


Introduction

This is the first module where metaprogramming stops observing runtime behavior and starts changing it. That is why decorators need a stricter teaching stance than introspection: the syntax is light, but the costs are easy to hide. A decorator can erase signatures, bury tracebacks, capture mutable state, or quietly change call semantics unless the wrapper stays honest about what it owns.

The purpose of this module is to make decorator mechanics explicit enough that you can see those costs before they become library folklore. You will build from closures and @decorator desugaring to real wrappers such as @timer, @once, and @deprecated, then close on functools.wraps as a correctness requirement rather than a style flourish.

Keep one question in view while reading:

Is this wrapper still a transparent function transformation, or has it become a small runtime policy engine?

That question matters because the capstone uses wrapper discipline as part of its public surface. Action decorators record history and preserve signatures at the same time. If the module does not teach that boundary clearly, the capstone starts looking magical instead of reviewable.

Risk boundary for this module:

  • thin wrappers are acceptable when they preserve metadata and advertise their behavior
  • stateful wrappers must be treated as policy surfaces with explicit limits
  • all examples here remain synchronous and single-process on purpose so the runtime cost stays visible

Why this module matters in the course

This is the first module where metaprogramming starts changing behavior instead of only observing it. That makes it the first place where runtime honesty can be lost fast: signatures disappear, stack traces get worse, metadata lies, and stateful wrappers start changing semantics without advertising the cost.

The point of this module is to make decorator mechanics feel explicit enough that those failure modes become visible immediately.

Questions this module should answer

By the end of the module, you should be able to answer:

  • What work happens once at definition time, and what work happens on each call?
  • Which wrappers preserve identity and which ones make tooling less trustworthy?
  • When is a decorator still a thin transformation and when has it become a small framework?
  • Why is functools.wraps part of correctness rather than just style?

If those answers are weak, later descriptor and metaclass material will be much harder to judge responsibly.

What to inspect in the capstone

Keep the capstone open while reading this module and inspect:

  • the action decorator implementation under capstone/src/incident_plugins/
  • tests that assert signature preservation and invocation recording
  • places where wrapper transparency matters to manifest generation and debugging

The capstone should make one point concrete here: a good decorator changes behavior without lying about what it wrapped.

Use this module when

  • a wrapper design feels clever but you cannot yet say what it changed at definition time or call time
  • you are not sure whether a decorator preserved the callable contract
  • you need a review standard for when a wrapper is still thin and when it has become hidden policy

Closing bar

Before moving on, you should be able to explain:

  • what work happens once when decoration occurs and what repeats on every call
  • why functools.wraps and preserved signatures are review requirements
  • how the capstone action wrapper stays narrower than a small framework
graph TD
  subgraph DefinitionTime["Definition time"]
    decorated["`@decorator` or `@factory(config)` on `func`"]
    build["`decorator(func)` builds `wrapper`"]
    wraps["`@functools.wraps(original_func)` copies metadata and sets `__wrapped__`"]
    closure["`wrapper` closes over `original_func` and decorator state"]
    rebind["Module name `func` now points to `wrapper`"]
    stack["Stacked decorators nest right-to-left: `func = d3(d2(d1(func)))`"]
    decorated --> build --> wraps --> closure --> rebind --> stack
  end

  subgraph CallTime["Call time"]
    caller["Caller executes `func(*args, **kwargs)`"]
    pre["Pre-call logic<br/>timers, cache lookup, warnings, validation"]
    original["Call closed-over `original_func(*args, **kwargs)`"]
    post["Post-call logic<br/>logging, cache store, counters, LRU updates"]
    result["Return transformed result to caller"]
    caller --> pre --> original --> post --> result
  end

Back to top


Core 1: Nested Functions → Functions That Return Functions

Canonical Definition

A decorator is fundamentally a callable that accepts a function (the decoratee) as its argument and returns a new callable with augmented behaviour. This is achieved via nested functions: the outer (decorator) function defines the inner (wrapper) function, which captures the decoratee and additional state via closures (Module 1, __closure__). The wrapper implements the enhanced __call__ protocol, delegating to the decoratee after or around custom logic. Formally, for a decorator d and function f, d(f) yields w such that w(*args, **kwargs) executes pre/post logic and invokes f(*args, **kwargs), preserving the original return value and call semantics (metadata such as the visible signature is only preserved if you additionally use tools like functools.wraps or explicit __signature__ assignment).

Deep Dive Explanation

Nested functions unlock decorators by encapsulating the decoratee and configuration within a closure, leveraging Python's lexical scoping for state isolation without global variables. This pattern—outer accepts callable, inner wraps invocation—mirrors higher-order functions but specialises for transformation, enabling patterns like logging or retries without subclassing. Historically, decorators were formalised in PEP 318 (2003, Python 2.4) as syntactic sugar atop this nesting, but the mechanics trace to closures in Python 2.2. Why nesting? It ensures the wrapper "remembers" the decoratee without shared module-level state, tying to Module 2's callable for runtime checks (e.g., if not callable(func): raise TypeError) and back to Module 1’s __closure__ cells as the concrete runtime vehicle for “remembering” func and any decorator state. Pedagogically, start here to demystify: the wrapper is a proxy, not a replacement—trace delegation to verify equivalence, then augment for transformation.

Examples

Basic nesting without augmentation (manual wrapper, no @ syntax yet):

def simple_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    # NOTE: must return the new wrapper; if we forget this, simple_decorator(greet)
    # would return None and replace the original function with a non-callable.
    return wrapper

def greet(name):
    return f"Hello, {name}!"

greet_wrapped = simple_decorator(greet)  # manual wrapping: what @simple_decorator automates
print(greet_wrapped("Alice"))  # Calling greet\nHello, Alice!
# Trace: wrapper captures func via closure (co_freevars=('func',)); delegates args/kwargs; prints pre-call.

With state (counter via closure):

def counter_decorator(func):
    count = 0  # Closed over by wrapper
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"Call {count} to {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@counter_decorator
def add(x, y):
    return x + y

print(add(2, 3))  # Call 1 to add\n5
print(add(4, 1))  # Call 2 to add\n5

@counter_decorator
def multiply(x, y):
    return x * y

print(multiply(2, 3))  # Call 1 to multiply\n6
print(multiply(4, 1))  # Call 2 to multiply\n4
# Trace: Each wrapper instance has independent count (separate closures); nonlocal mutates closure cell.

# These first examples deliberately omit functools.wraps so you can see the raw
# “outer defines inner, then return inner” pattern. In real code you almost
# always combine this structure with functools.wraps to preserve the original
# function’s name, docstring, and signature (developed fully in Core 4).

Advanced Notes and Pitfalls

  • Wrapper must accept *args, **kwargs to forward arbitrary signatures (Core 11 preview).
  • Pitfall: Forgetting nonlocal in Python 3 for mutable closure vars—assigns locally, breaking state.
  • Pitfall: Infinite recursion if the wrapper rebinds the original function name (e.g., func = wrapper inside wrapper) and calls the global name instead of the closed-over func. Always delegate to the closed-over func, not a rebinding-prone global.
  • Pitfall: Forgetting to return wrapper from the decorator body—at decoration time the function name will be rebound to None instead of a callable, and every call will fail immediately.
  • Pitfall: The wrapper must propagate exceptions unchanged unless the decorator explicitly documents new error behaviour; swallowing or rewriting errors silently makes debugging significantly harder.
  • Extension: Use Module 2 callable(func) for runtime checks.

Exercise

Implement log_calls(func) nesting a wrapper that logs args/kwargs pre-call (format: "func(args=..., kwargs=...)"). Test on variadic (e.g., sum(iterable)); verify closure isolation by applying to two functions—counters independent.

Back to top


Core 2: @decorator Syntax Is Just func = decorator(func)

Canonical Definition

The @decorator syntax (PEP 318) is syntactic sugar for function assignment: @d atop def f(): ... equates to f = d(f), where d (or the result of evaluating the decorator expression) is evaluated once at definition time, transforming f post-body execution. Multiple decorators stack right-to-left: if @d_outer is written above @d_inner, the result is f = d_outer(d_inner(f)); more generally, @d2 @d1 def f(): ... becomes f = d2(d1(f)). The decoratee f is the raw function object (Module 1), passed unbound; the result replaces f in the namespace.

Deep Dive Explanation

This syntax elevates nesting from verbose wrapping (f = d(f)) to declarative, reading as "apply d to f"—enhancing readability for chains like @timer @validate. Evaluation at definition ensures the transformation step itself runs once at definition/import time instead of on every call; the resulting wrapper still adds its own per-call overhead, as any additional logic would. Historically, proposed for clarity over manual calls, it integrates with Module 3's getsource (decorators excluded from co_firstlineno). Why sugar? Reduces boilerplate while preserving semantics: the transformed f retains invocability (Core 9). Pedagogically, equate @d def f(): pass to manual—trace id(f) pre/post to confirm replacement, then stack for composition.

Diagram: How @decorator and stacked decorators compose

Diagram: Decorator Application & Composition (@-syntax desugared)
==================================================================

1. Definition time - single decorator (executed once)
-----------------------------------------------------

Source code:                              Desugared exactly to:

    @d                                        def f(...):
    def f(...):                                   body                  # raw function object
        body

                                              f = d(f)                  # wrapper returned
                                                                        # name "f" now = wrapper


2. Definition time - stacked decorators (bottom -> top application)
-------------------------------------------------------------------

Source code:                              Step-by-step desugaring:

    @d3                                       def f(...):
    @d2                                           body                  # raw original f
    @d1
    def f(...):                               f = d1(f)                 # innermost first
        body                                  f = d2(f)                 # middle wrapper
                                              f = d3(f)                 # outermost last
                                                                        # final f = d3(d2(d1(f)))

Pipeline (what name "f" refers to after decoration):

original f --> [d1 wrapper] --> [d2 wrapper] --> [d3 wrapper]
                                                     ^
                                                     final binding of name "f"


3. Call time - execution order on every f(...)
-----------------------------------------------

caller calls:  f(args, kwargs)

               |
               v
   outermost wrapper (d3)   <-- applied last at definition time
               |
               v
      middle wrapper (d2)
               |
               v
   innermost wrapper (d1)   <-- applied first at definition time
               |
               v
   original function body executes
               |
               v
   result returns upward: d1 -> d2 -> d3 -> caller

In other words: decoration (f = d3(d2(d1(f)))) happens once at import/definition time; the chain of wrapper calls runs on every invocation of f.

Examples

Single decorator:

def uppercase(func):
    def wrapper(text):
        return func(text).upper()
    return wrapper

@uppercase
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))  # HELLO, ALICE!
# Trace: Equivalent to greet = uppercase(greet); wrapper delegates text, uppercases return.

Stacked (right-to-left):

def add_exclaim(func):
    def wrapper(text):
        return func(text) + "!"
    return wrapper

def trim(func):
    def wrapper(text):
        return func(text.strip())
    return wrapper

@add_exclaim
@trim
@uppercase
def message(text):
    return f"{text} world"

print(message("  hello  "))  # HELLO WORLD!
# Trace: message = add_exclaim(trim(uppercase(message))); innermost uppercase, then trim input, exclaim output.

Advanced Notes and Pitfalls

  • Decorators apply to def/async def/class; for lambdas, manual.
  • Pitfall: Decorator factories return decorator functions—@factory(arg) first evaluates factory(arg) once at definition time to obtain a decorator, then applies that decorator to the function (f = factory(arg)(f)).
  • Pitfall: Syntax errors in decorator propagate at definition (e.g., TypeError if non-callable).
  • Extension: Use Module 2 type(func) to classify decoratee pre-wrap.

Exercise

Apply stacked @uppercase @add_exclaim to a function returning lowercase; verify composition by printing intermediates (e.g., manual upper_then_exclaim). Induce pitfall: pass non-callable to @—trace error timing.

Back to top


Core 3: First Real Decorators: @timer, @once, @deprecated

Canonical Definition

Practical decorators transform via domain-specific logic: @timer measures execution (time.perf_counter); @once enforces idempotency (singleton execution per decorated function, ignoring subsequent arguments); @deprecated warns on use (warnings.warn, PEP 387). Each returns a wrapper delegating to the decoratee, injecting pre/post actions while preserving return value and raising exceptions unchanged.

Deep Dive Explanation

These exemplars illustrate decorators' utility: timing for profiling, once for lazy init, deprecation for API evolution—each exploiting *args, **kwargs forwarding and closure-based state. They build on Core 1 nesting, using Module 2 callable implicitly via delegation. Historically, @timer echoes profiling tools (cProfile), @once singleton-style initialization patterns, @deprecated maintenance best practices. We chose exactly these three because they match real production concerns:

  • @timer – measuring execution time of hot paths using time.perf_counter.
  • @once – one-time initialization with cached state on the wrapper (e.g. _once_result).
  • @deprecated – signalling obsolete APIs via warnings.warn at the call site.

Together they show that the same decorator skeleton can support timing, caching-like one-shot behavior, and API lifecycle signalling with only small changes to the wrapper body. Pedagogically, trace @timer: wrapper times delta, prints, delegates—extend to log files for realism.

Examples

@timer:

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.4f}s")
    return wrapper

@timer
def slow_compute(n):
    time.sleep(n / 10)
    return n ** 2

print(slow_compute(5))  # slow_compute took 0.5001s\n25
# Trace: perf_counter monotonic; delegates result; prints post, even on exceptions.

@once (idempotent, per-function execution):

import functools

def once(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not hasattr(wrapper, '_once_result'):
            wrapper._once_result = func(*args, **kwargs)
        return wrapper._once_result
    return wrapper

@once
def expensive_setup():
    print("Setting up once...")
    return "initialized"

print(expensive_setup())  # Setting up once...\ninitialized
print(expensive_setup(ignored_arg=42))  # initialized (ignores args after first call)
# Trace: Instance attr for state (per decorated function object, thread-unsafe); skips on second+ call.

@deprecated:

import warnings
import functools

def deprecated(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        warnings.warn(
            f"{func.__name__} is deprecated; use alternative.",
            DeprecationWarning,
            stacklevel=2,
        )
        return func(*args, **kwargs)
    return wrapper

@deprecated
def old_api(value):
    return value * 2

print(old_api(3))  # /path:1: DeprecationWarning: old_api is deprecated; use alternative.\n6
# Trace: warn at caller level; stack shows user frame; delegates unchanged.

Notice that @timer, @once, and @deprecated now all use functools.wraps on their inner wrapper to keep the original function’s name, docstring, and signature intact. In the earlier “bare” examples in Core 1 we skipped this on purpose so you could see the core wrapping pattern; Core 4 will dig into functools.wraps and identity preservation in depth.

Advanced Notes and Pitfalls

  • @timer: Use timeit for repeats; contextlib for RAII-style. Our implementation uses try/finally, so timing reports even on exceptions.
  • @once: Thread-unsafe (use a lock for concurrency); semantics are per-decorated-function execution, not per-argument memoization:
  • if the first call raises, no result is cached and later calls will re-run the function;
  • after the first successful call, all subsequent calls ignore their arguments entirely.
  • @deprecated: Version in message; stacklevel=2 for caller frame. Note: Users can silence DeprecationWarning via the warnings filter; if the deprecation is critical, you may choose to raise instead in a future version.
  • Pitfall: If try/finally is omitted, exceptions bypass post-logic—always include it for robustness.
  • Extension: Combine @once @timer—order matters: @once @timer skips timer after first call (once caches pre-timer); @timer @once runs timer on every call (once inside timer).

Exercise

Implement @retry(times=3): wraps, catches Exception, retries up to times (exponential backoff optional). Test on failing func; verify deprecation in @deprecated @retry.

Back to top


Core 4: functools.wraps and Writing Your Own Identity-Preserving Wrapper

Canonical Definition

functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES) is a decorator factory yielding a wrapper that copies attributes from wrapped (e.g., __name__, __doc__, __module__) to preserve identity for introspection (Module 1). WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__'); WRAPPER_UPDATES = ('__dict__', '__wrapped__'). Custom wrappers replicate via setattr(wrapper, attr, getattr(wrapped, attr, None)). This is the formal tool that makes the forward references in Core 1 and Core 3 concrete.

Deep Dive Explanation

Bare wrappers lose metadata (wrapper.__name__ == 'wrapper'), confounding tools like help or inspect.signature (Module 3)—@wraps restores via assignment, setting __wrapped__ for unwrapping (e.g., inspect.unwrap). Historically, functools (2.5) standardised amid decorator proliferation. Why preserve? Maintains debuggability: tracebacks show original names, signatures bind correctly. Ties to Module 1 __qualname__ (nesting) and Module 2 vars (inspect state). Pedagogically, contrast bare vs wrapped: inspect.signature fails on bare—use @wraps universally for professionalism.

Diagram: functools.wraps – Preserving Function Identity
=================================================================

1. Bare decorator – identity destroyed
--------------------------------------

    def decorator(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper

    @decorator
    def original(x: int, y: int) -> int:
        """Add two numbers."""
        return x + y

Result → wrapper overwrites everything:

```mermaid
graph TD
  broken["Decorated function without `@wraps`"]
  broken --> name["`__name__ = \"wrapper\"`"]
  broken --> qualname["`__qualname__ = \"wrapper\"`"]
  broken --> doc["`__doc__ = None`"]
  broken --> annotations["`__annotations__ = {}`"]
  broken --> wrapped["`__wrapped__` missing"]

Real-world breakage • help(), Sphinx, docstrings → empty or wrong • inspect.signature → (args, *kwargs) • Tracebacks, debuggers, IDEs → "wrapper" • Type checkers, pydantic, serializers → lost hints

2. With @functools.wraps – identity fully restored

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Result → wrapper is indistinguishable from original:

graph TD
  preserved["Decorated function with `@functools.wraps`"]
  preserved --> name["`__name__ = \"original\"`"]
  preserved --> qualname["`__qualname__ = \"original\"`"]
  preserved --> doc["`__doc__` preserved"]
  preserved --> annotations["`__annotations__` preserved"]
  preserved --> module["`__module__` preserved"]
  preserved --> dict["`__dict__` mirrors original"]
  preserved --> wrapped["`__wrapped__ = original func`"]

3. What @functools.wraps actually does

Copies directly: module, name, qualname, doc, annotations

Merges: wrapper.dict.update(func.dict)

Adds: wrapper.wrapped = func

4. Introspection now works perfectly

• inspect.signature → (x: int, y: int) -> int • help(), Sphinx, IDEs → correct name/doc/annotations • Tracebacks/debuggers → real function name • inspect.unwrap → original undecorated function

Non-negotiable rule for every decorator you write

@functools.wraps(func)   # always on the innermost wrapper

Omitting it silently breaks every tool that depends on correct metadata. Never publish a decorator without it.

### Examples

Bare vs preserved:

```python
import functools
import inspect

def bare_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def preserved_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bare_decorator
def bare_func(x):
    """Bare doc."""
    return x

@preserved_decorator
def preserved_func(x):
    """Preserved doc."""
    return x

print(bare_func.__name__)  # wrapper
print(preserved_func.__name__)  # preserved_func
print(inspect.signature(bare_func))  # (*args, **kwargs) — lost
print(inspect.signature(preserved_func))  # (x) — retained
print(preserved_func.__doc__)  # Preserved doc.
# Trace: wraps copies at wrap time; __wrapped__ enables inspect.unwrap(bare_func) → original (3.4+).

Custom preservation in a decorator:

def custom_wraps(wrapped):
    def decorator(inner):
        inner.__name__ = wrapped.__name__
        inner.__doc__ = wrapped.__doc__
        inner.__module__ = wrapped.__module__
        inner.__wrapped__ = wrapped
        return inner
    return decorator

def my_decorator(func):
    @custom_wraps(func)
    def inner(*args, **kwargs):
        return func(*args, **kwargs)
    return inner

@my_decorator
def example(x):
    return x * 2

print(example.__name__)  # example — copied
# Trace: custom_wraps as factory; used in decorator for reusable pattern.

Advanced Notes and Pitfalls

  • __wrapped__ chains for stacked (inspect.unwrap recurses).
  • Pitfall: wraps copies at decoration—mutations post-wrap (e.g., __doc__=) not reflected; re-decorate if needed.
  • Pitfall: C functions lack some attrs—wraps skips gracefully.
  • Extension: For advanced cases (e.g., decorators that change the calling convention), you can override the visible signature explicitly via inner.__signature__ = inspect.signature(wrapped) so that Module 3 tooling built on inspect.signature still reports the callable correctly.

Exercise

Refactor @timer with @wraps; verify help(timer_func) shows original doc/name. Implement custom my_wraps copying extras (__annotations__); test on annotated func—trace signature retention.

Back to top


Synthesis: Controlled Transformation of First-Class Functions

Cores 16–19 take the static object model and introspection machinery from Modules 1–3 and make it transforming:

  • Core 1 (nested functions) shows how closures let you build wrappers that remember the original function and any decorator state.
  • Core 2 (@decorator syntax) turns explicit f = d(f) wiring into declarative annotations, stacking multiple transformations predictably.
  • Core 3 (e.g. @timer, @once, @deprecated) demonstrates real-world behaviours—timing, one-shot initialisation, deprecation signalling—implemented as disciplined wrappers.
  • Core 4 (functools.wraps) closes the loop with Module 3: it preserves names, docs, annotations, and the unwrapped function so that inspect still sees the logical callable, not just the outermost wrapper.

The pattern is now clear: Module 1 gave you first-class callables; Module 2 taught you how to inspect and classify them; Module 3 gave you structured views (Signature, provenance, frames); Module 4 uses all of that to build decorators that change behaviour while keeping identities and introspection surfaces honest. The capstone @cache decorator then acts as a didactic stress test: it is useful but intentionally non-production, forcing you to confront the semantic and concurrency pitfalls that appear the moment a decorator stops being “just logging” and starts caching or controlling side effects.

Back to top


Capstone: @cache - Didactic Memoization from Scratch

Warning (didactic only, not production)
The @cache implementation below is an educational re-implementation of functools.lru_cache. It is single-threaded, not safe under concurrency, and requires all arguments to be hashable in its canonical form. For production code, always prefer functools.lru_cache or a well-tested caching library.

Canonical Implementation

import functools
from typing import Any, Callable, Dict, Hashable, Optional

def cache(maxsize: Optional[int] = 128) -> Callable:
    """Factory: returns decorator with LRU cache of maxsize (None for unlimited; 0 disables caching)."""
    if maxsize is not None and maxsize < 0:
        raise ValueError("maxsize must be >= 0 or None")

    if maxsize == 0:
        def decorator_no_cache(func: Callable) -> Callable:
            @functools.wraps(func)
            def wrapper(*args, **kwargs) -> Any:
                return func(*args, **kwargs)

            def cache_clear() -> None:
                """No-op for disabled cache (maxsize=0)."""
                pass

            wrapper.cache_clear = cache_clear
            return wrapper
        return decorator_no_cache

    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs) -> Any:
            # Key: (args, frozenset(kwargs.items())) — requires all components to be hashable.
            try:
                key: Hashable = (args, frozenset(kwargs.items()))
            except TypeError as e:
                raise TypeError(
                    "cache() only supports hashable arguments in its canonical form; "
                    "for unhashable arguments, see the 'unhashable extension' variant "
                    "later in this core."
                ) from e

            if key in wrapper._cache:
                # LRU: move to end (naive FIFO-based LRU; O(n) due to list.pop(0))
                wrapper._cache_order.remove(key)
                wrapper._cache_order.append(key)
                return wrapper._cache[key]

            # Miss: compute
            result = func(*args, **kwargs)

            # Evict oldest key if at capacity (naive FIFO-based LRU; O(n) due to list.pop(0))
            if maxsize is not None and len(wrapper._cache_order) >= maxsize:
                oldest = wrapper._cache_order.pop(0)
                del wrapper._cache[oldest]

            # Store
            wrapper._cache[key] = result
            wrapper._cache_order.append(key)
            return result

        # Cache state: instance attrs for debuggability (inspectable/clearable)
        wrapper._cache: Dict[Hashable, Any] = {}
        wrapper._cache_order: list = []  # For LRU eviction

        def cache_clear():
            """Clear cache."""
            wrapper._cache.clear()
            wrapper._cache_order.clear()

        wrapper.cache_clear = cache_clear  # Expose method
        return wrapper
    return decorator

# Usage example
@cache(maxsize=3)
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

print(fib(10))  # 55 (caches intermediates)
print(fib(5))   # 5 (hit)
fib.cache_clear()
print(fib(3))   # 2 (recaches post-clear)

Optional Extension: Unsafe Unhashable Support (Didactic Only)

# Do NOT use in real code – for teaching purposes only
def _make_unhashable_key(args, kwargs):
    # Order- and repr-dependent; for small demos only.
    return f"{args!r}|{dict(kwargs)!r}"

# Inside wrapper, replace the key block with:
# try:
#     key = (args, frozenset(kwargs.items()))
# except TypeError:
#     key = _make_unhashable_key(args, kwargs)

Deep Dive Explanation

@cache operationalises memoization: factory parameterises size (0 disables, None unlimited), decorator wraps with @wraps for metadata, state via instance attrs for debuggability (e.g., inspect wrapper._cache). Key hashes args/kwargs exactly as functools.lru_cache does in its canonical form. cache_clear exposed as method. Ties to Module 1 __call__ (delegation), Core 1 closure (state via attrs), Core 3 logic (miss/hit). Pedagogically, trace fib: recursive calls hit cache, reducing tree. For production, replace list with OrderedDict (O(1) move_to_end) and use functools.lru_cache.

Examples

LRU eviction (finite):

@cache(maxsize=2)
def expensive(a, b):
    print(f"Computing {a}+{b}")
    return a + b

expensive(1, 2)  # Computing 1+2\n3
expensive(3, 4)  # Computing 3+4\n7 (cache full)
expensive(1, 2)  # 3 (hit; moves to end)
expensive(5, 6)  # Computing 5+6\n11 (evicts 3+4)

Unlimited cache:

@cache(maxsize=None)
def unlimited_fib(n: int) -> int:
    if n < 2:
        return n
    return unlimited_fib(n-1) + unlimited_fib(n-2)

print(unlimited_fib(10))  # 55 (grows unbounded)

Disabled cache:

@cache(maxsize=0)
def no_cache_fib(n: int) -> int:
    if n < 2:
        return n
    return no_cache_fib(n-1) + no_cache_fib(n-2)

print(no_cache_fib(5))  # 5 (no caching)
print(no_cache_fib(5))  # 5 (recomputes)
no_cache_fib.cache_clear()  # No-op

Advanced Notes and Pitfalls

  • Canonical version: only supports hashable arguments; this mirrors functools.lru_cache and avoids the semantic traps of serialising mutables into keys. From a static-typing perspective, this also keeps the callable’s apparent type simple (purely value-based caching on positional/keyword arguments); more sophisticated, typed caches belong in the later, typing-aware decorator module.
  • Optional extension: serialising unhashable arguments into keys (e.g. via str or pickle) is didactic but dangerous:
  • order- and repr-dependent,
  • prone to collisions across runs or processes,
  • interacts badly with mutation after the call. Use only in tightly controlled demos, never in production.
  • Pitfall: Thread-safety absent—concurrent access can corrupt the cache or LRU order. Production caches must protect their internal state with locks or use thread-safe primitives.
  • Pitfall: O(n) remove/pop(0)—didactic; use collections.OrderedDict for O(1).
  • CPython: Hash collisions rare.

Exercise

Extend @cache with typed=False param: if True, key includes type(args)—prevents int/str mix (e.g., fib(1) != fib("1")). Test with fib(30) with/without—verify speedup.

You have completed Module 4.

Power Ladder Checkpoint

  • Stay with plain functions when the behavior can be named directly at the call site or composed without hidden wrapping.
  • Use a decorator when the concern is truly per-call or per-definition policy: timing, tracing, deprecation, caching, or instrumentation with preserved metadata.
  • Do not escalate to descriptors when the rule is about one callable rather than attribute access across many instances.
  • Do not escalate to metaclasses when the behavior can be attached after function definition without changing class creation semantics.
  • Before shipping a decorator, compare it against the Runtime Power Ladder and write down which lower rung almost worked.

Back to top


Glossary (Module 4)

Term Definition
Decorator A callable that takes a function (or class) and returns a new callable with modified/augmented behavior.
@decorator syntax Syntactic sugar for rebinding: @d above def f means f = d(f) executed at definition/import time.
Stacked decorators Multiple @ lines compose right-to-left: @d3 @d2 @d1 becomes f = d3(d2(d1(f))); call-time executes wrappers outer → inner.
Decoration time One-time transformation step when the function is defined (typically import time): wrappers are built and the name is rebound.
Call time Per-invocation execution of the wrapper chain, running pre/post logic around the original function call.
Decoratee The original function being wrapped; captured by the wrapper (usually via closure) and invoked by delegation.
Wrapper function The returned callable that implements the new behavior: accepts *args, **kwargs, runs extra logic, then delegates to the decoratee.
Nested function Inner function defined inside another function; the mechanical basis of decorators because it can capture the decoratee and state.
Closure Captured environment that lets the wrapper “remember” the decoratee and any decorator state (counters, caches, config).
Closure state Mutable state stored in a closure cell (via nonlocal) or on the wrapper object (attributes), used by stateful decorators.
nonlocal Keyword enabling mutation of a captured outer-scope variable inside the wrapper; required for closure-held counters, flags, etc.
Decorator factory A callable that returns a decorator, used as @factory(config); evaluated once at definition time, then applied to the function.
Forwarding wrapper Wrapper that accepts arbitrary arguments and forwards them unchanged to the decoratee; typically def wrapper(*args, **kwargs).
Semantic transparency Property of a “thin” decorator that preserves behavior and error model aside from its declared concern (e.g., timing/logging).
Stateful decorator Decorator that changes semantics by storing state across calls (cache, once, retries, rate limits); should be treated as “small framework.”
functools.wraps Standard wrapper helper that copies identity metadata to the wrapper and sets __wrapped__ to the original function.
Identity metadata Function attributes needed for debuggability and tooling: __name__, __qualname__, __doc__, __module__, __annotations__, and __dict__.
__wrapped__ Attribute pointing from wrapper to decoratee; enables inspect.unwrap and signature recovery through wrapper chains.
Introspection friendliness Preserving names/docs/signatures so tools (help, Sphinx, inspect.signature) report the logical callable rather than wrapper(*args, **kwargs).
Exception transparency Policy where the wrapper lets exceptions propagate unchanged unless the decorator explicitly documents altered error behavior.
@timer decorator Wrapper that measures duration of each call (typically with time.perf_counter) and reports it; should use try/finally to time even on errors.
@once decorator Idempotent decorator that runs the function at most once and returns the first successful result for all later calls, ignoring later arguments.
@deprecated decorator Wrapper that emits a warning on use (commonly warnings.warn(..., DeprecationWarning, stacklevel=2)) while delegating behavior unchanged.
Stacklevel Warnings parameter controlling which caller frame is reported; used to blame the user call site rather than the decorator wrapper.
Memoization Caching function results keyed by arguments so repeated calls return cached values rather than recomputing.
Cache key canonicalization Converting (args, kwargs) into a hashable key (e.g., (args, frozenset(kwargs.items()))), requiring hashable components.
LRU cache discipline Least-Recently-Used eviction policy: when capacity is reached, discard the entry unused for the longest time.
cache_clear hook Explicit method exposed by a cache wrapper to reset internal state deterministically (important for tests and long-running processes).
Didactic cache Educational cache implementation that is intentionally incomplete (e.g., single-threaded, O(n) eviction); contrasted with functools.lru_cache.
Concurrency caveat Stateful decorators without locks can corrupt state under multi-threading/async; correctness requires explicit concurrency design.
Decorator order sensitivity The order of stacked decorators changes semantics (e.g., @once @timer vs @timer @once determines whether timing runs once or always).

Proceed to Module 5: Decorators Level 2 – Real-World & Typing-Aware.

Back to top

Directory glossary

Use Glossary when you want the recurring language in this module kept stable while you move between lessons, exercises, and capstone checkpoints.