Repository: jd/tenacity

Issue:
8.4.2 breaks suggested way to overwrite tenacity behaviors on unit tests
Looks like the change introduced with 8.4.2: https://github.com/jd/tenacity/compare/8.4.1...8.4.2

Breaks the suggested behavior to overwrite tenacity wait time/retry confiig on tests, as suggested here:

https://github.com/jd/tenacity/issues/106

Doesn't looks like this was done by propose, is it expected? If yes, what would be the suggested way to ovewrite the retry config in tests to speed it up?

Discussion:

Patch:
diff --git a/doc/source/index.rst b/doc/source/index.rst
index 65dd208..928ddd9 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -124,8 +124,8 @@ retrying stuff.
         print("Stopping after 10 seconds")
         raise Exception
 
-If you're on a tight deadline, and exceeding your delay time isn't ok, 
-then you can give up on retries one attempt before you would exceed the delay. 
+If you're on a tight deadline, and exceeding your delay time isn't ok,
+then you can give up on retries one attempt before you would exceed the delay.
 
 .. testcode::
 
@@ -362,7 +362,7 @@ Statistics
 ~~~~~~~~~~
 
 You can access the statistics about the retry made over a function by using the
-`retry` attribute attached to the function and its `statistics` attribute:
+`statistics` attribute attached to the function:
 
 .. testcode::
 
@@ -375,7 +375,7 @@ You can access the statistics about the retry made over a function by using the
     except Exception:
         pass
 
-    print(raise_my_exception.retry.statistics)
+    print(raise_my_exception.statistics)
 
 .. testoutput::
    :hide:
@@ -495,7 +495,7 @@ using the `retry_with` function attached to the wrapped function:
     except Exception:
         pass
 
-    print(raise_my_exception.retry.statistics)
+    print(raise_my_exception.statistics)
 
 .. testoutput::
    :hide:
@@ -514,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly:
         retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True)
         retryer(never_good_enough, 'I really do try')
 
+You may also want to change the behaviour of a decorated function temporarily,
+like in tests to avoid unnecessary wait times. You can modify/patch the `retry`
+attribute attached to the function. Bear in mind this is a write-only attribute,
+statistics should be read from the function `statistics` attribute.
+
+.. testcode::
+
+    @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
+    def raise_my_exception():
+        raise MyException("Fail")
+
+    from unittest import mock
+
+    with mock.patch.object(raise_my_exception.retry, "wait", wait_fixed(0)):
+        try:
+            raise_my_exception()
+        except Exception:
+            pass
+
+    print(raise_my_exception.statistics)
+
+.. testoutput::
+   :hide:
+
+   ...
+
 Retrying code block
 ~~~~~~~~~~~~~~~~~~~
 
diff --git a/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml
new file mode 100644
index 0000000..967cd29
--- /dev/null
+++ b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+  - |
+    Restore the value of the `retry` attribute for wrapped functions. Also,
+    clarify that those attributes are write-only and statistics should be
+    read from the function attribute directly.
diff --git a/tenacity/__init__.py b/tenacity/__init__.py
index 06251ed..02057a0 100644
--- a/tenacity/__init__.py
+++ b/tenacity/__init__.py
@@ -339,7 +339,7 @@ class BaseRetrying(ABC):
             return self.copy(*args, **kwargs).wraps(f)
 
         # Preserve attributes
-        wrapped_f.retry = wrapped_f  # type: ignore[attr-defined]
+        wrapped_f.retry = self  # type: ignore[attr-defined]
         wrapped_f.retry_with = retry_with  # type: ignore[attr-defined]
         wrapped_f.statistics = {}  # type: ignore[attr-defined]
 
diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py
index 38b76c7..a926091 100644
--- a/tenacity/asyncio/__init__.py
+++ b/tenacity/asyncio/__init__.py
@@ -189,7 +189,7 @@ class AsyncRetrying(BaseRetrying):
             return await copy(fn, *args, **kwargs)
 
         # Preserve attributes
-        async_wrapped.retry = async_wrapped  # type: ignore[attr-defined]
+        async_wrapped.retry = self  # type: ignore[attr-defined]
         async_wrapped.retry_with = wrapped.retry_with  # type: ignore[attr-defined]
         async_wrapped.statistics = {}  # type: ignore[attr-defined]
 


Test patch:
diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py
index 8716529..0b74476 100644
--- a/tests/test_asyncio.py
+++ b/tests/test_asyncio.py
@@ -17,6 +17,7 @@ import asyncio
 import inspect
 import unittest
 from functools import wraps
+from unittest import mock
 
 try:
     import trio
@@ -59,7 +60,7 @@ async def _retryable_coroutine(thing):
 @retry(stop=stop_after_attempt(2))
 async def _retryable_coroutine_with_2_attempts(thing):
     await asyncio.sleep(0.00001)
-    thing.go()
+    return thing.go()
 
 
 class TestAsyncio(unittest.TestCase):
@@ -394,6 +395,64 @@ class TestContextManager(unittest.TestCase):
                     await _async_function(thing)
 
 
+class TestDecoratorWrapper(unittest.TestCase):
+    @asynctest
+    async def test_retry_function_attributes(self):
+        """Test that the wrapped function attributes are exposed as intended.
+
+        - statistics contains the value for the latest function run
+        - retry object can be modified to change its behaviour (useful to patch in tests)
+        - retry object statistics do not contain valid information
+        """
+
+        self.assertTrue(
+            await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(1))
+        )
+
+        expected_stats = {
+            "attempt_number": 2,
+            "delay_since_first_attempt": mock.ANY,
+            "idle_for": mock.ANY,
+            "start_time": mock.ANY,
+        }
+        self.assertEqual(
+            _retryable_coroutine_with_2_attempts.statistics,  # type: ignore[attr-defined]
+            expected_stats,
+        )
+        self.assertEqual(
+            _retryable_coroutine_with_2_attempts.retry.statistics,  # type: ignore[attr-defined]
+            {},
+        )
+
+        with mock.patch.object(
+            _retryable_coroutine_with_2_attempts.retry,  # type: ignore[attr-defined]
+            "stop",
+            tenacity.stop_after_attempt(1),
+        ):
+            try:
+                self.assertTrue(
+                    await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(2))
+                )
+            except RetryError as exc:
+                expected_stats = {
+                    "attempt_number": 1,
+                    "delay_since_first_attempt": mock.ANY,
+                    "idle_for": mock.ANY,
+                    "start_time": mock.ANY,
+                }
+                self.assertEqual(
+                    _retryable_coroutine_with_2_attempts.statistics,  # type: ignore[attr-defined]
+                    expected_stats,
+                )
+                self.assertEqual(exc.last_attempt.attempt_number, 1)
+                self.assertEqual(
+                    _retryable_coroutine_with_2_attempts.retry.statistics,  # type: ignore[attr-defined]
+                    {},
+                )
+            else:
+                self.fail("RetryError should have been raised after 1 attempt")
+
+
 # make sure mypy accepts passing an async sleep function
 # https://github.com/jd/tenacity/issues/399
 async def my_async_sleep(x: float) -> None:
diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py
index e158fa6..ecc0312 100644
--- a/tests/test_tenacity.py
+++ b/tests/test_tenacity.py
@@ -25,6 +25,7 @@ import warnings
 from contextlib import contextmanager
 from copy import copy
 from fractions import Fraction
+from unittest import mock
 
 import pytest
 
@@ -1073,7 +1074,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))
             )
         except NameError as e:
-            s = _retryable_test_with_unless_exception_type_name.retry.statistics
+            s = _retryable_test_with_unless_exception_type_name.statistics
             self.assertTrue(s["attempt_number"] == 6)
             print(e)
         else:
@@ -1088,7 +1089,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 )
             )
         except NameError as e:
-            s = _retryable_test_with_unless_exception_type_no_input.retry.statistics
+            s = _retryable_test_with_unless_exception_type_no_input.statistics
             self.assertTrue(s["attempt_number"] == 6)
             print(e)
         else:
@@ -1111,7 +1112,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))
             )
         except CustomError:
-            print(_retryable_test_if_exception_message_message.retry.statistics)
+            print(_retryable_test_if_exception_message_message.statistics)
             self.fail("CustomError should've been retried from errormessage")
 
     def test_retry_if_not_exception_message(self):
@@ -1122,7 +1123,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 )
             )
         except CustomError:
-            s = _retryable_test_if_not_exception_message_message.retry.statistics
+            s = _retryable_test_if_not_exception_message_message.statistics
             self.assertTrue(s["attempt_number"] == 1)
 
     def test_retry_if_not_exception_message_delay(self):
@@ -1131,7 +1132,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 _retryable_test_not_exception_message_delay(NameErrorUntilCount(3))
             )
         except NameError:
-            s = _retryable_test_not_exception_message_delay.retry.statistics
+            s = _retryable_test_not_exception_message_delay.statistics
             print(s["attempt_number"])
             self.assertTrue(s["attempt_number"] == 4)
 
@@ -1151,7 +1152,7 @@ class TestDecoratorWrapper(unittest.TestCase):
                 )
             )
         except CustomError:
-            s = _retryable_test_if_not_exception_message_message.retry.statistics
+            s = _retryable_test_if_not_exception_message_message.statistics
             self.assertTrue(s["attempt_number"] == 1)
 
     def test_retry_if_exception_cause_type(self):
@@ -1209,6 +1210,43 @@ class TestDecoratorWrapper(unittest.TestCase):
         h = retrying.wraps(Hello())
         self.assertEqual(h(), "Hello")
 
+    def test_retry_function_attributes(self):
+        """Test that the wrapped function attributes are exposed as intended.
+
+        - statistics contains the value for the latest function run
+        - retry object can be modified to change its behaviour (useful to patch in tests)
+        - retry object statistics do not contain valid information
+        """
+
+        self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
+
+        expected_stats = {
+            "attempt_number": 3,
+            "delay_since_first_attempt": mock.ANY,
+            "idle_for": mock.ANY,
+            "start_time": mock.ANY,
+        }
+        self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
+        self.assertEqual(_retryable_test_with_stop.retry.statistics, {})
+
+        with mock.patch.object(
+            _retryable_test_with_stop.retry, "stop", tenacity.stop_after_attempt(1)
+        ):
+            try:
+                self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
+            except RetryError as exc:
+                expected_stats = {
+                    "attempt_number": 1,
+                    "delay_since_first_attempt": mock.ANY,
+                    "idle_for": mock.ANY,
+                    "start_time": mock.ANY,
+                }
+                self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
+                self.assertEqual(exc.last_attempt.attempt_number, 1)
+                self.assertEqual(_retryable_test_with_stop.retry.statistics, {})
+            else:
+                self.fail("RetryError should have been raised after 1 attempt")
+
 
 class TestRetryWith:
     def test_redefine_wait(self):
@@ -1479,21 +1517,21 @@ class TestStatistics(unittest.TestCase):
         def _foobar():
             return 42
 
-        self.assertEqual({}, _foobar.retry.statistics)
+        self.assertEqual({}, _foobar.statistics)
         _foobar()
-        self.assertEqual(1, _foobar.retry.statistics["attempt_number"])
+        self.assertEqual(1, _foobar.statistics["attempt_number"])
 
     def test_stats_failing(self):
         @retry(stop=tenacity.stop_after_attempt(2))
         def _foobar():
             raise ValueError(42)
 
-        self.assertEqual({}, _foobar.retry.statistics)
+        self.assertEqual({}, _foobar.statistics)
         try:
             _foobar()
         except Exception:  # noqa: B902
             pass
-        self.assertEqual(2, _foobar.retry.statistics["attempt_number"])
+        self.assertEqual(2, _foobar.statistics["attempt_number"])
 
 
 class TestRetryErrorCallback(unittest.TestCase):