Learning Platform
Глоссарий Troubleshooting
Урок 07.02 · 22 мин
Продвинутый
functools.wrapsupdate_wrapperWRAPPER_ASSIGNMENTS__wrapped__inspectParameterized decoratorsRetry patternRecipe
Требуемые знания:

functools.wraps и параметризованные декораторы

Урок 01 показал closure-based wrapper. Но мы упомянули проблему: wrapper теряет __name__/__doc__/__module__ оригинальной функции. Это ломает introspection — debugging-tools (pytest, Sphinx, traceback formatter) теряют информацию. Решение — @functools.wraps(fn) decorator из stdlib: одна строка, и metadata скопирован.

В этом уроке:

  1. Демонстрируем Pitfall 1 — wrapper без @wraps теряет introspection (empirically).
  2. @functools.wraps — что копирует, как работает (cite Lib/functools.py).
  3. __wrapped__ — атрибут для unwrap’а (introspection rescue).
  4. update_wrapper — низкоуровневый API (используется внутри wraps).
  5. Recipe end-to-end: @retry(max_attempts=3, delay_seconds=0.1) — параметризованный декоратор для flaky operations.
  6. Code challenge: count_calls decorator с @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__Nonewrapper своего docstring нет).
  • hello.__qualname__'dec_no_wraps.<locals>.wrapper' — путь к функции из перспективы compiler’а; не имеет связи с оригиналом.

Где это ломается:

  1. help(hello) показывает signature wrapper(*args, **kwargs) без оригинальных параметров — bezpolezno.
  2. Sphinx / pdoc генерирует documentation с именем wrapper — вся ваша docstring потеряна.
  3. pytest collect’ит test functions по __name__. Если @my_decorator def test_foo() — pytest видит 'wrapper', и при ошибке репорт показывает 'wrapper FAILED'. Если декорировано 10 тестов — 10 одинаковых 'wrapper FAILED' строк, debugging пытка.
  4. traceback: Exception in wrapper вместо Exception in process_user — вы не знаете, какая функция упала.
  5. 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.


Что копирует @wrapsWRAPPER_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)

Что это делает:

  1. WRAPPER_ASSIGNMENTS — список атрибутов, которые копируются из оригинала в wrapper: __module__, __name__, __qualname__, __annotations__, __doc__.
  2. WRAPPER_UPDATES — список dict-атрибутов, которые mergeятся в wrapper’ом dict (не replaceятся): __dict__. Если у оригинала был кастомный атрибут (e.g., fn.tag = 'admin'), он попадёт и на wrapper.
  3. __wrapped__ — устанавливается последним, ссылается на оригинал. Это атрибут для unwrap’а (см. ниже).
  4. @wraps(fn) возвращает partial(update_wrapper, wrapped=fn) — partially-applied версию update_wrapper. Когда применяется к wrapper’у, вызывает update_wrapper(wrapper, wrapped=fn).

Cite: Lib/functools.pyWRAPPER_ASSIGNMENTS, update_wrapper, wraps.

TIP

@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'

Что мы получили:

  1. Three nested defs: retry(max_attempts=3)decoratorwrapper. Каждый уровень — closure.
  2. @wraps(fn)flaky_db_query.__name__ остаётся 'flaky_db_query', не 'wrapper'.
  3. Composable: один @retry(3) навешивается на любую функцию, retry-логика отделена от business logic.
  4. Configurable: max_attempts, delay_seconds — настройки per-вызов.
TIP

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.


@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

#TrapFix
1Wrapper без @wraps теряет __name__/__doc__@wraps(fn) сразу под def wrapper
2__wrapped__ не установлен — inspect.unwrap не работает@wraps устанавливает автоматически
3Параметризованный декоратор без () — функция становится первым параметром@retry(), не @retry
4Side effect в decorator-factory — runs один разПереместите внутрь wrapper
5update_wrapper(wrapper, fn) забыт в class-based decorator (урок 03)Вызвать в __init__

Ключевые выводы

  1. @functools.wraps(fn) — обязательная hygiene. Wrapper без него теряет __name__ / __doc__ / __module__ / __qualname__ / __annotations__ — ломает introspection (pytest, Sphinx, inspect.signature, traceback).
  2. WRAPPER_ASSIGNMENTS('__module__', '__name__', '__qualname__', '__annotations__', '__doc__') — что копируется (cite Lib/functools.py).
  3. __wrapped__ — атрибут на wrapper’е, ссылающийся на оригинал; inspect.unwrap следует цепочку до finalного non-wrapped.
  4. update_wrapper — низкоуровневый API; используется когда wrapper — class instance (урок 03 — update_wrapper(self, fn) в __init__).
  5. @retry(max_attempts=N) — recipe параметризованного декоратора через three nested def; каждый уровень — closure.
  6. Production: tenacity для realного retry, @functools.lru_cache — пример параметризованного декоратора в stdlib (тот же pattern).

В уроке 03 — class-based декораторы (__init__ + __call__) с instance state (counter, registry, rate limiter).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Что вернёт `decorated.__name__` для wrapper'а БЕЗ `@functools.wraps`? ```python def dec(fn): def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper @dec def hello(): pass # hello.__name__ == ? ```

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5