Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Restore contents of retry attribute for wrapped functions #484

Merged
merged 3 commits into from
Jul 5, 2024
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
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