functools.wraps и параметризованные декораторы
Урок 01 показал closure-based wrapper. Но мы упомянули проблему: wrapper теряет __name__/__doc__/__module__ оригинальной функции. Это ломает introspection — debugging-tools (pytest, Sphinx, traceback formatter) теряют информацию. Решение — @functools.wraps(fn) decorator из stdlib: одна строка, и metadata скопирован.
В этом уроке:
- Демонстрируем Pitfall 1 — wrapper без
@wrapsтеряет introspection (empirically). @functools.wraps— что копирует, как работает (citeLib/functools.py).__wrapped__— атрибут для unwrap’а (introspection rescue).update_wrapper— низкоуровневый API (используется внутриwraps).- Recipe end-to-end:
@retry(max_attempts=3, delay_seconds=0.1)— параметризованный декоратор для flaky operations. - Code challenge:
count_callsdecorator с@wraps— verify, чтоdecorated.__name__returns оригинальное имя.
Это критически прикладной урок — @functools.wraps обязателен в любом production-grade декораторе.
Pitfall 1: wrapper без @wraps ломает introspection
Empirically — пишем wrapper без @wraps:
def dec_no_wraps(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@dec_no_wraps
def hello(name):
"""Greet someone by name."""
return f'Hello, {name}!'
print(hello.__name__) # 'wrapper' ← НЕ 'hello'!
print(hello.__doc__) # None ← НЕ 'Greet someone by name.'!
print(hello.__module__) # '__main__' (это оригинал, но это случайно)
print(hello.__qualname__) # 'dec_no_wraps.<locals>.wrapper'
После декорирования:
hello.__name__возвращает'wrapper'— это локальное имя функции внутриdec_no_wraps. Каждый wrapper, созданный в этом scope, имеет одинаковое__name__. Если у вас 10 декорированных функций — все будут'wrapper'.hello.__doc__—None(уwrapperсвоего docstring нет).hello.__qualname__—'dec_no_wraps.<locals>.wrapper'— путь к функции из перспективы compiler’а; не имеет связи с оригиналом.
Где это ломается:
help(hello)показывает signaturewrapper(*args, **kwargs)без оригинальных параметров — bezpolezno.- Sphinx / pdoc генерирует documentation с именем
wrapper— вся ваша docstring потеряна. - pytest collect’ит test functions по
__name__. Если@my_decorator def test_foo()— pytest видит'wrapper', и при ошибке репорт показывает'wrapper FAILED'. Если декорировано 10 тестов — 10 одинаковых'wrapper FAILED'строк, debugging пытка. - traceback:
Exception in wrapperвместоException in process_user— вы не знаете, какая функция упала. inspect.signature(hello)возвращает(*args, **kwargs)— нечем проверить, что вызывали с правильными типами.
import inspect
print(inspect.signature(hello)) # (*args, **kwargs) ← оригинальной (name) больше нет
Это не косметическая проблема. Production-grade код не может иметь такие декораторы — debugging становится impossible.
@functools.wraps: лечение в одну строку
@functools.wraps(fn) — декоратор-decorator (применяется к wrapper’у, чтобы он скопировал metadata из fn):
from functools import wraps
def dec_with_wraps(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@dec_with_wraps
def hello(name):
"""Greet someone by name."""
return f'Hello, {name}!'
print(hello.__name__) # 'hello' ← OK!
print(hello.__doc__) # 'Greet someone by name.' ← OK!
print(hello.__qualname__) # 'hello' ← OK!
print(hello.__wrapped__) # <function hello at 0x...> ← оригинал доступен!
import inspect
print(inspect.signature(hello)) # (name) ← реальная signature!
Одна строка @wraps(fn) исправляет все 5 проблем выше. Никогда не пишите production-декоратор без @wraps — это не optional, это обязательный hygiene.
Что копирует @wraps — WRAPPER_ASSIGNMENTS
@functools.wraps под капотом — wrapper для functools.update_wrapper. Откроем Lib/functools.py:
# Lib/functools.py (упрощено для ясности)
WRAPPER_ASSIGNMENTS = (
'__module__',
'__name__',
'__qualname__',
'__annotations__',
'__doc__',
)
WRAPPER_UPDATES = ('__dict__',)
def update_wrapper(wrapper,
wrapped,
assigned=WRAPPER_ASSIGNMENTS,
updated=WRAPPER_UPDATES):
for attr in assigned:
try:
value = getattr(wrapped, attr)
except AttributeError:
pass
else:
setattr(wrapper, attr, value)
for attr in updated:
getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
# Issue #17482: set __wrapped__ last so we don't inadvertently copy it
# from the wrapped function when updating __dict__
wrapper.__wrapped__ = wrapped
return wrapper
def wraps(wrapped,
assigned=WRAPPER_ASSIGNMENTS,
updated=WRAPPER_UPDATES):
return partial(update_wrapper, wrapped=wrapped,
assigned=assigned, updated=updated)
Что это делает:
WRAPPER_ASSIGNMENTS— список атрибутов, которые копируются из оригинала в wrapper:__module__,__name__,__qualname__,__annotations__,__doc__.WRAPPER_UPDATES— список dict-атрибутов, которые mergeятся в wrapper’ом dict (не replaceятся):__dict__. Если у оригинала был кастомный атрибут (e.g.,fn.tag = 'admin'), он попадёт и на wrapper.__wrapped__— устанавливается последним, ссылается на оригинал. Это атрибут для unwrap’а (см. ниже).@wraps(fn)возвращаетpartial(update_wrapper, wrapped=fn)— partially-applied версиюupdate_wrapper. Когда применяется к wrapper’у, вызываетupdate_wrapper(wrapper, wrapped=fn).
Cite: Lib/functools.py — WRAPPER_ASSIGNMENTS, update_wrapper, wraps.
@wraps это просто partial(update_wrapper, ...). Если хотите уточнить, что копируется — передайте assigned= или updated= explicit’но. Например, не копировать __doc__: @wraps(fn, assigned=('__name__', '__qualname__')).
__wrapped__: introspection rescue
Атрибут __wrapped__ указывает на оригинальную функцию. Это позволяет inspect-tools «снять» декораторы и работать с оригиналом:
from functools import wraps
import inspect
def my_dec(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@my_dec
def add(a: int, b: int) -> int:
return a + b
print(add.__wrapped__) # <function add at 0x...> — оригинал
print(add.__wrapped__.__name__) # 'add'
# inspect.unwrap проходит __wrapped__ цепочку до конца
original = inspect.unwrap(add)
print(original.__name__) # 'add'
print(inspect.signature(original)) # (a: int, b: int) -> int
inspect.unwrap рекурсивно следует __wrapped__, пока не найдёт функцию без него. Это полезно, если декораторов несколько:
@my_dec
@my_dec
@my_dec
def triple_decorated():
return 42
# Цепочка __wrapped__ → __wrapped__ → __wrapped__:
print(triple_decorated.__wrapped__.__wrapped__.__wrapped__.__name__) # 'triple_decorated'
# Или короче:
print(inspect.unwrap(triple_decorated).__name__) # 'triple_decorated'
inspect.signature(decorated) тоже автоматически следует __wrapped__, чтобы вернуть signature оригинала, не wrapper’а — inspect understands этот convention.
update_wrapper — низкоуровневый API
update_wrapper(wrapper, wrapped, ...) — то же, что wraps, но прямой вызов (не decorator-syntax). Используется когда wrapper — не функция, а instance класса (см. урок 03):
from functools import update_wrapper
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
update_wrapper(self, fn) # ← копируем metadata в self
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def hello():
"""Say hello."""
return 'hi'
print(hello.__name__) # 'hello' — благодаря update_wrapper(self, fn)!
print(hello.__doc__) # 'Say hello.'
hello(); hello()
print(hello.calls) # 2
Без update_wrapper(self, fn) instance не имел бы __name__ / __doc__. Урок 03 покажет class-based decorators в деталях.
Recipe end-to-end: @retry(max_attempts=3, delay_seconds=0.1)
Параметризованный декоратор — three nested def (урок 01). Recipe для retry на flaky operations (DB queries, API calls):
from functools import wraps
import time
def retry(max_attempts=3, delay_seconds=0.1):
"""Декоратор, повторяющий fn до max_attempts раз при exception."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt < max_attempts:
# В production: time.sleep(delay_seconds)
# Здесь — print для demo (Pyodide-friendly)
print(f'attempt {attempt} failed: {exc!r}; retrying...')
else:
print(f'attempt {attempt} failed: {exc!r}; giving up')
raise last_exc # все попытки потрачены — re-raise последний
return wrapper
return decorator
# Использование:
attempts_so_far = 0
@retry(max_attempts=3, delay_seconds=0.0)
def flaky_db_query():
global attempts_so_far
attempts_so_far += 1
if attempts_so_far < 3:
raise ConnectionError('DB unavailable')
return 'query result'
print(flaky_db_query())
# attempt 1 failed: ConnectionError('DB unavailable'); retrying...
# attempt 2 failed: ConnectionError('DB unavailable'); retrying...
# 'query result'
Что мы получили:
- Three nested defs:
retry(max_attempts=3)→decorator→wrapper. Каждый уровень — closure. @wraps(fn)—flaky_db_query.__name__остаётся'flaky_db_query', не'wrapper'.- Composable: один
@retry(3)навешивается на любую функцию, retry-логика отделена от business logic. - Configurable:
max_attempts,delay_seconds— настройки per-вызов.
Production-уточнение: для realных retry в production используйте библиотеку tenacity — она exponential backoff, jitter, conditional retry (only on certain exceptions), tracing integration. M06 учит pattern; production использует battle-tested implementation.
Common mistakes
Mistake 1: @retry без () для параметризованного декоратора
@retry # ← BUG: вызывает retry(flaky_db_query) — flaky_db_query становится max_attempts!
def flaky_db_query():
...
Силлогизм: @retry (без скобок) применяет retry напрямую к функции; flaky_db_query будет передан как первый позиционный аргумент max_attempts. Wrapper получит max_attempts=<function>, comparison с int сломается.
Правильно: @retry(max_attempts=3) — даже если значение default’ное, скобки обязательны.
Mistake 2: забыли @wraps в wrapper
См. Pitfall 1 выше. Каждый production-декоратор обязан включать @wraps(fn).
Mistake 3: side effects в decorator-factory вместо wrapper’а
def trace(fn):
print(f'tracing {fn.__name__}') # ← это runs ОДИН раз, при декорировании!
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
print сработает один раз — при @trace def f, а не при каждом f(). Чтобы трейсить каждый вызов, переместите print внутрь wrapper. Common newbie mistake.
Cross-link M03 урок 04: lru_cache uses same pattern
@functools.lru_cache(maxsize=128) (M03 урок 04) — сам параметризованный декоратор: factory принимает maxsize, возвращает реальный декоратор. Implementation в Lib/functools.py использует те же three nested def (плюс C-acceleration в Modules/_functoolsmodule.c). Если поняли recipe @retry, вы поняли, как написан lru_cache.
from functools import lru_cache
@lru_cache(maxsize=128)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# Эквивалент без @-syntax:
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
fib = lru_cache(maxsize=128)(fib) # double call!
Та же mechanism: lru_cache(maxsize=128) — factory; возвращает _lru_cache_wrapper; применяется к fib. Cite: Lib/functools.py — _lru_cache_wrapper.
Pitfall checklist
| # | Trap | Fix |
|---|---|---|
| 1 | Wrapper без @wraps теряет __name__/__doc__ | @wraps(fn) сразу под def wrapper |
| 2 | __wrapped__ не установлен — inspect.unwrap не работает | @wraps устанавливает автоматически |
| 3 | Параметризованный декоратор без () — функция становится первым параметром | @retry(), не @retry |
| 4 | Side effect в decorator-factory — runs один раз | Переместите внутрь wrapper |
| 5 | update_wrapper(wrapper, fn) забыт в class-based decorator (урок 03) | Вызвать в __init__ |
Ключевые выводы
@functools.wraps(fn)— обязательная hygiene. Wrapper без него теряет__name__/__doc__/__module__/__qualname__/__annotations__— ломает introspection (pytest, Sphinx,inspect.signature, traceback).WRAPPER_ASSIGNMENTS—('__module__', '__name__', '__qualname__', '__annotations__', '__doc__')— что копируется (citeLib/functools.py).__wrapped__— атрибут на wrapper’е, ссылающийся на оригинал;inspect.unwrapследует цепочку до finalного non-wrapped.update_wrapper— низкоуровневый API; используется когда wrapper — class instance (урок 03 —update_wrapper(self, fn)в__init__).@retry(max_attempts=N)— recipe параметризованного декоратора через three nesteddef; каждый уровень — closure.- Production:
tenacityдля realного retry,@functools.lru_cache— пример параметризованного декоратора в stdlib (тот же pattern).
В уроке 03 — class-based декораторы (__init__ + __call__) с instance state (counter, registry, rate limiter).