diff --git a/releasenotes/notes/fix-statistics-through-outer-decorator-b8a6d6b9a6a70833.yaml b/releasenotes/notes/fix-statistics-through-outer-decorator-b8a6d6b9a6a70833.yaml new file mode 100644 index 00000000..55b34e01 --- /dev/null +++ b/releasenotes/notes/fix-statistics-through-outer-decorator-b8a6d6b9a6a70833.yaml @@ -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. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index e9532fc2..6b591464 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -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]": @@ -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]": ... diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 64598949..a20edc2f 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -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 diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 20b320e1..96560b07 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -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 diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b2c0289a..b117061e 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -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: