From a4cd470fdc9a452a7397a44e33d498fb03aa58cb Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 19:18:37 +0530 Subject: [PATCH 1/2] Keep statistics visible when @retry is further wrapped When a @retry-decorated function was wrapped by another decorator that uses functools.wraps (e.g. a timing decorator), accessing ``func.statistics`` returned an empty ``{}``. functools.wraps copies the inner wrapper's ``__dict__`` (including the ``statistics`` reference) into the outer wrapper. On each call the inner wrapper rebound ``wrapped_f.statistics`` to a fresh dict, so the outer wrapper kept pointing at the original, now-stale, empty dict. Reuse the same statistics dict in place on every call (clearing it and sharing it with the per-call copy) instead of rebinding the attribute. The dict identity stays stable, so the stats remain reachable through the outer wrapper's copied ``__dict__``. The same change is applied to the async wrapper. Fixes #519. --- ...ough-outer-decorator-b8a6d6b9a6a70833.yaml | 8 +++++ tenacity/__init__.py | 10 +++++-- tenacity/asyncio/__init__.py | 10 +++++-- tests/test_asyncio.py | 26 +++++++++++++++++ tests/test_tenacity.py | 29 +++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/fix-statistics-through-outer-decorator-b8a6d6b9a6a70833.yaml 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..ee41d57c 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]": 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..91fd67c6 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 + + def outer( + fn: typing.Callable[..., typing.Any], + ) -> typing.Callable[..., typing.Any]: + @functools.wraps(fn) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + return fn(*args, **kwargs) + + return 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: From efbe866eacbcf3195c97470ac6ad13a9e7128780 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 19:51:20 +0530 Subject: [PATCH 2/2] Type __wrapped__ on _RetryDecorated so statistics stay type-safe The runtime fix keeps func.statistics visible when a @retry-decorated function is further wrapped by another functools.wraps-based decorator. However mypy (strict) rejected the tests because the public typing did not expose the attributes the tests rely on: * _RetryDecorated had no __wrapped__ member, so accessing my_call.__wrapped__.statistics failed with [attr-defined]. * the sync test's outer decorator was annotated Callable[..., Any] -> Callable[..., Any], erasing the _RetryDecorated type so .statistics/.__wrapped__ were unreachable. Declare __wrapped__ on the _RetryDecorated protocol (it is always set by functools.wraps on the retry wrapper) and make the sync test's outer decorator type-preserving via a TypeVar, mirroring the async test. mypy, ruff, pytest and the docs build are all green. --- tenacity/__init__.py | 5 +++++ tests/test_tenacity.py | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index ee41d57c..6b591464 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -660,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/tests/test_tenacity.py b/tests/test_tenacity.py index 91fd67c6..b117061e 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1517,14 +1517,14 @@ def test_statistics_visible_through_outer_decorator(self) -> None: """ import functools - def outer( - fn: typing.Callable[..., typing.Any], - ) -> typing.Callable[..., typing.Any]: + _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 wrapper + return typing.cast("_F", wrapper) @outer @retry(stop=tenacity.stop_after_attempt(3))