Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
fixes:
- |
Keep ``func.statistics`` visible when a ``@retry``-decorated function is
further wrapped by another decorator (for example a timing decorator using
``functools.wraps``). The statistics dict is now reused in place on each
call instead of being rebound, so it stays reachable through the outer
wrapper's copied ``__dict__`` instead of appearing empty.
15 changes: 13 additions & 2 deletions tenacity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,14 @@ def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any:
# Always create a copy to prevent overwriting the local contexts when
# calling the same wrapped functions multiple times in the same stack
copy = self.copy()
wrapped_f.statistics = copy.statistics # type: ignore[attr-defined]
self._local.statistics = copy.statistics
# Reuse the same statistics dict rather than rebinding the attribute
# so that the stats stay visible through additional decorators that
# copy attributes via functools.wraps (which copies the reference to
# this dict into the outer wrapper's __dict__). See issue #519.
stats = wrapped_f.statistics # type: ignore[attr-defined]
stats.clear()
copy._local.statistics = stats # noqa: SLF001
self._local.statistics = stats
return copy(f, *args, **kw)

def retry_with(*args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]":
Expand Down Expand Up @@ -654,6 +660,11 @@ class _RetryDecorated(t.Protocol[P, R]):

retry: "BaseRetrying"
statistics: dict[str, t.Any]
# Set by functools.wraps on the retry wrapper. Declared so that the
# statistics stay accessible in a type-safe way even when the decorated
# function is further wrapped by another functools.wraps-based decorator
# (which copies these attributes onto the outer wrapper). See issue #519.
__wrapped__: "_RetryDecorated[P, R]"

def retry_with(self, *args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]": ...

Expand Down
10 changes: 8 additions & 2 deletions tenacity/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,14 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any:
# Always create a copy to prevent overwriting the local contexts when
# calling the same wrapped functions multiple times in the same stack
copy = self.copy()
async_wrapped.statistics = copy.statistics # type: ignore[attr-defined]
self._local.statistics = copy.statistics
# Reuse the same statistics dict rather than rebinding the attribute
# so that the stats stay visible through additional decorators that
# copy attributes via functools.wraps (which copies the reference to
# this dict into the outer wrapper's __dict__). See issue #519.
stats = async_wrapped.statistics # type: ignore[attr-defined]
stats.clear()
copy._local.statistics = stats # noqa: SLF001
self._local.statistics = stats
return await copy(fn, *args, **kwargs) # type: ignore[type-var]

# Preserve attributes
Expand Down
26 changes: 26 additions & 0 deletions tests/test_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ def test_retry_attributes(self) -> None:
assert hasattr(_retryable_coroutine, "retry")
assert hasattr(_retryable_coroutine, "retry_with")

@asynctest
async def test_statistics_visible_through_outer_decorator(self) -> None:
"""Statistics must resolve when @retry is wrapped by another decorator.

A well-behaved outer decorator uses functools.wraps, which copies the
inner wrapper's ``__dict__`` (including ``statistics``). Rebinding the
attribute on each call left the outer wrapper pointing at a stale empty
dict. See issue #519.
"""

def outer(fn: _F) -> _F:
@wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return await fn(*args, **kwargs)

return wrapper # type: ignore[return-value]

@outer
@retry(stop=stop_after_attempt(3))
async def my_call() -> str:
return "ok"

assert await my_call() == "ok"
assert my_call.statistics["attempt_number"] == 1
assert my_call.statistics is my_call.__wrapped__.statistics

def test_retry_preserves_argument_defaults(self) -> None:
async def function_with_defaults(a: int = 1) -> int:
return a
Expand Down
29 changes: 29 additions & 0 deletions tests/test_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,35 @@ def succeeds_first_try() -> bool:
succeeds_first_try()
assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0

def test_statistics_visible_through_outer_decorator(self) -> None:
"""Statistics must resolve when @retry is wrapped by another decorator.

A well-behaved outer decorator uses functools.wraps, which copies the
inner wrapper's ``__dict__`` (including ``statistics``). Rebinding the
attribute on each call left the outer wrapper pointing at a stale empty
dict. The statistics must instead stay visible through the wrapper
chain. See issue #519.
"""
import functools

_F = typing.TypeVar("_F", bound=typing.Callable[..., typing.Any])

def outer(fn: _F) -> _F:
@functools.wraps(fn)
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
return fn(*args, **kwargs)

return typing.cast("_F", wrapper)

@outer
@retry(stop=tenacity.stop_after_attempt(3))
def my_call() -> str:
return "ok"

assert my_call() == "ok"
assert my_call.statistics["attempt_number"] == 1
assert my_call.statistics is my_call.__wrapped__.statistics


class TestEnabled:
def test_enabled_false_skips_retry(self) -> None:
Expand Down