Skip to content

Commit 31fe2d0

Browse files
authored
fix: Restore contents of retry attribute for wrapped functions (#484)
* Restore retry attribute in wrapped functions * Add tests for wrapped function attributes * Update docs and add release note
1 parent 33cd0e1 commit 31fe2d0

File tree

6 files changed

+147
-18
lines changed

6 files changed

+147
-18
lines changed

doc/source/index.rst

+31-5
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ retrying stuff.
124124
print("Stopping after 10 seconds")
125125
raise Exception
126126

127-
If you're on a tight deadline, and exceeding your delay time isn't ok,
128-
then you can give up on retries one attempt before you would exceed the delay.
127+
If you're on a tight deadline, and exceeding your delay time isn't ok,
128+
then you can give up on retries one attempt before you would exceed the delay.
129129

130130
.. testcode::
131131

@@ -362,7 +362,7 @@ Statistics
362362
~~~~~~~~~~
363363

364364
You can access the statistics about the retry made over a function by using the
365-
`retry` attribute attached to the function and its `statistics` attribute:
365+
`statistics` attribute attached to the function:
366366

367367
.. testcode::
368368

@@ -375,7 +375,7 @@ You can access the statistics about the retry made over a function by using the
375375
except Exception:
376376
pass
377377

378-
print(raise_my_exception.retry.statistics)
378+
print(raise_my_exception.statistics)
379379

380380
.. testoutput::
381381
:hide:
@@ -495,7 +495,7 @@ using the `retry_with` function attached to the wrapped function:
495495
except Exception:
496496
pass
497497

498-
print(raise_my_exception.retry.statistics)
498+
print(raise_my_exception.statistics)
499499

500500
.. testoutput::
501501
:hide:
@@ -514,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly:
514514
retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True)
515515
retryer(never_good_enough, 'I really do try')
516516

517+
You may also want to change the behaviour of a decorated function temporarily,
518+
like in tests to avoid unnecessary wait times. You can modify/patch the `retry`
519+
attribute attached to the function. Bear in mind this is a write-only attribute,
520+
statistics should be read from the function `statistics` attribute.
521+
522+
.. testcode::
523+
524+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
525+
def raise_my_exception():
526+
raise MyException("Fail")
527+
528+
from unittest import mock
529+
530+
with mock.patch.object(raise_my_exception.retry, "wait", wait_fixed(0)):
531+
try:
532+
raise_my_exception()
533+
except Exception:
534+
pass
535+
536+
print(raise_my_exception.statistics)
537+
538+
.. testoutput::
539+
:hide:
540+
541+
...
542+
517543
Retrying code block
518544
~~~~~~~~~~~~~~~~~~~
519545

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
fixes:
3+
- |
4+
Restore the value of the `retry` attribute for wrapped functions. Also,
5+
clarify that those attributes are write-only and statistics should be
6+
read from the function attribute directly.

tenacity/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn:
339339
return self.copy(*args, **kwargs).wraps(f)
340340

341341
# Preserve attributes
342-
wrapped_f.retry = wrapped_f # type: ignore[attr-defined]
342+
wrapped_f.retry = self # type: ignore[attr-defined]
343343
wrapped_f.retry_with = retry_with # type: ignore[attr-defined]
344344
wrapped_f.statistics = {} # type: ignore[attr-defined]
345345

tenacity/asyncio/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any:
189189
return await copy(fn, *args, **kwargs)
190190

191191
# Preserve attributes
192-
async_wrapped.retry = async_wrapped # type: ignore[attr-defined]
192+
async_wrapped.retry = self # type: ignore[attr-defined]
193193
async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined]
194194
async_wrapped.statistics = {} # type: ignore[attr-defined]
195195

tests/test_asyncio.py

+60-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import inspect
1818
import unittest
1919
from functools import wraps
20+
from unittest import mock
2021

2122
try:
2223
import trio
@@ -59,7 +60,7 @@ async def _retryable_coroutine(thing):
5960
@retry(stop=stop_after_attempt(2))
6061
async def _retryable_coroutine_with_2_attempts(thing):
6162
await asyncio.sleep(0.00001)
62-
thing.go()
63+
return thing.go()
6364

6465

6566
class TestAsyncio(unittest.TestCase):
@@ -394,6 +395,64 @@ async def test_async_retying_iterator(self):
394395
await _async_function(thing)
395396

396397

398+
class TestDecoratorWrapper(unittest.TestCase):
399+
@asynctest
400+
async def test_retry_function_attributes(self):
401+
"""Test that the wrapped function attributes are exposed as intended.
402+
403+
- statistics contains the value for the latest function run
404+
- retry object can be modified to change its behaviour (useful to patch in tests)
405+
- retry object statistics do not contain valid information
406+
"""
407+
408+
self.assertTrue(
409+
await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(1))
410+
)
411+
412+
expected_stats = {
413+
"attempt_number": 2,
414+
"delay_since_first_attempt": mock.ANY,
415+
"idle_for": mock.ANY,
416+
"start_time": mock.ANY,
417+
}
418+
self.assertEqual(
419+
_retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined]
420+
expected_stats,
421+
)
422+
self.assertEqual(
423+
_retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined]
424+
{},
425+
)
426+
427+
with mock.patch.object(
428+
_retryable_coroutine_with_2_attempts.retry, # type: ignore[attr-defined]
429+
"stop",
430+
tenacity.stop_after_attempt(1),
431+
):
432+
try:
433+
self.assertTrue(
434+
await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(2))
435+
)
436+
except RetryError as exc:
437+
expected_stats = {
438+
"attempt_number": 1,
439+
"delay_since_first_attempt": mock.ANY,
440+
"idle_for": mock.ANY,
441+
"start_time": mock.ANY,
442+
}
443+
self.assertEqual(
444+
_retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined]
445+
expected_stats,
446+
)
447+
self.assertEqual(exc.last_attempt.attempt_number, 1)
448+
self.assertEqual(
449+
_retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined]
450+
{},
451+
)
452+
else:
453+
self.fail("RetryError should have been raised after 1 attempt")
454+
455+
397456
# make sure mypy accepts passing an async sleep function
398457
# https://github.com/jd/tenacity/issues/399
399458
async def my_async_sleep(x: float) -> None:

tests/test_tenacity.py

+48-10
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from contextlib import contextmanager
2626
from copy import copy
2727
from fractions import Fraction
28+
from unittest import mock
2829

2930
import pytest
3031

@@ -1073,7 +1074,7 @@ def test_retry_until_exception_of_type_attempt_number(self):
10731074
_retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))
10741075
)
10751076
except NameError as e:
1076-
s = _retryable_test_with_unless_exception_type_name.retry.statistics
1077+
s = _retryable_test_with_unless_exception_type_name.statistics
10771078
self.assertTrue(s["attempt_number"] == 6)
10781079
print(e)
10791080
else:
@@ -1088,7 +1089,7 @@ def test_retry_until_exception_of_type_no_type(self):
10881089
)
10891090
)
10901091
except NameError as e:
1091-
s = _retryable_test_with_unless_exception_type_no_input.retry.statistics
1092+
s = _retryable_test_with_unless_exception_type_no_input.statistics
10921093
self.assertTrue(s["attempt_number"] == 6)
10931094
print(e)
10941095
else:
@@ -1111,7 +1112,7 @@ def test_retry_if_exception_message(self):
11111112
_retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))
11121113
)
11131114
except CustomError:
1114-
print(_retryable_test_if_exception_message_message.retry.statistics)
1115+
print(_retryable_test_if_exception_message_message.statistics)
11151116
self.fail("CustomError should've been retried from errormessage")
11161117

11171118
def test_retry_if_not_exception_message(self):
@@ -1122,7 +1123,7 @@ def test_retry_if_not_exception_message(self):
11221123
)
11231124
)
11241125
except CustomError:
1125-
s = _retryable_test_if_not_exception_message_message.retry.statistics
1126+
s = _retryable_test_if_not_exception_message_message.statistics
11261127
self.assertTrue(s["attempt_number"] == 1)
11271128

11281129
def test_retry_if_not_exception_message_delay(self):
@@ -1131,7 +1132,7 @@ def test_retry_if_not_exception_message_delay(self):
11311132
_retryable_test_not_exception_message_delay(NameErrorUntilCount(3))
11321133
)
11331134
except NameError:
1134-
s = _retryable_test_not_exception_message_delay.retry.statistics
1135+
s = _retryable_test_not_exception_message_delay.statistics
11351136
print(s["attempt_number"])
11361137
self.assertTrue(s["attempt_number"] == 4)
11371138

@@ -1151,7 +1152,7 @@ def test_retry_if_not_exception_message_match(self):
11511152
)
11521153
)
11531154
except CustomError:
1154-
s = _retryable_test_if_not_exception_message_message.retry.statistics
1155+
s = _retryable_test_if_not_exception_message_message.statistics
11551156
self.assertTrue(s["attempt_number"] == 1)
11561157

11571158
def test_retry_if_exception_cause_type(self):
@@ -1209,6 +1210,43 @@ def __call__(self):
12091210
h = retrying.wraps(Hello())
12101211
self.assertEqual(h(), "Hello")
12111212

1213+
def test_retry_function_attributes(self):
1214+
"""Test that the wrapped function attributes are exposed as intended.
1215+
1216+
- statistics contains the value for the latest function run
1217+
- retry object can be modified to change its behaviour (useful to patch in tests)
1218+
- retry object statistics do not contain valid information
1219+
"""
1220+
1221+
self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
1222+
1223+
expected_stats = {
1224+
"attempt_number": 3,
1225+
"delay_since_first_attempt": mock.ANY,
1226+
"idle_for": mock.ANY,
1227+
"start_time": mock.ANY,
1228+
}
1229+
self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
1230+
self.assertEqual(_retryable_test_with_stop.retry.statistics, {})
1231+
1232+
with mock.patch.object(
1233+
_retryable_test_with_stop.retry, "stop", tenacity.stop_after_attempt(1)
1234+
):
1235+
try:
1236+
self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
1237+
except RetryError as exc:
1238+
expected_stats = {
1239+
"attempt_number": 1,
1240+
"delay_since_first_attempt": mock.ANY,
1241+
"idle_for": mock.ANY,
1242+
"start_time": mock.ANY,
1243+
}
1244+
self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
1245+
self.assertEqual(exc.last_attempt.attempt_number, 1)
1246+
self.assertEqual(_retryable_test_with_stop.retry.statistics, {})
1247+
else:
1248+
self.fail("RetryError should have been raised after 1 attempt")
1249+
12121250

12131251
class TestRetryWith:
12141252
def test_redefine_wait(self):
@@ -1479,21 +1517,21 @@ def test_stats(self):
14791517
def _foobar():
14801518
return 42
14811519

1482-
self.assertEqual({}, _foobar.retry.statistics)
1520+
self.assertEqual({}, _foobar.statistics)
14831521
_foobar()
1484-
self.assertEqual(1, _foobar.retry.statistics["attempt_number"])
1522+
self.assertEqual(1, _foobar.statistics["attempt_number"])
14851523

14861524
def test_stats_failing(self):
14871525
@retry(stop=tenacity.stop_after_attempt(2))
14881526
def _foobar():
14891527
raise ValueError(42)
14901528

1491-
self.assertEqual({}, _foobar.retry.statistics)
1529+
self.assertEqual({}, _foobar.statistics)
14921530
try:
14931531
_foobar()
14941532
except Exception: # noqa: B902
14951533
pass
1496-
self.assertEqual(2, _foobar.retry.statistics["attempt_number"])
1534+
self.assertEqual(2, _foobar.statistics["attempt_number"])
14971535

14981536

14991537
class TestRetryErrorCallback(unittest.TestCase):

0 commit comments

Comments
 (0)