Skip to main content

Python Decorators — Wrapping Callables at: Practice Problems & Exercises

Practice: Decorators — Wrapping Callables at Engineering Depth

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Simple Logging DecoratorEasy
decoratorwrapperbasics

Write a log_calls decorator that prints before and after calling the wrapped function. Use func.__name__ to get the function name.

Python
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Done {func.__name__}")
        return result
    return wrapper

@log_calls
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
Solution
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper

@log_calls
def greet(name):
print(f"Hello, {name}!")

greet("Alice")

Explanation: @log_calls is syntactic sugar for greet = log_calls(greet). After decoration, greet refers to wrapper. When called, wrapper runs the before/after logic and delegates to the original function via the closure variable func. *args, **kwargs makes the wrapper signature-agnostic — it forwards all arguments correctly.

def log_calls(func):
  # Wrap func so it prints "Calling <name>" before
  # and "Done <name>" after each call.
  pass

@log_calls
def greet(name):
  print(f"Hello, {name}!")

greet("Alice")
Expected Output
Calling greet\nHello, Alice!\nDone greet
Hints

Hint 1: Define an inner wrapper(*args, **kwargs) function inside log_calls.

Hint 2: Print before calling func(*args, **kwargs), print after, then return the result.

Hint 3: Return the wrapper function (not its return value).


#2Preserve Metadata with functools.wrapsEasy
decoratorfunctools.wrapsmetadata

Add @functools.wraps(func) to the log_calls decorator from problem 1 so that the wrapped function retains its original metadata.

Python
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Done {func.__name__}")
        return result
    return wrapper

@log_calls
def add(a, b):
    """Return the sum of a and b."""
    return a + b

print(add.__name__)
print(add.__doc__)
print(add(2, 3))
Solution
import functools

def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper

@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b

print(add.__name__) # add
print(add.__doc__) # Return the sum of a and b.
print(add(2, 3)) # 5

Explanation: Without @functools.wraps, add.__name__ would be 'wrapper' and add.__doc__ would be None. This breaks help(), inspect.signature(), pytest introspection, and many frameworks. wraps also sets add.__wrapped__ = func, allowing tools to unwrap the decorator chain and access the original function.

import functools

def log_calls(func):
  # Same as problem 1, but preserve __name__, __doc__, __module__
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      print(f"Calling {func.__name__}")
      result = func(*args, **kwargs)
      print(f"Done {func.__name__}")
      return result
  return wrapper

@log_calls
def add(a, b):
  """Return the sum of a and b."""
  return a + b

print(add.__name__)   # add  (NOT wrapper)
print(add.__doc__)    # Return the sum of a and b.
print(add(2, 3))
Expected Output
add\nReturn the sum of a and b.\nCalling add\nDone add\n5
Hints

Hint 1: @functools.wraps(func) copies __name__, __doc__, __module__, __qualname__, __annotations__, and __wrapped__ from func to wrapper.

Hint 2: Apply @functools.wraps(func) to the inner wrapper function.

Hint 3: Without it, add.__name__ would be "wrapper" — confusing in tracebacks and help().


#3Timing DecoratorEasy
decoratortimeperformance

Implement a timer decorator that measures and prints execution time in milliseconds. Exact timing output will vary — the result value is fixed.

Python
import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed_ms = (time.perf_counter() - start) * 1000
        print(f"{func.__name__} took {elapsed_ms:.2f} ms")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

result = slow_sum(1_000_000)
print(f"Result: {result}")
Solution
import functools
import time

def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{func.__name__} took {elapsed_ms:.2f} ms")
return result
return wrapper

@timer
def slow_sum(n):
return sum(range(n))

result = slow_sum(1_000_000)
print(f"Result: {result}")

Explanation: time.perf_counter() is the highest-resolution timer available — more precise than time.time() which only gives wall-clock seconds. Always measure both sides of the function call, not the whole decorator body. Production timing decorators often accumulate stats (min, max, p99) rather than printing each call.

import functools
import time

def timer(func):
  # Print how long func takes to run (in milliseconds)
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      pass  # implement
  return wrapper

@timer
def slow_sum(n):
  return sum(range(n))

result = slow_sum(1_000_000)
print(f"Result: {result}")
Expected Output
slow_sum took 12.34 ms\nResult: 499999500000
Hints

Hint 1: Record start = time.perf_counter() before the call.

Hint 2: Record end = time.perf_counter() after.

Hint 3: Elapsed milliseconds: (end - start) * 1000

Hint 4: Print the timing, then return the result.


#4Validate Argument TypesEasy
decoratorvalidationisinstance

Implement require_int which raises TypeError if any positional argument to the wrapped function is not an int.

Python
import functools

def require_int(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if not isinstance(arg, int):
                raise TypeError(f"Argument {arg!r} is not an int")
        return func(*args, **kwargs)
    return wrapper

@require_int
def multiply(a, b):
    return a * b

print(multiply(3, 4))

try:
    multiply(3, '4')
except TypeError as e:
    print(e)
Solution
import functools

def require_int(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if not isinstance(arg, int):
raise TypeError(f"Argument {arg!r} is not an int")
return func(*args, **kwargs)
return wrapper

@require_int
def multiply(a, b):
return a * b

print(multiply(3, 4)) # 12

try:
multiply(3, '4')
except TypeError as e:
print(e) # Argument '4' is not an int

Explanation: Using isinstance rather than type(arg) == int correctly handles subclasses (e.g., bool is a subclass of intisinstance(True, int) is True). The !r format spec in the f-string calls repr(), adding quotes around string values in the error message, which helps users distinguish between 42 (an int) and '42' (a string).

import functools

def require_int(func):
  # Raise TypeError if any positional argument is not an int
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      pass  # implement
  return wrapper

@require_int
def multiply(a, b):
  return a * b

print(multiply(3, 4))   # 12

try:
  multiply(3, '4')
except TypeError as e:
  print(e)            # Argument '4' is not an int
Expected Output
12\nArgument '4' is not an int
Hints

Hint 1: Iterate over args and check isinstance(arg, int).

Hint 2: If any fails, raise TypeError with a helpful message including the bad value.

Hint 3: Use repr(arg) in the message so strings show their quotes: repr('4') == "'4'"


Medium

#5Decorator Factory — Retry with Exponential BackoffMedium
decorator factoryretryexponential backoffthree-level nesting

Implement a retry(max_attempts, delay, exceptions) decorator factory. The decorator should retry the wrapped function up to max_attempts times, waiting delay seconds between retries, re-raising on final failure.

Python
import functools
import time

def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise last_exc
        return wrapper
    return decorator

call_count = 0

@retry(max_attempts=3, delay=0, exceptions=(ValueError,))
def flaky():
    global call_count
    call_count += 1
    if call_count < 3:
        raise ValueError("not ready")
    return "success"

print(flaky())
print(call_count)
Solution
import functools
import time

def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
if attempt < max_attempts:
time.sleep(delay)
raise last_exc
return wrapper
return decorator

Explanation: The three-level nesting is the canonical decorator factory pattern: the outer function (retry) accepts configuration and returns decorator; decorator accepts the function and returns wrapper; wrapper implements the runtime logic. The exceptions tuple is used directly in except exceptions as e — Python accepts a tuple of exception types there. Re-raising last_exc (not using bare raise) preserves the correct traceback.

import functools
import time

def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
  # Decorator factory: returns a decorator that retries func up to max_attempts times.
  # Wait delay seconds between retries (no actual sleep needed in tests — use delay=0).
  # Re-raise the last exception if all attempts fail.
  def decorator(func):
      @functools.wraps(func)
      def wrapper(*args, **kwargs):
          pass  # implement
      return wrapper
  return decorator

call_count = 0

@retry(max_attempts=3, delay=0, exceptions=(ValueError,))
def flaky():
  global call_count
  call_count += 1
  if call_count < 3:
      raise ValueError("not ready")
  return "success"

print(flaky())       # success
print(call_count)    # 3
Expected Output
success\n3
Hints

Hint 1: Three levels: retry() returns decorator, decorator wraps func, wrapper runs the retry loop.

Hint 2: Loop from 1 to max_attempts. On each attempt call func(*args, **kwargs) in a try/except.

Hint 3: If exception matches exceptions tuple and attempts remain, wait and continue.

Hint 4: If last attempt fails, re-raise. If successful, return the result.


#6Memoization DecoratorMedium
decoratormemoizationcachefunctools

Implement a memoize decorator that caches results keyed by function arguments. Show that each unique argument is computed exactly once.

Python
import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key not in cache:
            cache[key] = func(*args, **kwargs)
        return cache[key]
    return wrapper

call_count = 0

@memoize
def fib(n):
    global call_count
    call_count += 1
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))
print(call_count)
Solution
import functools

def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper

call_count = 0

@memoize
def fib(n):
global call_count
call_count += 1
if n < 2:
return n
return fib(n - 1) + fib(n - 2)

print(fib(10)) # 55
print(call_count) # 11

Explanation: The cache is a dict closed over by wrapper. tuple(sorted(kwargs.items())) makes keyword arguments hashable and order-independent. fib(10) triggers 11 unique calls (fib(0) through fib(10)); all subsequent calls for already-seen values hit the cache. functools.lru_cache(maxsize=None) is the production equivalent — backed by a C implementation that is ~10x faster and handles cache eviction.

import functools

def memoize(func):
  # Cache results keyed by (args, frozen kwargs).
  # Must work for any hashable arguments.
  cache = {}
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      pass  # implement
  return wrapper

call_count = 0

@memoize
def fib(n):
  global call_count
  call_count += 1
  if n < 2:
      return n
  return fib(n - 1) + fib(n - 2)

print(fib(10))        # 55
print(call_count)     # 11 (each unique n computed once)
Expected Output
55\n11
Hints

Hint 1: Cache key: (args, tuple(sorted(kwargs.items())))

Hint 2: If key is in cache, return cache[key]. Otherwise compute, store, return.

Hint 3: functools.lru_cache does this more efficiently — but implement it manually here.


#7Class-Based Decorator with StateMedium
class-based decorator__call____init__stateful

Implement CallCounter as a class-based decorator that tracks invocation count. The count should be accessible as decorated_func.call_count.

Python
import functools

class CallCounter:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self._func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        return self._func(*args, **kwargs)

@CallCounter
def add(a, b):
    return a + b

print(add(1, 2))
print(add(3, 4))
print(add.call_count)
Solution
import functools

class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
self.call_count = 0

def __call__(self, *args, **kwargs):
self.call_count += 1
return self._func(*args, **kwargs)

@CallCounter
def add(a, b):
return a + b

print(add(1, 2)) # 3
print(add(3, 4)) # 7
print(add.call_count) # 2

Explanation: Class-based decorators are preferred when the decorator needs persistent mutable state (counters, caches, connection pools). __init__ receives the function (replacing the outer factory call) and __call__ replaces the wrapper. functools.update_wrapper(self, func) is the class-based equivalent of @functools.wraps — it copies __name__, __doc__, etc. onto the instance.

import functools

class CallCounter:
  """
  A class-based decorator that tracks how many times
  the wrapped function has been called.
  Access the count via func.call_count.
  """
  def __init__(self, func):
      pass

  def __call__(self, *args, **kwargs):
      pass

@CallCounter
def add(a, b):
  return a + b

print(add(1, 2))        # 3
print(add(3, 4))        # 7
print(add.call_count)   # 2
Expected Output
3\n7\n2
Hints

Hint 1: __init__ receives the function — store it and initialise call_count = 0.

Hint 2: __call__ is invoked when the decorated function is called — increment and delegate.

Hint 3: Apply functools.update_wrapper(self, func) in __init__ to copy metadata.


#8Stacking Decorators — Order MattersMedium
stacking decoratorsordercomposition

Apply the three decorators in the correct order so the output is <b><i><u>Hello</u></i></b>. Confirm you understand the bottom-up application order.

Python
import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

def underline(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<u>{func(*args, **kwargs)}</u>"
    return wrapper

@bold
@italic
@underline
def greet():
    return "Hello"

print(greet())
Solution
@bold
@italic
@underline
def greet():
return "Hello"

print(greet()) # <b><i><u>Hello</u></i></b>

Explanation: Python applies stacked decorators from bottom to top: greet = bold(italic(underline(greet))). At call time, execution flows top to bottom through the wrapper chain: bold.wrapper calls italic.wrapper, which calls underline.wrapper, which calls the original greet(). The return value bubbles back up: "Hello""<u>Hello</u>""<i><u>Hello</u></i>""<b><i><u>Hello</u></i></b>".

import functools

def bold(func):
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      return f"<b>{func(*args, **kwargs)}</b>"
  return wrapper

def italic(func):
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      return f"<i>{func(*args, **kwargs)}</i>"
  return wrapper

def underline(func):
  @functools.wraps(func)
  def wrapper(*args, **kwargs):
      return f"<u>{func(*args, **kwargs)}</u>"
  return wrapper

# Apply all three: result should be <b><i><u>Hello</u></i></b>
@bold
@italic
@underline
def greet():
  return "Hello"

print(greet())
Expected Output
<b><i><u>Hello</u></i></b>
Hints

Hint 1: Decorators are applied bottom-up: the one closest to the function runs first on the value.

Hint 2: @bold @italic @underline means bold(italic(underline(greet))).

Hint 3: underline wraps "Hello" first, then italic wraps that, then bold wraps the result.


Hard

#9Rate Limiter DecoratorHard
decorator factoryrate limitertimestateful

Implement a rate_limit(calls_per_second) decorator factory. It should raise RuntimeError if the function is called too quickly, showing the required wait time.

Python
import functools
import time

def rate_limit(calls_per_second):
    min_interval = 1.0 / calls_per_second

    def decorator(func):
        last_called = [0.0]

        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.perf_counter()
            elapsed = now - last_called[0]
            if elapsed < min_interval:
                remaining = min_interval - elapsed
                raise RuntimeError(
                    f"Rate limit exceeded: must wait {remaining:.2f}s between calls"
                )
            last_called[0] = now
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limit(calls_per_second=2)
def fetch(url):
    return f"fetched {url}"

print(fetch("https://api.example.com/1"))
time.sleep(0.6)
print(fetch("https://api.example.com/2"))

try:
    fetch("https://api.example.com/3")
except RuntimeError as e:
    print(e)
Solution
import functools
import time

def rate_limit(calls_per_second):
min_interval = 1.0 / calls_per_second

def decorator(func):
last_called = [0.0]

@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.perf_counter()
elapsed = now - last_called[0]
if elapsed < min_interval:
remaining = min_interval - elapsed
raise RuntimeError(
f"Rate limit exceeded: must wait {remaining:.2f}s between calls"
)
last_called[0] = now
return func(*args, **kwargs)
return wrapper
return decorator

Explanation: The last_called = [0.0] trick: Python closures close over the variable name, not the value. A plain last_called = 0.0 float cannot be mutated inside wrapper (assignment would create a new local). Wrapping it in a list allows last_called[0] = now to mutate the existing object. Alternatively, Python 3 allows nonlocal last_called with a float. Production rate limiters use a token bucket algorithm for burst support.

import functools
import time

def rate_limit(calls_per_second):
  # Decorator factory: raise RuntimeError if the function is called
  # more than calls_per_second times per second.
  # Track the last call timestamp in the wrapper's closure.
  min_interval = 1.0 / calls_per_second

  def decorator(func):
      last_called = [0.0]  # use a list so we can mutate inside closure
      @functools.wraps(func)
      def wrapper(*args, **kwargs):
          pass  # implement
      return wrapper
  return decorator

@rate_limit(calls_per_second=2)
def fetch(url):
  return f"fetched {url}"

import time
print(fetch("https://api.example.com/1"))   # ok
time.sleep(0.6)                             # wait > 0.5s
print(fetch("https://api.example.com/2"))   # ok

try:
  fetch("https://api.example.com/3")      # too fast
except RuntimeError as e:
  print(e)
Expected Output
fetched https://api.example.com/1\nfetched https://api.example.com/2\nRate limit exceeded: must wait 0.50s between calls
Hints

Hint 1: min_interval = 1.0 / calls_per_second is the required gap in seconds.

Hint 2: last_called is a list with one element so it can be mutated inside the nested closure.

Hint 3: On each call: elapsed = time.perf_counter() - last_called[0].

Hint 4: If elapsed < min_interval, raise RuntimeError with a message showing the remaining wait.

Hint 5: Otherwise update last_called[0] and proceed.


#10Decorator that Works With and Without ArgumentsHard
decorator factoryoptional argumentsfunctools.wraps

Run the provided code and confirm you understand how the optional-argument decorator pattern works. The implementation is already correct — trace through both call paths.

Python
import functools

def repeat(_func=None, *, times=2):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper

    if _func is not None:
        return decorator(_func)
    return decorator

@repeat
def say_hi():
    print("hi")

@repeat(times=3)
def say_hello():
    print("hello")

say_hi()
say_hello()
Solution
import functools

def repeat(_func=None, *, times=2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper

if _func is not None:
return decorator(_func)
return decorator

Explanation: There are two call paths. @repeat (no parentheses): Python passes say_hi as _func; the check if _func is not None is True, so decorator(_func) is called immediately and the result (the wrapper) is returned. @repeat(times=3): Python calls repeat(times=3), so _func=None; decorator is returned; Python then calls decorator(say_hello). The * in the signature forces times to be keyword-only, preventing accidental positional use of times.

import functools

def repeat(_func=None, *, times=2):
  # Can be used as @repeat or @repeat(times=3)
  # Calls the function 'times' times and returns the last result.
  def decorator(func):
      @functools.wraps(func)
      def wrapper(*args, **kwargs):
          result = None
          for _ in range(times):
              result = func(*args, **kwargs)
          return result
      return wrapper

  if _func is not None:
      # Called as @repeat with no parentheses
      return decorator(_func)
  # Called as @repeat(times=3)
  return decorator

@repeat
def say_hi():
  print("hi")

@repeat(times=3)
def say_hello():
  print("hello")

say_hi()
say_hello()
Expected Output
hi\nhi\nhello\nhello\nhello
Hints

Hint 1: The trick is the optional first positional argument _func.

Hint 2: When @repeat is used without (), Python passes the function as _func.

Hint 3: When @repeat(times=3) is used, _func is None and decorator is returned.

Hint 4: The keyword-only * forces times to always be a keyword argument.


#11FastAPI-Style Route DecoratorHard
decorator factoryregistryroute dispatcherclass-based

Implement the Router class with get(path) and post(path) decorator methods that register handlers, and dispatch(method, path) that calls the matching handler.

Python
import functools

class Router:
    def __init__(self):
        self._routes = {}

    def _register(self, method, path):
        def decorator(func):
            self._routes[(method.upper(), path)] = func
            return func  # return unchanged — no wrapping needed
        return decorator

    def get(self, path):
        return self._register('GET', path)

    def post(self, path):
        return self._register('POST', path)

    def dispatch(self, method, path, **kwargs):
        key = (method.upper(), path)
        if key not in self._routes:
            raise KeyError(f"No route for {method} {path}")
        return self._routes[key](**kwargs)

router = Router()

@router.get("/users")
def list_users():
    return "listing users"

@router.post("/users")
def create_user(name="unknown"):
    return f"created {name}"

print(router.dispatch("GET",  "/users"))
print(router.dispatch("POST", "/users", name="Alice"))
Solution
import functools

class Router:
def __init__(self):
self._routes = {}

def _register(self, method, path):
def decorator(func):
self._routes[(method.upper(), path)] = func
return func
return decorator

def get(self, path):
return self._register('GET', path)

def post(self, path):
return self._register('POST', path)

def dispatch(self, method, path, **kwargs):
key = (method.upper(), path)
if key not in self._routes:
raise KeyError(f"No route for {method} {path}")
return self._routes[key](**kwargs)

Explanation: @router.get("/users") calls router.get("/users"), which calls self._register('GET', '/users'), which returns a decorator. Python then calls decorator(list_users), which stores the function in self._routes and returns func unchanged. Returning func without wrapping is intentional — the decorator's purpose is registration as a side effect, not runtime wrapping. This is precisely how FastAPI, Flask, and Django register route handlers.

import functools

class Router:
  """
  A minimal FastAPI-style router.
  @router.get("/path") registers a handler for GET /path.
  @router.post("/path") registers a handler for POST /path.
  router.dispatch(method, path) calls the matching handler.
  """
  def __init__(self):
      self._routes = {}  # {(method, path): handler}

  def get(self, path):
      pass  # return a decorator that registers the handler under ('GET', path)

  def post(self, path):
      pass  # return a decorator that registers the handler under ('POST', path)

  def _register(self, method, path):
      pass  # return a decorator factory helper

  def dispatch(self, method, path, **kwargs):
      key = (method.upper(), path)
      if key not in self._routes:
          raise KeyError(f"No route for {method} {path}")
      return self._routes[key](**kwargs)

router = Router()

@router.get("/users")
def list_users():
  return "listing users"

@router.post("/users")
def create_user(name="unknown"):
  return f"created {name}"

print(router.dispatch("GET",  "/users"))
print(router.dispatch("POST", "/users", name="Alice"))
Expected Output
listing users\ncreated Alice
Hints

Hint 1: get(path) and post(path) are decorator factories — they return a decorator.

Hint 2: The decorator should store func in self._routes[(method, path)] and return func unchanged.

Hint 3: Returning func unchanged (not a wrapper) is correct here — registration is the side effect.

Hint 4: You can implement _register(method, path) as a shared helper called by get() and post().

© 2026 EngineersOfAI. All rights reserved.