Skip to content

Commit

Permalink
fix: Restore contents of retry attribute for wrapped functions (#484)
Browse files Browse the repository at this point in the history
* Restore retry attribute in wrapped functions

* Add tests for wrapped function attributes

* Update docs and add release note
  • Loading branch information
hasier authored Jul 5, 2024
1 parent 33cd0e1 commit 31fe2d0
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 18 deletions.
36 changes: 31 additions & 5 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down Expand Up @@ -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::

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
~~~~~~~~~~~~~~~~~~~

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion tenacity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn:
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]

Expand Down
2 changes: 1 addition & 1 deletion tenacity/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any:
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]

Expand Down
61 changes: 60 additions & 1 deletion tests/test_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import inspect
import unittest
from functools import wraps
from unittest import mock

try:
import trio
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -394,6 +395,64 @@ async def test_async_retying_iterator(self):
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:
Expand Down
58 changes: 48 additions & 10 deletions tests/test_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from contextlib import contextmanager
from copy import copy
from fractions import Fraction
from unittest import mock

import pytest

Expand Down Expand Up @@ -1073,7 +1074,7 @@ def test_retry_until_exception_of_type_attempt_number(self):
_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:
Expand All @@ -1088,7 +1089,7 @@ def test_retry_until_exception_of_type_no_type(self):
)
)
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:
Expand All @@ -1111,7 +1112,7 @@ def test_retry_if_exception_message(self):
_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):
Expand All @@ -1122,7 +1123,7 @@ def test_retry_if_not_exception_message(self):
)
)
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):
Expand All @@ -1131,7 +1132,7 @@ def test_retry_if_not_exception_message_delay(self):
_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)

Expand All @@ -1151,7 +1152,7 @@ def test_retry_if_not_exception_message_match(self):
)
)
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):
Expand Down Expand Up @@ -1209,6 +1210,43 @@ def __call__(self):
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):
Expand Down Expand Up @@ -1479,21 +1517,21 @@ def test_stats(self):
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):
Expand Down

0 comments on commit 31fe2d0

Please sign in to comment.