tenacity 8.4.1__tar.gz → 8.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {tenacity-8.4.1 → tenacity-8.5.0}/.github/workflows/ci.yaml +1 -1
  2. {tenacity-8.4.1 → tenacity-8.5.0}/.github/workflows/deploy.yaml +1 -1
  3. {tenacity-8.4.1/tenacity.egg-info → tenacity-8.5.0}/PKG-INFO +1 -1
  4. {tenacity-8.4.1 → tenacity-8.5.0}/README.rst +31 -5
  5. {tenacity-8.4.1 → tenacity-8.5.0}/doc/source/index.rst +31 -5
  6. tenacity-8.5.0/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml +4 -0
  7. tenacity-8.5.0/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml +6 -0
  8. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/__init__.py +7 -1
  9. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/asyncio/__init__.py +9 -4
  10. {tenacity-8.4.1 → tenacity-8.5.0/tenacity.egg-info}/PKG-INFO +1 -1
  11. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity.egg-info/SOURCES.txt +3 -0
  12. {tenacity-8.4.1 → tenacity-8.5.0}/tests/test_asyncio.py +60 -1
  13. tenacity-8.5.0/tests/test_issue_478.py +118 -0
  14. {tenacity-8.4.1 → tenacity-8.5.0}/tests/test_tenacity.py +48 -10
  15. {tenacity-8.4.1 → tenacity-8.5.0}/.editorconfig +0 -0
  16. {tenacity-8.4.1 → tenacity-8.5.0}/.github/dependabot.yml +0 -0
  17. {tenacity-8.4.1 → tenacity-8.5.0}/.gitignore +0 -0
  18. {tenacity-8.4.1 → tenacity-8.5.0}/.mergify.yml +0 -0
  19. {tenacity-8.4.1 → tenacity-8.5.0}/.readthedocs.yml +0 -0
  20. {tenacity-8.4.1 → tenacity-8.5.0}/LICENSE +0 -0
  21. {tenacity-8.4.1 → tenacity-8.5.0}/doc/source/api.rst +0 -0
  22. {tenacity-8.4.1 → tenacity-8.5.0}/doc/source/changelog.rst +0 -0
  23. {tenacity-8.4.1 → tenacity-8.5.0}/doc/source/conf.py +0 -0
  24. {tenacity-8.4.1 → tenacity-8.5.0}/pyproject.toml +0 -0
  25. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml +0 -0
  26. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml +0 -0
  27. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml +0 -0
  28. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add-reno-d1ab5710f272650a.yaml +0 -0
  29. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml +0 -0
  30. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml +0 -0
  31. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml +0 -0
  32. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml +0 -0
  33. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml +0 -0
  34. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml +0 -0
  35. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/after_log-50f4d73b24ce9203.yaml +0 -0
  36. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/allow-mocking-of-nap-sleep-6679c50e702446f1.yaml +0 -0
  37. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/annotate_code-197b93130df14042.yaml +0 -0
  38. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/before_sleep_log-improvements-d8149274dfb37d7c.yaml +0 -0
  39. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml +0 -0
  40. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml +0 -0
  41. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml +0 -0
  42. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml +0 -0
  43. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml +0 -0
  44. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml +0 -0
  45. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml +0 -0
  46. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml +0 -0
  47. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml +0 -0
  48. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml +0 -0
  49. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml +0 -0
  50. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml +0 -0
  51. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/pr320-py3-only-wheel-tag.yaml +0 -0
  52. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml +0 -0
  53. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/remove-py36-876c0416cf279d15.yaml +0 -0
  54. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml +0 -0
  55. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml +0 -0
  56. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml +0 -0
  57. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml +0 -0
  58. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml +0 -0
  59. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml +0 -0
  60. {tenacity-8.4.1 → tenacity-8.5.0}/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml +0 -0
  61. {tenacity-8.4.1 → tenacity-8.5.0}/reno.yaml +0 -0
  62. {tenacity-8.4.1 → tenacity-8.5.0}/setup.cfg +0 -0
  63. {tenacity-8.4.1 → tenacity-8.5.0}/setup.py +0 -0
  64. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/_utils.py +0 -0
  65. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/after.py +0 -0
  66. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/asyncio/retry.py +0 -0
  67. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/before.py +0 -0
  68. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/before_sleep.py +0 -0
  69. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/nap.py +0 -0
  70. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/py.typed +0 -0
  71. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/retry.py +0 -0
  72. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/stop.py +0 -0
  73. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/tornadoweb.py +0 -0
  74. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity/wait.py +0 -0
  75. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity.egg-info/dependency_links.txt +0 -0
  76. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity.egg-info/requires.txt +0 -0
  77. {tenacity-8.4.1 → tenacity-8.5.0}/tenacity.egg-info/top_level.txt +0 -0
  78. {tenacity-8.4.1 → tenacity-8.5.0}/tests/__init__.py +0 -0
  79. {tenacity-8.4.1 → tenacity-8.5.0}/tests/test_after.py +0 -0
  80. {tenacity-8.4.1 → tenacity-8.5.0}/tests/test_tornado.py +0 -0
  81. {tenacity-8.4.1 → tenacity-8.5.0}/tests/test_utils.py +0 -0
  82. {tenacity-8.4.1 → tenacity-8.5.0}/tox.ini +0 -0
@@ -34,7 +34,7 @@ jobs:
34
34
  tox: mypy
35
35
  steps:
36
36
  - name: Checkout 🛎️
37
- uses: actions/checkout@v4.1.6
37
+ uses: actions/checkout@v4.1.7
38
38
  with:
39
39
  fetch-depth: 0
40
40
 
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  steps:
13
13
  - name: Checkout 🛎️
14
- uses: actions/checkout@v4.1.6
14
+ uses: actions/checkout@v4.1.7
15
15
  with:
16
16
  fetch-depth: 0
17
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tenacity
3
- Version: 8.4.1
3
+ Version: 8.5.0
4
4
  Summary: Retry code until it succeeds
5
5
  Home-page: https://github.com/jd/tenacity
6
6
  Author: Julien Danjou
@@ -124,8 +124,8 @@ retrying stuff.
124
124
  print("Stopping after 10 seconds")
125
125
  raise Exception
126
126
 
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.
129
129
 
130
130
  .. testcode::
131
131
 
@@ -362,7 +362,7 @@ Statistics
362
362
  ~~~~~~~~~~
363
363
 
364
364
  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:
366
366
 
367
367
  .. testcode::
368
368
 
@@ -375,7 +375,7 @@ You can access the statistics about the retry made over a function by using the
375
375
  except Exception:
376
376
  pass
377
377
 
378
- print(raise_my_exception.retry.statistics)
378
+ print(raise_my_exception.statistics)
379
379
 
380
380
  .. testoutput::
381
381
  :hide:
@@ -495,7 +495,7 @@ using the `retry_with` function attached to the wrapped function:
495
495
  except Exception:
496
496
  pass
497
497
 
498
- print(raise_my_exception.retry.statistics)
498
+ print(raise_my_exception.statistics)
499
499
 
500
500
  .. testoutput::
501
501
  :hide:
@@ -514,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly:
514
514
  retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True)
515
515
  retryer(never_good_enough, 'I really do try')
516
516
 
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
+
517
543
  Retrying code block
518
544
  ~~~~~~~~~~~~~~~~~~~
519
545
 
@@ -124,8 +124,8 @@ retrying stuff.
124
124
  print("Stopping after 10 seconds")
125
125
  raise Exception
126
126
 
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.
129
129
 
130
130
  .. testcode::
131
131
 
@@ -362,7 +362,7 @@ Statistics
362
362
  ~~~~~~~~~~
363
363
 
364
364
  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:
366
366
 
367
367
  .. testcode::
368
368
 
@@ -375,7 +375,7 @@ You can access the statistics about the retry made over a function by using the
375
375
  except Exception:
376
376
  pass
377
377
 
378
- print(raise_my_exception.retry.statistics)
378
+ print(raise_my_exception.statistics)
379
379
 
380
380
  .. testoutput::
381
381
  :hide:
@@ -495,7 +495,7 @@ using the `retry_with` function attached to the wrapped function:
495
495
  except Exception:
496
496
  pass
497
497
 
498
- print(raise_my_exception.retry.statistics)
498
+ print(raise_my_exception.statistics)
499
499
 
500
500
  .. testoutput::
501
501
  :hide:
@@ -514,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly:
514
514
  retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True)
515
515
  retryer(never_good_enough, 'I really do try')
516
516
 
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
+
517
543
  Retrying code block
518
544
  ~~~~~~~~~~~~~~~~~~~
519
545
 
@@ -0,0 +1,4 @@
1
+ ---
2
+ fixes:
3
+ - |
4
+ Avoid overwriting local contexts when applying the retry decorator.
@@ -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.
@@ -329,13 +329,19 @@ class BaseRetrying(ABC):
329
329
  f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")
330
330
  )
331
331
  def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any:
332
- return self(f, *args, **kw)
332
+ # Always create a copy to prevent overwriting the local contexts when
333
+ # calling the same wrapped functions multiple times in the same stack
334
+ copy = self.copy()
335
+ wrapped_f.statistics = copy.statistics # type: ignore[attr-defined]
336
+ return copy(f, *args, **kw)
333
337
 
334
338
  def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn:
335
339
  return self.copy(*args, **kwargs).wraps(f)
336
340
 
341
+ # Preserve attributes
337
342
  wrapped_f.retry = self # type: ignore[attr-defined]
338
343
  wrapped_f.retry_with = retry_with # type: ignore[attr-defined]
344
+ wrapped_f.statistics = {} # type: ignore[attr-defined]
339
345
 
340
346
  return wrapped_f # type: ignore[return-value]
341
347
 
@@ -175,18 +175,23 @@ class AsyncRetrying(BaseRetrying):
175
175
  raise StopAsyncIteration
176
176
 
177
177
  def wraps(self, fn: WrappedFn) -> WrappedFn:
178
- fn = super().wraps(fn)
178
+ wrapped = super().wraps(fn)
179
179
  # Ensure wrapper is recognized as a coroutine function.
180
180
 
181
181
  @functools.wraps(
182
182
  fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")
183
183
  )
184
184
  async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any:
185
- return await fn(*args, **kwargs)
185
+ # Always create a copy to prevent overwriting the local contexts when
186
+ # calling the same wrapped functions multiple times in the same stack
187
+ copy = self.copy()
188
+ async_wrapped.statistics = copy.statistics # type: ignore[attr-defined]
189
+ return await copy(fn, *args, **kwargs)
186
190
 
187
191
  # Preserve attributes
188
- async_wrapped.retry = fn.retry # type: ignore[attr-defined]
189
- async_wrapped.retry_with = fn.retry_with # type: ignore[attr-defined]
192
+ async_wrapped.retry = self # type: ignore[attr-defined]
193
+ async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined]
194
+ async_wrapped.statistics = {} # type: ignore[attr-defined]
190
195
 
191
196
  return async_wrapped # type: ignore[return-value]
192
197
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tenacity
3
- Version: 8.4.1
3
+ Version: 8.5.0
4
4
  Summary: Retry code until it succeeds
5
5
  Home-page: https://github.com/jd/tenacity
6
6
  Author: Julien Danjou
@@ -37,6 +37,8 @@ releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml
37
37
  releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml
38
38
  releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml
39
39
  releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml
40
+ releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml
41
+ releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml
40
42
  releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml
41
43
  releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml
42
44
  releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml
@@ -73,6 +75,7 @@ tenacity/asyncio/retry.py
73
75
  tests/__init__.py
74
76
  tests/test_after.py
75
77
  tests/test_asyncio.py
78
+ tests/test_issue_478.py
76
79
  tests/test_tenacity.py
77
80
  tests/test_tornado.py
78
81
  tests/test_utils.py
@@ -17,6 +17,7 @@ import asyncio
17
17
  import inspect
18
18
  import unittest
19
19
  from functools import wraps
20
+ from unittest import mock
20
21
 
21
22
  try:
22
23
  import trio
@@ -59,7 +60,7 @@ async def _retryable_coroutine(thing):
59
60
  @retry(stop=stop_after_attempt(2))
60
61
  async def _retryable_coroutine_with_2_attempts(thing):
61
62
  await asyncio.sleep(0.00001)
62
- thing.go()
63
+ return thing.go()
63
64
 
64
65
 
65
66
  class TestAsyncio(unittest.TestCase):
@@ -394,6 +395,64 @@ class TestContextManager(unittest.TestCase):
394
395
  await _async_function(thing)
395
396
 
396
397
 
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
+
397
456
  # make sure mypy accepts passing an async sleep function
398
457
  # https://github.com/jd/tenacity/issues/399
399
458
  async def my_async_sleep(x: float) -> None:
@@ -0,0 +1,118 @@
1
+ import asyncio
2
+ import typing
3
+ import unittest
4
+
5
+ from functools import wraps
6
+
7
+ from tenacity import RetryCallState, retry
8
+
9
+
10
+ def asynctest(
11
+ callable_: typing.Callable[..., typing.Any],
12
+ ) -> typing.Callable[..., typing.Any]:
13
+ @wraps(callable_)
14
+ def wrapper(*a: typing.Any, **kw: typing.Any) -> typing.Any:
15
+ loop = asyncio.get_event_loop()
16
+ return loop.run_until_complete(callable_(*a, **kw))
17
+
18
+ return wrapper
19
+
20
+
21
+ MAX_RETRY_FIX_ATTEMPTS = 2
22
+
23
+
24
+ class TestIssue478(unittest.TestCase):
25
+ def test_issue(self) -> None:
26
+ results = []
27
+
28
+ def do_retry(retry_state: RetryCallState) -> bool:
29
+ outcome = retry_state.outcome
30
+ assert outcome
31
+ ex = outcome.exception()
32
+ _subject_: str = retry_state.args[0]
33
+
34
+ if _subject_ == "Fix": # no retry on fix failure
35
+ return False
36
+
37
+ if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS:
38
+ return False
39
+
40
+ if ex:
41
+ do_fix_work()
42
+ return True
43
+
44
+ return False
45
+
46
+ @retry(reraise=True, retry=do_retry)
47
+ def _do_work(subject: str) -> None:
48
+ if subject == "Error":
49
+ results.append(f"{subject} is not working")
50
+ raise Exception(f"{subject} is not working")
51
+ results.append(f"{subject} is working")
52
+
53
+ def do_any_work(subject: str) -> None:
54
+ _do_work(subject)
55
+
56
+ def do_fix_work() -> None:
57
+ _do_work("Fix")
58
+
59
+ try:
60
+ do_any_work("Error")
61
+ except Exception as exc:
62
+ assert str(exc) == "Error is not working"
63
+ else:
64
+ assert False, "No exception caught"
65
+
66
+ assert results == [
67
+ "Error is not working",
68
+ "Fix is working",
69
+ "Error is not working",
70
+ ]
71
+
72
+ @asynctest
73
+ async def test_async(self) -> None:
74
+ results = []
75
+
76
+ async def do_retry(retry_state: RetryCallState) -> bool:
77
+ outcome = retry_state.outcome
78
+ assert outcome
79
+ ex = outcome.exception()
80
+ _subject_: str = retry_state.args[0]
81
+
82
+ if _subject_ == "Fix": # no retry on fix failure
83
+ return False
84
+
85
+ if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS:
86
+ return False
87
+
88
+ if ex:
89
+ await do_fix_work()
90
+ return True
91
+
92
+ return False
93
+
94
+ @retry(reraise=True, retry=do_retry)
95
+ async def _do_work(subject: str) -> None:
96
+ if subject == "Error":
97
+ results.append(f"{subject} is not working")
98
+ raise Exception(f"{subject} is not working")
99
+ results.append(f"{subject} is working")
100
+
101
+ async def do_any_work(subject: str) -> None:
102
+ await _do_work(subject)
103
+
104
+ async def do_fix_work() -> None:
105
+ await _do_work("Fix")
106
+
107
+ try:
108
+ await do_any_work("Error")
109
+ except Exception as exc:
110
+ assert str(exc) == "Error is not working"
111
+ else:
112
+ assert False, "No exception caught"
113
+
114
+ assert results == [
115
+ "Error is not working",
116
+ "Fix is working",
117
+ "Error is not working",
118
+ ]
@@ -25,6 +25,7 @@ import warnings
25
25
  from contextlib import contextmanager
26
26
  from copy import copy
27
27
  from fractions import Fraction
28
+ from unittest import mock
28
29
 
29
30
  import pytest
30
31
 
@@ -1073,7 +1074,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1073
1074
  _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))
1074
1075
  )
1075
1076
  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
1077
1078
  self.assertTrue(s["attempt_number"] == 6)
1078
1079
  print(e)
1079
1080
  else:
@@ -1088,7 +1089,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1088
1089
  )
1089
1090
  )
1090
1091
  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
1092
1093
  self.assertTrue(s["attempt_number"] == 6)
1093
1094
  print(e)
1094
1095
  else:
@@ -1111,7 +1112,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1111
1112
  _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))
1112
1113
  )
1113
1114
  except CustomError:
1114
- print(_retryable_test_if_exception_message_message.retry.statistics)
1115
+ print(_retryable_test_if_exception_message_message.statistics)
1115
1116
  self.fail("CustomError should've been retried from errormessage")
1116
1117
 
1117
1118
  def test_retry_if_not_exception_message(self):
@@ -1122,7 +1123,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1122
1123
  )
1123
1124
  )
1124
1125
  except CustomError:
1125
- s = _retryable_test_if_not_exception_message_message.retry.statistics
1126
+ s = _retryable_test_if_not_exception_message_message.statistics
1126
1127
  self.assertTrue(s["attempt_number"] == 1)
1127
1128
 
1128
1129
  def test_retry_if_not_exception_message_delay(self):
@@ -1131,7 +1132,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1131
1132
  _retryable_test_not_exception_message_delay(NameErrorUntilCount(3))
1132
1133
  )
1133
1134
  except NameError:
1134
- s = _retryable_test_not_exception_message_delay.retry.statistics
1135
+ s = _retryable_test_not_exception_message_delay.statistics
1135
1136
  print(s["attempt_number"])
1136
1137
  self.assertTrue(s["attempt_number"] == 4)
1137
1138
 
@@ -1151,7 +1152,7 @@ class TestDecoratorWrapper(unittest.TestCase):
1151
1152
  )
1152
1153
  )
1153
1154
  except CustomError:
1154
- s = _retryable_test_if_not_exception_message_message.retry.statistics
1155
+ s = _retryable_test_if_not_exception_message_message.statistics
1155
1156
  self.assertTrue(s["attempt_number"] == 1)
1156
1157
 
1157
1158
  def test_retry_if_exception_cause_type(self):
@@ -1209,6 +1210,43 @@ class TestDecoratorWrapper(unittest.TestCase):
1209
1210
  h = retrying.wraps(Hello())
1210
1211
  self.assertEqual(h(), "Hello")
1211
1212
 
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
+
1212
1250
 
1213
1251
  class TestRetryWith:
1214
1252
  def test_redefine_wait(self):
@@ -1479,21 +1517,21 @@ class TestStatistics(unittest.TestCase):
1479
1517
  def _foobar():
1480
1518
  return 42
1481
1519
 
1482
- self.assertEqual({}, _foobar.retry.statistics)
1520
+ self.assertEqual({}, _foobar.statistics)
1483
1521
  _foobar()
1484
- self.assertEqual(1, _foobar.retry.statistics["attempt_number"])
1522
+ self.assertEqual(1, _foobar.statistics["attempt_number"])
1485
1523
 
1486
1524
  def test_stats_failing(self):
1487
1525
  @retry(stop=tenacity.stop_after_attempt(2))
1488
1526
  def _foobar():
1489
1527
  raise ValueError(42)
1490
1528
 
1491
- self.assertEqual({}, _foobar.retry.statistics)
1529
+ self.assertEqual({}, _foobar.statistics)
1492
1530
  try:
1493
1531
  _foobar()
1494
1532
  except Exception: # noqa: B902
1495
1533
  pass
1496
- self.assertEqual(2, _foobar.retry.statistics["attempt_number"])
1534
+ self.assertEqual(2, _foobar.statistics["attempt_number"])
1497
1535
 
1498
1536
 
1499
1537
  class TestRetryErrorCallback(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes