From c7d45614dcbb1e13dbb946a6578fd5bf280f09bb Mon Sep 17 00:00:00 2001 From: Daniel Fortunov Date: Mon, 26 Oct 2020 09:06:13 +0000 Subject: [PATCH 001/124] Fix unit tests failing on Windows (#260) * start_time == outcome_timestamp is possible * logging.Formatter uses explicitly hard-coded `\n` newlines (as opposed to using the platform-specific os.linesep) --- tenacity/tests/test_tenacity.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index 223b19a3..ecdb006a 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -14,7 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. import logging -import os import re import sys import time @@ -507,8 +506,8 @@ def returnval(): self.assertEqual(retry_state.kwargs, {}) self.assertEqual(retry_state.outcome.result(), 123) self.assertEqual(retry_state.attempt_number, 1) - self.assertGreater(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, + retry_state.start_time) def dying(): raise Exception("Broken") @@ -521,8 +520,8 @@ def dying(): self.assertEqual(retry_state.kwargs, {}) self.assertEqual(str(retry_state.outcome.exception()), 'Broken') self.assertEqual(retry_state.attempt_number, 1) - self.assertGreater(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, + retry_state.start_time) class TestRetryConditions(unittest.TestCase): @@ -1198,7 +1197,7 @@ def test_before_sleep_log_raises_with_exc_info(self): etalon_re = re.compile(r"^Retrying .* in 0\.01 seconds as it raised " r"(IO|OS)Error: Hi there, I'm an IOError\.{0}" r"Traceback \(most recent call last\):{0}" - r".*$".format(os.linesep), + r".*$".format('\n'), flags=re.MULTILINE) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format From c6732540c041063dbae8c1703a99ad7e84f8f73e Mon Sep 17 00:00:00 2001 From: Daniel Fortunov Date: Mon, 26 Oct 2020 09:09:33 +0000 Subject: [PATCH 002/124] Always return booleans from all retry_* methods (#261) ...rather than sometimes allowing a default return of `None` Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tenacity/retry.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tenacity/retry.py b/tenacity/retry.py index 8e4fab32..283e6202 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -67,6 +67,8 @@ def __init__(self, predicate): def __call__(self, retry_state): if retry_state.outcome.failed: return self.predicate(retry_state.outcome.exception()) + else: + return False class retry_if_exception_type(retry_if_exception): @@ -104,6 +106,8 @@ def __init__(self, predicate): def __call__(self, retry_state): if not retry_state.outcome.failed: return self.predicate(retry_state.outcome.result()) + else: + return False class retry_if_not_result(retry_base): @@ -116,6 +120,8 @@ def __init__(self, predicate): def __call__(self, retry_state): if not retry_state.outcome.failed: return not self.predicate(retry_state.outcome.result()) + else: + return False class retry_if_exception_message(retry_if_exception): From be9bccbb731d28ab0bcd8a4ee5acae70988bf593 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 2 Nov 2020 17:02:22 +0100 Subject: [PATCH 003/124] Fix asyncio.iscoroutinefunction(f) check for decorated function (#263) Co-authored-by: Slava Skvortsov --- tenacity/_asyncio.py | 8 +++++++- tenacity/tests/test_asyncio.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 6b24b2e1..35d7eb93 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -15,7 +15,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import asyncio import sys from asyncio import sleep @@ -71,3 +71,9 @@ async def __anext__(self): await self.sleep(do) else: return do + + def wraps(self, fn): + fn = super().wraps(fn) + # Ensure wrapper is recognized as a coroutine function. + fn._is_coroutine = asyncio.coroutines._is_coroutine + return fn diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index 617a0ef5..f34a40af 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -58,6 +58,10 @@ async def test_retry(self): await _retryable_coroutine(thing) assert thing.counter == thing.count + @asynctest + async def test_iscoroutinefunction(self): + assert asyncio.iscoroutinefunction(_retryable_coroutine) + @asynctest async def test_retry_using_async_retying(self): thing = NoIOErrorAfterCount(5) From db0f9586e02d5235d571dc549b04ff3d5f13de47 Mon Sep 17 00:00:00 2001 From: Zefeng Zhu <414731811@qq.com> Date: Tue, 15 Dec 2020 17:28:27 +0800 Subject: [PATCH 004/124] Fix iscoroutinefunction check (for both inspect & asyncio) (#265) * Fix iscoroutinefunction check (inspect & asyncio) * Add real async wrap & preserve retry attributes * Adjust to pep8 * Add test to ensure retryable_coroutine attributes * Fix `blank line contains whitespace` pep8 error --- tenacity/_asyncio.py | 12 +++++++++--- tenacity/tests/test_asyncio.py | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 35d7eb93..33caefaa 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -15,7 +15,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import sys from asyncio import sleep @@ -75,5 +74,12 @@ async def __anext__(self): def wraps(self, fn): fn = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. - fn._is_coroutine = asyncio.coroutines._is_coroutine - return fn + + async def async_wrapped(*args, **kwargs): + return await fn(*args, **kwargs) + + # Preserve attributes + async_wrapped.retry = fn.retry + async_wrapped.retry_with = fn.retry_with + + return async_wrapped diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index f34a40af..3e3b33b1 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -14,6 +14,7 @@ # limitations under the License. import asyncio +import inspect import unittest import six @@ -61,6 +62,7 @@ async def test_retry(self): @asynctest async def test_iscoroutinefunction(self): assert asyncio.iscoroutinefunction(_retryable_coroutine) + assert inspect.iscoroutinefunction(_retryable_coroutine) @asynctest async def test_retry_using_async_retying(self): @@ -80,6 +82,10 @@ async def test_stop_after_attempt(self): def test_repr(self): repr(tasyncio.AsyncRetrying()) + def test_retry_attributes(self): + assert hasattr(_retryable_coroutine, 'retry') + assert hasattr(_retryable_coroutine, 'retry_with') + @asynctest async def test_attempt_number_is_correct_for_interleaved_coroutines(self): From 98f7da70f867b70dd4a47135290eedc7b36b3723 Mon Sep 17 00:00:00 2001 From: Christian Hartung Date: Wed, 16 Dec 2020 16:20:08 -0300 Subject: [PATCH 005/124] Add call method to AsyncRetying (#267) --- tenacity/__init__.py | 12 ++++++------ tenacity/tests/test_asyncio.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 336f4f65..c224bb67 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -404,6 +404,12 @@ def __iter__(self): def __call__(self, *args, **kwargs): pass + def call(self, *args, **kwargs): + """Use ``__call__`` instead because this method is deprecated.""" + warnings.warn("'call()' method is deprecated. " + + "Use '__call__()' instead", DeprecationWarning) + return self.__call__(*args, **kwargs) + class Retrying(BaseRetrying): """Retrying controller.""" @@ -428,12 +434,6 @@ def __call__(self, fn, *args, **kwargs): else: return do - def call(self, *args, **kwargs): - """Use ``__call__`` instead because this method is deprecated.""" - warnings.warn("'Retrying.call()' method is deprecated. " + - "Use 'Retrying.__call__()' instead", DeprecationWarning) - return self.__call__(*args, **kwargs) - class Future(futures.Future): """Encapsulates a (future or past) attempted call to a target function.""" diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index 3e3b33b1..cfd664cc 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -17,6 +17,8 @@ import inspect import unittest +import pytest + import six from tenacity import AsyncRetrying, RetryError @@ -71,6 +73,14 @@ async def test_retry_using_async_retying(self): await retrying(_async_function, thing) assert thing.counter == thing.count + @asynctest + async def test_retry_using_async_retying_legacy_method(self): + thing = NoIOErrorAfterCount(5) + retrying = AsyncRetrying() + with pytest.warns(DeprecationWarning): + await retrying.call(_async_function, thing) + assert thing.counter == thing.count + @asynctest async def test_stop_after_attempt(self): thing = NoIOErrorAfterCount(2) From 8d629023f8c95a729c915ff4c4bf415ad28f59f9 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 16 Dec 2020 20:21:40 +0100 Subject: [PATCH 006/124] fix: remove saythanks (dead) --- doc/source/index.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index af3c5bba..370432fc 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -6,9 +6,6 @@ Tenacity .. image:: https://circleci.com/gh/jd/tenacity.svg?style=svg :target: https://circleci.com/gh/jd/tenacity -.. image:: https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg - :target: https://saythanks.io/to/jd - .. image:: https://img.shields.io/endpoint.svg?url=https://dashboard.mergify.io/badges/jd/tenacity&style=flat :target: https://mergify.io :alt: Mergify Status From 2214708c7e95338bd64700fae399953364148e52 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 29 Jan 2021 16:16:13 +0100 Subject: [PATCH 007/124] Pull request for fix-ci (#276) * chore: add missing noqa statements * docs: fix autoinstanceattribute --- doc/source/index.rst | 20 ++++++++++---------- tenacity/__init__.py | 2 +- tenacity/_asyncio.py | 2 +- tenacity/tests/test_tenacity.py | 2 +- tenacity/tornadoweb.py | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index 370432fc..9334d512 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -382,36 +382,36 @@ RetryCallState Constant attributes: - .. autoinstanceattribute:: start_time(float) + .. autoattribute:: start_time(float) :annotation: - .. autoinstanceattribute:: retry_object(BaseRetrying) + .. autoattribute:: retry_object(BaseRetrying) :annotation: - .. autoinstanceattribute:: fn(callable) + .. autoattribute:: fn(callable) :annotation: - .. autoinstanceattribute:: args(tuple) + .. autoattribute:: args(tuple) :annotation: - .. autoinstanceattribute:: kwargs(dict) + .. autoattribute:: kwargs(dict) :annotation: Variable attributes: - .. autoinstanceattribute:: attempt_number(int) + .. autoattribute:: attempt_number(int) :annotation: - .. autoinstanceattribute:: outcome(tenacity.Future or None) + .. autoattribute:: outcome(tenacity.Future or None) :annotation: - .. autoinstanceattribute:: outcome_timestamp(float or None) + .. autoattribute:: outcome_timestamp(float or None) :annotation: - .. autoinstanceattribute:: idle_for(float) + .. autoattribute:: idle_for(float) :annotation: - .. autoinstanceattribute:: next_action(tenacity.RetryAction or None) + .. autoattribute:: next_action(tenacity.RetryAction or None) :annotation: Other Custom Callbacks diff --git a/tenacity/__init__.py b/tenacity/__init__.py index c224bb67..9a6375b9 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -424,7 +424,7 @@ def __call__(self, fn, *args, **kwargs): if isinstance(do, DoAttempt): try: result = fn(*args, **kwargs) - except BaseException: + except BaseException: # noqa: B902 retry_state.set_exception(sys.exc_info()) else: retry_state.set_result(result) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 33caefaa..de0f5fc2 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -43,7 +43,7 @@ async def __call__(self, fn, *args, **kwargs): if isinstance(do, DoAttempt): try: result = await fn(*args, **kwargs) - except BaseException: + except BaseException: # noqa: B902 retry_state.set_exception(sys.exc_info()) else: retry_state.set_result(result) diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index ecdb006a..8cee38f6 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -1317,7 +1317,7 @@ def _foobar(): self.assertEqual({}, _foobar.retry.statistics) try: _foobar() - except Exception: + except Exception: # noqa: B902 pass self.assertEqual(2, _foobar.retry.statistics['attempt_number']) diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 27dd349a..bf734477 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -42,7 +42,7 @@ def __call__(self, fn, *args, **kwargs): if isinstance(do, DoAttempt): try: result = yield fn(*args, **kwargs) - except BaseException: + except BaseException: # noqa: B902 retry_state.set_exception(sys.exc_info()) else: retry_state.set_result(result) From 5e8e97aa325dd15d0cf8bd2ead08f9b288f09b31 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 29 Jan 2021 16:04:26 +0100 Subject: [PATCH 008/124] chore: add support for Python 3.9 --- .circleci/config.yml | 14 ++++++++++++-- .mergify.yml | 2 ++ setup.cfg | 1 + tox.ini | 8 ++++---- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a48c80ad..43a8a4ed 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 jobs: pep8: docker: - - image: circleci/python:3.8 + - image: circleci/python:3.9 steps: - checkout - run: @@ -55,9 +55,18 @@ jobs: command: | sudo pip install tox tox -e py38 + py39: + docker: + - image: circleci/python:3.9 + steps: + - checkout + - run: + command: | + sudo pip install tox + tox -e py39 deploy: docker: - - image: circleci/python:3.8 + - image: circleci/python:3.9 steps: - checkout - run: | @@ -90,6 +99,7 @@ workflows: - py36 - py37 - py38 + - py39 - deploy: filters: tags: diff --git a/.mergify.yml b/.mergify.yml index c3c6be04..5fdbee1b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -7,6 +7,7 @@ pull_request_rules: - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" + - "status-success=ci/circleci: py39" - "#approved-reviews-by>=1" - label!=work-in-progress actions: @@ -22,6 +23,7 @@ pull_request_rules: - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" + - "status-success=ci/circleci: py39" - label!=work-in-progress actions: merge: diff --git a/setup.cfg b/setup.cfg index 5cc3cb18..ebe4e7de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifier = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Topic :: Utilities [options] diff --git a/tox.ini b/tox.ini index 88076793..d9a7528c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py35, py36, py37, py38, pep8, pypy +envlist = py27, py35, py36, py37, py38, py39, pep8, pypy [testenv] usedevelop = True @@ -10,9 +10,9 @@ deps = typeguard;python_version>='3.0' commands = py{27,py}: pytest --ignore='tenacity/tests/test_asyncio.py' {posargs} - py3{5,6,7,8}: pytest {posargs} - py3{5,6,7,8}: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{5,6,7,8}: sphinx-build -a -E -W -b html doc/source doc/build + py3{5,6,7,8,9}: pytest {posargs} + py3{5,6,7,8,9}: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{5,6,7,8,9}: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 250b27e47712e42e096a68a3c3518da8de0e90ba Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 29 Jan 2021 16:20:13 +0100 Subject: [PATCH 009/124] refactor: remove old compat support code (#274) --- doc/source/index.rst | 92 -------- tenacity/__init__.py | 44 +--- tenacity/compat.py | 299 -------------------------- tenacity/retry.py | 19 +- tenacity/stop.py | 18 +- tenacity/tests/test_tenacity.py | 361 +++++++++++--------------------- tenacity/wait.py | 18 +- 7 files changed, 141 insertions(+), 710 deletions(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index 9334d512..2f025ef1 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -367,12 +367,6 @@ without raising an exception (or you can re-raise or do anything really) def eventually_return_false(): return False -.. note:: - - Calling the parameter ``retry_state`` is important, because this is how - *tenacity* internally distinguishes callbacks from their :ref:`deprecated - counterparts `. - RetryCallState ~~~~~~~~~~~~~~ @@ -477,92 +471,6 @@ Here's an example with a custom ``before_sleep`` function: except RetryError: pass -.. _deprecated-callbacks: - -.. note:: - - It was also possible to define custom callbacks before, but they accepted - varying parameter sets and none of those provided full state. The old way is - deprecated, but kept for backward compatibility. - - .. function:: my_deprecated_stop(previous_attempt_number, delay_since_first_attempt) - - *deprecated* - - :param previous_attempt_number: the number of current attempt - :type previous_attempt_number: int - :param delay_since_first_attempt: interval in seconds between the - beginning of first attempt and current time - :type delay_since_first_attempt: float - :rtype: bool - - .. function:: my_deprecated_wait(previous_attempt_number, delay_since_first_attempt [, last_result]) - - *deprecated* - - :param previous_attempt_number: the number of current attempt - :type previous_attempt_number: int - :param delay_since_first_attempt: interval in seconds between the - beginning of first attempt and current time - :type delay_since_first_attempt: float - :param tenacity.Future last_result: current outcome - - :return: number of seconds to wait before next retry - :rtype: float - - .. function:: my_deprecated_retry(attempt) - - *deprecated* - - :param tenacity.Future attempt: current outcome - :return: whether or not retrying should continue - :rtype: bool - - .. function:: my_deprecated_before(func, trial_number) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param int trial_number: the number of current attempt - - .. function:: my_deprecated_after(func, trial_number, trial_time_taken) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param int trial_number: the number of current attempt - :param float trial_time_taken: interval in seconds between the beginning - of first attempt and current time - - .. function:: my_deprecated_before_sleep(func, sleep, last_result) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param float sleep: number of seconds to wait until next retry - :param tenacity.Future last_result: current outcome - -.. testcode:: - - import logging - - logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - - logger = logging.getLogger(__name__) - - def my_before_sleep(retry_object, sleep, last_result): - logger.warning( - 'Retrying %s: last_result=%s, retrying in %s seconds...', - retry_object.fn, last_result, sleep) - - @retry(stop=stop_after_attempt(3), before_sleep=my_before_sleep) - def raise_my_exception(): - raise MyException("Fail") - - try: - raise_my_exception() - except RetryError: - pass Changing Arguments at Run Time ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 9a6375b9..f17472ca 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -38,7 +38,6 @@ import six from tenacity import _utils -from tenacity import compat as _compat # Import all built-in retry strategies for easier usage. from .retry import retry_all # noqa @@ -225,50 +224,21 @@ def __init__(self, retry_error_cls=RetryError, retry_error_callback=None): self.sleep = sleep - self._stop = stop - self._wait = wait - self._retry = retry - self._before = before - self._after = after - self._before_sleep = before_sleep + self.stop = stop + self.wait = wait + self.retry = retry + self.before = before + self.after = after + self.before_sleep = before_sleep self.reraise = reraise self._local = threading.local() self.retry_error_cls = retry_error_cls - self._retry_error_callback = retry_error_callback + self.retry_error_callback = retry_error_callback # This attribute was moved to RetryCallState and is deprecated on # Retrying objects but kept for backward compatibility. self.fn = None - @_utils.cached_property - def stop(self): - return _compat.stop_func_accept_retry_state(self._stop) - - @_utils.cached_property - def wait(self): - return _compat.wait_func_accept_retry_state(self._wait) - - @_utils.cached_property - def retry(self): - return _compat.retry_func_accept_retry_state(self._retry) - - @_utils.cached_property - def before(self): - return _compat.before_func_accept_retry_state(self._before) - - @_utils.cached_property - def after(self): - return _compat.after_func_accept_retry_state(self._after) - - @_utils.cached_property - def before_sleep(self): - return _compat.before_sleep_func_accept_retry_state(self._before_sleep) - - @_utils.cached_property - def retry_error_callback(self): - return _compat.retry_error_callback_accept_retry_state( - self._retry_error_callback) - def copy(self, sleep=_unset, stop=_unset, wait=_unset, retry=_unset, before=_unset, after=_unset, before_sleep=_unset, reraise=_unset): diff --git a/tenacity/compat.py b/tenacity/compat.py index 6bd815e0..c08ff774 100644 --- a/tenacity/compat.py +++ b/tenacity/compat.py @@ -1,305 +1,6 @@ """Utilities for providing backward compatibility.""" - -import inspect -from fractions import Fraction -from warnings import warn - import six -from tenacity import _utils - - -def warn_about_non_retry_state_deprecation(cbname, func, stacklevel): - msg = ( - '"%s" function must accept single "retry_state" parameter,' - ' please update %s' % (cbname, _utils.get_callback_name(func))) - warn(msg, DeprecationWarning, stacklevel=stacklevel + 1) - - -def warn_about_dunder_non_retry_state_deprecation(fn, stacklevel): - msg = ( - '"%s" method must be called with' - ' single "retry_state" parameter' % (_utils.get_callback_name(fn))) - warn(msg, DeprecationWarning, stacklevel=stacklevel + 1) - - -def func_takes_retry_state(func): - if not six.callable(func): - raise Exception(func) - return False - if not inspect.isfunction(func) and not inspect.ismethod(func): - # func is a callable object rather than a function/method - func = func.__call__ - func_spec = _utils.getargspec(func) - return 'retry_state' in func_spec.args - - -_unset = object() - - -def _make_unset_exception(func_name, **kwargs): - missing = [] - for k, v in six.iteritems(kwargs): - if v is _unset: - missing.append(k) - missing_str = ', '.join(repr(s) for s in missing) - return TypeError(func_name + ' func missing parameters: ' + missing_str) - - -def _set_delay_since_start(retry_state, delay): - # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to - # avoid complexity in test code. - retry_state.start_time = Fraction(retry_state.start_time) - retry_state.outcome_timestamp = (retry_state.start_time + Fraction(delay)) - assert retry_state.seconds_since_start == delay - - -def make_retry_state(previous_attempt_number, delay_since_first_attempt, - last_result=None): - """Construct RetryCallState for given attempt number & delay. - - Only used in testing and thus is extra careful about timestamp arithmetics. - """ - required_parameter_unset = (previous_attempt_number is _unset or - delay_since_first_attempt is _unset) - if required_parameter_unset: - raise _make_unset_exception( - 'wait/stop', - previous_attempt_number=previous_attempt_number, - delay_since_first_attempt=delay_since_first_attempt) - - from tenacity import RetryCallState - retry_state = RetryCallState(None, None, (), {}) - retry_state.attempt_number = previous_attempt_number - if last_result is not None: - retry_state.outcome = last_result - else: - retry_state.set_result(None) - _set_delay_since_start(retry_state, delay_since_first_attempt) - return retry_state - - -def func_takes_last_result(waiter): - """Check if function has a "last_result" parameter. - - Needed to provide backward compatibility for wait functions that didn't - take "last_result" in the beginning. - """ - if not six.callable(waiter): - return False - if not inspect.isfunction(waiter) and not inspect.ismethod(waiter): - # waiter is a class, check dunder-call rather than dunder-init. - waiter = waiter.__call__ - waiter_spec = _utils.getargspec(waiter) - return 'last_result' in waiter_spec.args - - -def stop_dunder_call_accept_old_params(fn): - """Decorate cls.__call__ method to accept old "stop" signature.""" - @_utils.wraps(fn) - def new_fn(self, - previous_attempt_number=_unset, - delay_since_first_attempt=_unset, - retry_state=None): - if retry_state is None: - from tenacity import RetryCallState - retry_state_passed_as_non_kwarg = ( - previous_attempt_number is not _unset and - isinstance(previous_attempt_number, RetryCallState)) - if retry_state_passed_as_non_kwarg: - retry_state = previous_attempt_number - else: - warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2) - retry_state = make_retry_state( - previous_attempt_number=previous_attempt_number, - delay_since_first_attempt=delay_since_first_attempt) - return fn(self, retry_state=retry_state) - return new_fn - - -def stop_func_accept_retry_state(stop_func): - """Wrap "stop" function to accept "retry_state" parameter.""" - if not six.callable(stop_func): - return stop_func - - if func_takes_retry_state(stop_func): - return stop_func - - @_utils.wraps(stop_func) - def wrapped_stop_func(retry_state): - warn_about_non_retry_state_deprecation( - 'stop', stop_func, stacklevel=4) - return stop_func( - retry_state.attempt_number, - retry_state.seconds_since_start, - ) - return wrapped_stop_func - - -def wait_dunder_call_accept_old_params(fn): - """Decorate cls.__call__ method to accept old "wait" signature.""" - @_utils.wraps(fn) - def new_fn(self, - previous_attempt_number=_unset, - delay_since_first_attempt=_unset, - last_result=None, - retry_state=None): - if retry_state is None: - from tenacity import RetryCallState - retry_state_passed_as_non_kwarg = ( - previous_attempt_number is not _unset and - isinstance(previous_attempt_number, RetryCallState)) - if retry_state_passed_as_non_kwarg: - retry_state = previous_attempt_number - else: - warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2) - retry_state = make_retry_state( - previous_attempt_number=previous_attempt_number, - delay_since_first_attempt=delay_since_first_attempt, - last_result=last_result) - return fn(self, retry_state=retry_state) - return new_fn - - -def wait_func_accept_retry_state(wait_func): - """Wrap wait function to accept "retry_state" parameter.""" - if not six.callable(wait_func): - return wait_func - - if func_takes_retry_state(wait_func): - return wait_func - - if func_takes_last_result(wait_func): - @_utils.wraps(wait_func) - def wrapped_wait_func(retry_state): - warn_about_non_retry_state_deprecation( - 'wait', wait_func, stacklevel=4) - return wait_func( - retry_state.attempt_number, - retry_state.seconds_since_start, - last_result=retry_state.outcome, - ) - else: - @_utils.wraps(wait_func) - def wrapped_wait_func(retry_state): - warn_about_non_retry_state_deprecation( - 'wait', wait_func, stacklevel=4) - return wait_func( - retry_state.attempt_number, - retry_state.seconds_since_start, - ) - return wrapped_wait_func - - -def retry_dunder_call_accept_old_params(fn): - """Decorate cls.__call__ method to accept old "retry" signature.""" - @_utils.wraps(fn) - def new_fn(self, attempt=_unset, retry_state=None): - if retry_state is None: - from tenacity import RetryCallState - if attempt is _unset: - raise _make_unset_exception('retry', attempt=attempt) - retry_state_passed_as_non_kwarg = ( - attempt is not _unset and - isinstance(attempt, RetryCallState)) - if retry_state_passed_as_non_kwarg: - retry_state = attempt - else: - warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2) - retry_state = RetryCallState(None, None, (), {}) - retry_state.outcome = attempt - return fn(self, retry_state=retry_state) - return new_fn - - -def retry_func_accept_retry_state(retry_func): - """Wrap "retry" function to accept "retry_state" parameter.""" - if not six.callable(retry_func): - return retry_func - - if func_takes_retry_state(retry_func): - return retry_func - - @_utils.wraps(retry_func) - def wrapped_retry_func(retry_state): - warn_about_non_retry_state_deprecation( - 'retry', retry_func, stacklevel=4) - return retry_func(retry_state.outcome) - return wrapped_retry_func - - -def before_func_accept_retry_state(fn): - """Wrap "before" function to accept "retry_state".""" - if not six.callable(fn): - return fn - - if func_takes_retry_state(fn): - return fn - - @_utils.wraps(fn) - def wrapped_before_func(retry_state): - # func, trial_number, trial_time_taken - warn_about_non_retry_state_deprecation('before', fn, stacklevel=4) - return fn( - retry_state.fn, - retry_state.attempt_number, - ) - return wrapped_before_func - - -def after_func_accept_retry_state(fn): - """Wrap "after" function to accept "retry_state".""" - if not six.callable(fn): - return fn - - if func_takes_retry_state(fn): - return fn - - @_utils.wraps(fn) - def wrapped_after_sleep_func(retry_state): - # func, trial_number, trial_time_taken - warn_about_non_retry_state_deprecation('after', fn, stacklevel=4) - return fn( - retry_state.fn, - retry_state.attempt_number, - retry_state.seconds_since_start) - return wrapped_after_sleep_func - - -def before_sleep_func_accept_retry_state(fn): - """Wrap "before_sleep" function to accept "retry_state".""" - if not six.callable(fn): - return fn - - if func_takes_retry_state(fn): - return fn - - @_utils.wraps(fn) - def wrapped_before_sleep_func(retry_state): - # retry_object, sleep, last_result - warn_about_non_retry_state_deprecation( - 'before_sleep', fn, stacklevel=4) - return fn( - retry_state.retry_object, - sleep=getattr(retry_state.next_action, 'sleep'), - last_result=retry_state.outcome) - return wrapped_before_sleep_func - - -def retry_error_callback_accept_retry_state(fn): - if not six.callable(fn): - return fn - - if func_takes_retry_state(fn): - return fn - - @_utils.wraps(fn) - def wrapped_retry_error_callback(retry_state): - warn_about_non_retry_state_deprecation( - 'retry_error_callback', fn, stacklevel=4) - return fn(retry_state.outcome) - return wrapped_retry_error_callback - def get_exc_info_from_future(future): """ diff --git a/tenacity/retry.py b/tenacity/retry.py index 283e6202..55e59510 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -1,4 +1,6 @@ -# Copyright 2016 Julien Danjou +# -*- encoding: utf-8 -*- +# +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -19,8 +21,6 @@ import six -from tenacity import compat as _compat - @six.add_metaclass(abc.ABCMeta) class retry_base(object): @@ -63,7 +63,6 @@ class retry_if_exception(retry_base): def __init__(self, predicate): self.predicate = predicate - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): if retry_state.outcome.failed: return self.predicate(retry_state.outcome.exception()) @@ -88,7 +87,6 @@ def __init__(self, exception_types=Exception): super(retry_unless_exception_type, self).__init__( lambda e: not isinstance(e, exception_types)) - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): # always retry if no exception was raised if not retry_state.outcome.failed: @@ -102,7 +100,6 @@ class retry_if_result(retry_base): def __init__(self, predicate): self.predicate = predicate - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): if not retry_state.outcome.failed: return self.predicate(retry_state.outcome.result()) @@ -116,7 +113,6 @@ class retry_if_not_result(retry_base): def __init__(self, predicate): self.predicate = predicate - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): if not retry_state.outcome.failed: return not self.predicate(retry_state.outcome.result()) @@ -162,7 +158,6 @@ def __init__(self, *args, **kwargs): self.predicate = lambda *args_, **kwargs_: not if_predicate( *args_, **kwargs_) - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): if not retry_state.outcome.failed: return True @@ -173,10 +168,8 @@ class retry_any(retry_base): """Retries if any of the retries condition is valid.""" def __init__(self, *retries): - self.retries = tuple(_compat.retry_func_accept_retry_state(r) - for r in retries) + self.retries = retries - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): return any(r(retry_state) for r in self.retries) @@ -185,9 +178,7 @@ class retry_all(retry_base): """Retries if all the retries condition are valid.""" def __init__(self, *retries): - self.retries = tuple(_compat.retry_func_accept_retry_state(r) - for r in retries) + self.retries = retries - @_compat.retry_dunder_call_accept_old_params def __call__(self, retry_state): return all(r(retry_state) for r in self.retries) diff --git a/tenacity/stop.py b/tenacity/stop.py index b5874092..94a0f329 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -1,4 +1,6 @@ -# Copyright 2016 Julien Danjou +# -*- encoding: utf-8 -*- +# +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -17,8 +19,6 @@ import six -from tenacity import compat as _compat - @six.add_metaclass(abc.ABCMeta) class stop_base(object): @@ -39,10 +39,8 @@ class stop_any(stop_base): """Stop if any of the stop condition is valid.""" def __init__(self, *stops): - self.stops = tuple(_compat.stop_func_accept_retry_state(stop_func) - for stop_func in stops) + self.stops = stops - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return any(x(retry_state) for x in self.stops) @@ -51,10 +49,8 @@ class stop_all(stop_base): """Stop if all the stop conditions are valid.""" def __init__(self, *stops): - self.stops = tuple(_compat.stop_func_accept_retry_state(stop_func) - for stop_func in stops) + self.stops = stops - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return all(x(retry_state) for x in self.stops) @@ -62,7 +58,6 @@ def __call__(self, retry_state): class _stop_never(stop_base): """Never stop.""" - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return False @@ -76,7 +71,6 @@ class stop_when_event_set(stop_base): def __init__(self, event): self.event = event - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return self.event.is_set() @@ -87,7 +81,6 @@ class stop_after_attempt(stop_base): def __init__(self, max_attempt_number): self.max_attempt_number = max_attempt_number - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return retry_state.attempt_number >= self.max_attempt_number @@ -98,6 +91,5 @@ class stop_after_delay(stop_base): def __init__(self, max_delay): self.max_delay = max_delay - @_compat.stop_dunder_call_accept_old_params def __call__(self, retry_state): return retry_state.seconds_since_start >= self.max_delay diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index 8cee38f6..b800a3e3 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -1,4 +1,6 @@ -# Copyright 2016 Julien Danjou +# -*- encoding: utf-8 -*- +# +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013 Ray Holder # @@ -22,14 +24,58 @@ import warnings from contextlib import contextmanager from copy import copy +from fractions import Fraction import pytest import six.moves import tenacity -from tenacity import RetryError, Retrying, retry -from tenacity.compat import make_retry_state +from tenacity import RetryCallState, RetryError, Retrying, retry + + +_unset = object() + + +def _make_unset_exception(func_name, **kwargs): + missing = [] + for k, v in six.iteritems(kwargs): + if v is _unset: + missing.append(k) + missing_str = ', '.join(repr(s) for s in missing) + return TypeError(func_name + ' func missing parameters: ' + missing_str) + + +def _set_delay_since_start(retry_state, delay): + # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to + # avoid complexity in test code. + retry_state.start_time = Fraction(retry_state.start_time) + retry_state.outcome_timestamp = (retry_state.start_time + Fraction(delay)) + assert retry_state.seconds_since_start == delay + + +def make_retry_state(previous_attempt_number, delay_since_first_attempt, + last_result=None): + """Construct RetryCallState for given attempt number & delay. + + Only used in testing and thus is extra careful about timestamp arithmetics. + """ + required_parameter_unset = (previous_attempt_number is _unset or + delay_since_first_attempt is _unset) + if required_parameter_unset: + raise _make_unset_exception( + 'wait/stop', + previous_attempt_number=previous_attempt_number, + delay_since_first_attempt=delay_since_first_attempt) + + retry_state = RetryCallState(None, None, (), {}) + retry_state.attempt_number = previous_attempt_number + if last_result is not None: + retry_state.outcome = last_result + else: + retry_state.set_result(None) + _set_delay_since_start(retry_state, delay_since_first_attempt) + return retry_state class TestBase(unittest.TestCase): @@ -115,32 +161,6 @@ def test_stop_after_delay(self): def test_legacy_explicit_stop_type(self): Retrying(stop="stop_after_attempt") - def test_stop_backward_compat(self): - r = Retrying(stop=lambda attempt, delay: attempt == delay) - with reports_deprecation_warning(): - self.assertFalse(r.stop(make_retry_state(1, 3))) - with reports_deprecation_warning(): - self.assertFalse(r.stop(make_retry_state(100, 99))) - with reports_deprecation_warning(): - self.assertTrue(r.stop(make_retry_state(101, 101))) - - def test_retry_child_class_with_override_backward_compat(self): - - class MyStop(tenacity.stop_after_attempt): - def __init__(self): - super(MyStop, self).__init__(1) - - def __call__(self, attempt_number, seconds_since_start): - return super(MyStop, self).__call__( - attempt_number, seconds_since_start) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=MyStop()) - - def failing(): - raise NotImplementedError() - with pytest.raises(RetryError): - retrying(failing) - def test_stop_func_with_retry_state(self): def stop_func(retry_state): rs = retry_state @@ -156,24 +176,24 @@ class TestWaitConditions(unittest.TestCase): def test_no_sleep(self): r = Retrying() - self.assertEqual(0, r.wait(18, 9879)) + self.assertEqual(0, r.wait(make_retry_state(18, 9879))) def test_fixed_sleep(self): r = Retrying(wait=tenacity.wait_fixed(1)) - self.assertEqual(1, r.wait(12, 6546)) + self.assertEqual(1, r.wait(make_retry_state(12, 6546))) def test_incrementing_sleep(self): r = Retrying(wait=tenacity.wait_incrementing( start=500, increment=100)) - self.assertEqual(500, r.wait(1, 6546)) - self.assertEqual(600, r.wait(2, 6546)) - self.assertEqual(700, r.wait(3, 6546)) + self.assertEqual(500, r.wait(make_retry_state(1, 6546))) + self.assertEqual(600, r.wait(make_retry_state(2, 6546))) + self.assertEqual(700, r.wait(make_retry_state(3, 6546))) def test_random_sleep(self): r = Retrying(wait=tenacity.wait_random(min=1, max=20)) times = set() for x in six.moves.range(1000): - times.add(r.wait(1, 6546)) + times.add(r.wait(make_retry_state(1, 6546))) # this is kind of non-deterministic... self.assertTrue(len(times) > 1) @@ -184,10 +204,10 @@ def test_random_sleep(self): def test_random_sleep_without_min(self): r = Retrying(wait=tenacity.wait_random(max=2)) times = set() - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) # this is kind of non-deterministic... self.assertTrue(len(times) > 1) @@ -197,100 +217,81 @@ def test_random_sleep_without_min(self): def test_exponential(self): r = Retrying(wait=tenacity.wait_exponential()) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 64) - self.assertEqual(r.wait(8, 0), 128) + self.assertEqual(r.wait(make_retry_state(1, 0)), 1) + self.assertEqual(r.wait(make_retry_state(2, 0)), 2) + self.assertEqual(r.wait(make_retry_state(3, 0)), 4) + self.assertEqual(r.wait(make_retry_state(4, 0)), 8) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 128) def test_exponential_with_max_wait(self): r = Retrying(wait=tenacity.wait_exponential(max=40)) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 40) - self.assertEqual(r.wait(8, 0), 40) - self.assertEqual(r.wait(50, 0), 40) + self.assertEqual(r.wait(make_retry_state(1, 0)), 1) + self.assertEqual(r.wait(make_retry_state(2, 0)), 2) + self.assertEqual(r.wait(make_retry_state(3, 0)), 4) + self.assertEqual(r.wait(make_retry_state(4, 0)), 8) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 40) + self.assertEqual(r.wait(make_retry_state(8, 0)), 40) + self.assertEqual(r.wait(make_retry_state(50, 0)), 40) def test_exponential_with_min_wait(self): r = Retrying(wait=tenacity.wait_exponential(min=20)) - self.assertEqual(r.wait(1, 0), 20) - self.assertEqual(r.wait(2, 0), 20) - self.assertEqual(r.wait(3, 0), 20) - self.assertEqual(r.wait(4, 0), 20) - self.assertEqual(r.wait(5, 0), 20) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 64) - self.assertEqual(r.wait(8, 0), 128) - self.assertEqual(r.wait(20, 0), 524288) + self.assertEqual(r.wait(make_retry_state(1, 0)), 20) + self.assertEqual(r.wait(make_retry_state(2, 0)), 20) + self.assertEqual(r.wait(make_retry_state(3, 0)), 20) + self.assertEqual(r.wait(make_retry_state(4, 0)), 20) + self.assertEqual(r.wait(make_retry_state(5, 0)), 20) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 128) + self.assertEqual(r.wait(make_retry_state(20, 0)), 524288) def test_exponential_with_max_wait_and_multiplier(self): r = Retrying(wait=tenacity.wait_exponential( max=50, multiplier=1)) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 50) - self.assertEqual(r.wait(8, 0), 50) - self.assertEqual(r.wait(50, 0), 50) + self.assertEqual(r.wait(make_retry_state(1, 0)), 1) + self.assertEqual(r.wait(make_retry_state(2, 0)), 2) + self.assertEqual(r.wait(make_retry_state(3, 0)), 4) + self.assertEqual(r.wait(make_retry_state(4, 0)), 8) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 50) + self.assertEqual(r.wait(make_retry_state(8, 0)), 50) + self.assertEqual(r.wait(make_retry_state(50, 0)), 50) def test_exponential_with_min_wait_and_multiplier(self): r = Retrying(wait=tenacity.wait_exponential( min=20, multiplier=2)) - self.assertEqual(r.wait(1, 0), 20) - self.assertEqual(r.wait(2, 0), 20) - self.assertEqual(r.wait(3, 0), 20) - self.assertEqual(r.wait(4, 0), 20) - self.assertEqual(r.wait(5, 0), 32) - self.assertEqual(r.wait(6, 0), 64) - self.assertEqual(r.wait(7, 0), 128) - self.assertEqual(r.wait(8, 0), 256) - self.assertEqual(r.wait(20, 0), 1048576) + self.assertEqual(r.wait(make_retry_state(1, 0)), 20) + self.assertEqual(r.wait(make_retry_state(2, 0)), 20) + self.assertEqual(r.wait(make_retry_state(3, 0)), 20) + self.assertEqual(r.wait(make_retry_state(4, 0)), 20) + self.assertEqual(r.wait(make_retry_state(5, 0)), 32) + self.assertEqual(r.wait(make_retry_state(6, 0)), 64) + self.assertEqual(r.wait(make_retry_state(7, 0)), 128) + self.assertEqual(r.wait(make_retry_state(8, 0)), 256) + self.assertEqual(r.wait(make_retry_state(20, 0)), 1048576) def test_exponential_with_min_wait_and_max_wait(self): r = Retrying(wait=tenacity.wait_exponential(min=10, max=100)) - self.assertEqual(r.wait(1, 0), 10) - self.assertEqual(r.wait(2, 0), 10) - self.assertEqual(r.wait(3, 0), 10) - self.assertEqual(r.wait(4, 0), 10) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 64) - self.assertEqual(r.wait(8, 0), 100) - self.assertEqual(r.wait(9, 0), 100) - self.assertEqual(r.wait(20, 0), 100) + self.assertEqual(r.wait(make_retry_state(1, 0)), 10) + self.assertEqual(r.wait(make_retry_state(2, 0)), 10) + self.assertEqual(r.wait(make_retry_state(3, 0)), 10) + self.assertEqual(r.wait(make_retry_state(4, 0)), 10) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 100) + self.assertEqual(r.wait(make_retry_state(9, 0)), 100) + self.assertEqual(r.wait(make_retry_state(20, 0)), 100) def test_legacy_explicit_wait_type(self): Retrying(wait="exponential_sleep") - def test_wait_backward_compat_with_result(self): - captures = [] - - def wait_capture(attempt, delay, last_result=None): - captures.append(last_result) - return 1 - - def dying(): - raise Exception("Broken") - - r_attempts = 10 - r = Retrying(wait=wait_capture, sleep=lambda secs: None, - stop=tenacity.stop_after_attempt(r_attempts), - reraise=True) - with reports_deprecation_warning(): - self.assertRaises(Exception, r, dying) - self.assertEqual(r_attempts - 1, len(captures)) - self.assertTrue(all([r.failed for r in captures])) - def test_wait_func(self): def wait_func(retry_state): return retry_state.attempt_number * retry_state.seconds_since_start @@ -304,7 +305,7 @@ def test_wait_combine(self): tenacity.wait_fixed(5))) # Test it a few time since it's random for i in six.moves.range(1000): - w = r.wait(1, 5) + w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 8) self.assertGreaterEqual(w, 5) @@ -312,7 +313,7 @@ def test_wait_double_sum(self): r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) # Test it a few time since it's random for i in six.moves.range(1000): - w = r.wait(1, 5) + w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 8) self.assertGreaterEqual(w, 5) @@ -321,7 +322,7 @@ def test_wait_triple_sum(self): tenacity.wait_fixed(5)) # Test it a few time since it's random for i in six.moves.range(1000): - w = r.wait(1, 5) + w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) @@ -332,7 +333,7 @@ def test_wait_arbitrary_sum(self): tenacity.wait_none()])) # Test it a few time since it's random for i in six.moves.range(1000): - w = r.wait(1, 5) + w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) @@ -355,7 +356,7 @@ def test_wait_chain(self): [tenacity.wait_fixed(8) for i in six.moves.range(1)])) for i in six.moves.range(10): - w = r.wait(i + 1, 1) + w = r.wait(make_retry_state(i + 1, 1)) if i < 2: self._assert_range(w, 1, 2) elif i < 4: @@ -409,7 +410,7 @@ def test_wait_random_exponential(self): # Default arguments exist fn = tenacity.wait_random_exponential() - fn(0, 0) + fn(make_retry_state(0, 0)) def test_wait_random_exponential_statistically(self): fn = tenacity.wait_random_exponential(0.5, 60.0) @@ -434,52 +435,6 @@ def mean(lst): self._assert_inclusive_epsilon(mean(attempt[8]), 30, 2.56) self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56) - def test_wait_backward_compat(self): - """Ensure Retrying object accepts both old and newstyle wait funcs.""" - def wait1(previous_attempt_number, delay_since_first_attempt): - wait1.calls.append(( - previous_attempt_number, delay_since_first_attempt)) - return 0 - wait1.calls = [] - - def wait2(previous_attempt_number, delay_since_first_attempt, - last_result): - wait2.calls.append(( - previous_attempt_number, delay_since_first_attempt, - last_result)) - return 0 - wait2.calls = [] - - def dying(): - raise Exception("Broken") - - retrying1 = Retrying(wait=wait1, stop=tenacity.stop_after_attempt(4)) - with reports_deprecation_warning(): - self.assertRaises(Exception, lambda: retrying1(dying)) - self.assertEqual([t[0] for t in wait1.calls], [1, 2, 3]) - # This assumes that 3 iterations complete within 1 second. - self.assertTrue(all(t[1] < 1 for t in wait1.calls)) - - retrying2 = Retrying(wait=wait2, stop=tenacity.stop_after_attempt(4)) - with reports_deprecation_warning(): - self.assertRaises(Exception, lambda: retrying2(dying)) - self.assertEqual([t[0] for t in wait2.calls], [1, 2, 3]) - # This assumes that 3 iterations complete within 1 second. - self.assertTrue(all(t[1] < 1 for t in wait2.calls)) - self.assertEqual([str(t[2].exception()) for t in wait2.calls], - ['Broken'] * 3) - - def test_wait_class_backward_compatibility(self): - """Ensure builtin objects accept both old and new parameters.""" - waitobj = tenacity.wait_fixed(5) - self.assertEqual(waitobj(1, 0.1), 5) - self.assertEqual( - waitobj(1, 0.1, tenacity.Future.construct(1, 1, False)), 5) - retry_state = make_retry_state(123, 456) - self.assertEqual(retry_state.attempt_number, 123) - self.assertEqual(retry_state.seconds_since_start, 456) - self.assertEqual(waitobj(retry_state=retry_state), 5) - def test_wait_retry_state_attributes(self): class ExtractCallState(Exception): @@ -1035,25 +990,6 @@ def __call__(self): h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") - def test_retry_child_class_with_override_backward_compat(self): - def always_true(_): - return True - - class MyRetry(tenacity.retry_if_exception): - def __init__(self): - super(MyRetry, self).__init__(always_true) - - def __call__(self, attempt): - return super(MyRetry, self).__call__(attempt) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(1), - retry=MyRetry()) - - def failing(): - raise NotImplementedError() - with pytest.raises(RetryError): - retrying(failing) - class TestBeforeAfterAttempts(unittest.TestCase): _attempt_number = 0 @@ -1110,43 +1046,6 @@ def _test_before_sleep(): _test_before_sleep() self.assertEqual(_before_sleep.attempt_number, 2) - def test_before_sleep_backward_compat(self): - def _before_sleep(retry_obj, sleep, last_result): - self.assertGreater(sleep, 0) - _before_sleep.attempt_number = \ - retry_obj.statistics['attempt_number'] - _before_sleep.attempt_number = 0 - - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) - def _test_before_sleep(): - if _before_sleep.attempt_number < 2: - raise Exception("testing before_sleep_attempts handler") - - with reports_deprecation_warning(): - _test_before_sleep() - self.assertEqual(_before_sleep.attempt_number, 2) - - def _before_sleep(self, retry_state): - self.slept += 1 - - def test_before_sleep_backward_compat_method(self): - self.slept = 0 - - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=self._before_sleep) - def _test_before_sleep(): - raise Exception("testing before_sleep_attempts handler") - - try: - _test_before_sleep() - except tenacity.RetryError: - pass - - self.assertEqual(self.slept, 2) - def _before_sleep_log_raises(self, get_call_fn): thing = NoIOErrorAfterCount(2) logger = logging.getLogger(self.id()) @@ -1332,28 +1231,6 @@ def _callback(self, fut): self._callback_called = True return fut - def test_retry_error_callback_backward_compat(self): - num_attempts = 3 - - def retry_error_callback(fut): - retry_error_callback.called_times += 1 - return fut - - retry_error_callback.called_times = 0 - - @retry(stop=tenacity.stop_after_attempt(num_attempts), - retry_error_callback=retry_error_callback) - def _foobar(): - self._attempt_number += 1 - raise Exception("This exception should not be raised") - - with reports_deprecation_warning(): - result = _foobar() - - self.assertEqual(retry_error_callback.called_times, 1) - self.assertEqual(num_attempts, self._attempt_number) - self.assertIsInstance(result, tenacity.Future) - def test_retry_error_callback(self): num_attempts = 3 diff --git a/tenacity/wait.py b/tenacity/wait.py index d3c835f2..235a24bd 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -1,4 +1,6 @@ -# Copyright 2016 Julien Danjou +# -*- encoding: utf-8 -*- +# +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -20,7 +22,6 @@ import six from tenacity import _utils -from tenacity import compat as _compat @six.add_metaclass(abc.ABCMeta) @@ -47,7 +48,6 @@ class wait_fixed(wait_base): def __init__(self, wait): self.wait_fixed = wait - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): return self.wait_fixed @@ -66,7 +66,6 @@ def __init__(self, min=0, max=1): # noqa self.wait_random_min = min self.wait_random_max = max - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): return (self.wait_random_min + (random.random() * @@ -77,10 +76,8 @@ class wait_combine(wait_base): """Combine several waiting strategies.""" def __init__(self, *strategies): - self.wait_funcs = tuple(_compat.wait_func_accept_retry_state(strategy) - for strategy in strategies) + self.wait_funcs = strategies - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): return sum(x(retry_state=retry_state) for x in self.wait_funcs) @@ -102,10 +99,8 @@ def wait_chained(): """ def __init__(self, *strategies): - self.strategies = [_compat.wait_func_accept_retry_state(strategy) - for strategy in strategies] + self.strategies = strategies - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): wait_func_no = min(max(retry_state.attempt_number, 1), len(self.strategies)) @@ -125,7 +120,6 @@ def __init__(self, start=0, increment=100, max=_utils.MAX_WAIT): # noqa self.increment = increment self.max = max - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): result = self.start + ( self.increment * (retry_state.attempt_number - 1) @@ -152,7 +146,6 @@ def __init__(self, multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0): # noq self.max = max self.exp_base = exp_base - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): try: exp = self.exp_base ** (retry_state.attempt_number - 1) @@ -188,7 +181,6 @@ class wait_random_exponential(wait_exponential): """ - @_compat.wait_dunder_call_accept_old_params def __call__(self, retry_state): high = super(wait_random_exponential, self).__call__( retry_state=retry_state) From fd7842774cc10d41e68e6bc64afe7d9f83221aa0 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 29 Jan 2021 16:42:15 +0100 Subject: [PATCH 010/124] ci: enable black (#277) --- doc/source/conf.py | 10 +- setup.py | 2 +- tenacity/__init__.py | 94 ++++--- tenacity/_asyncio.py | 8 +- tenacity/_utils.py | 15 +- tenacity/after.py | 17 +- tenacity/before.py | 11 +- tenacity/before_sleep.py | 21 +- tenacity/retry.py | 22 +- tenacity/tests/test_asyncio.py | 7 +- tenacity/tests/test_tenacity.py | 479 ++++++++++++++++++-------------- tenacity/tests/test_tornado.py | 3 +- tenacity/tornadoweb.py | 8 +- tenacity/wait.py | 16 +- tox.ini | 3 +- 15 files changed, 411 insertions(+), 305 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 23856bc5..8bb88192 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -19,16 +19,16 @@ import os import sys -master_doc = 'index' +master_doc = "index" project = "Tenacity" # Add tenacity to the path, so sphinx can find the functions for autodoc. -sys.path.insert(0, os.path.abspath('../..')) +sys.path.insert(0, os.path.abspath("../..")) extensions = [ - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', - 'reno.sphinxext', + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "reno.sphinxext", ] # -- Options for sphinx.ext.doctest ----------------------------------------- diff --git a/setup.py b/setup.py index 66163685..ec7ac861 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,6 @@ import setuptools setuptools.setup( - setup_requires=['setuptools_scm'], + setup_requires=["setuptools_scm"], use_scm_version=True, ) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index f17472ca..74205127 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -115,11 +115,15 @@ def retry(*dargs, **dkw): # noqa if len(dargs) == 1 and callable(dargs[0]): return retry()(dargs[0]) else: + def wrap(f): if iscoroutinefunction is not None and iscoroutinefunction(f): r = AsyncRetrying(*dargs, **dkw) - elif tornado and hasattr(tornado.gen, 'is_coroutine_function') \ - and tornado.gen.is_coroutine_function(f): + elif ( + tornado + and hasattr(tornado.gen, "is_coroutine_function") + and tornado.gen.is_coroutine_function(f) + ): r = TornadoRetrying(*dargs, **dkw) else: r = Retrying(*dargs, **dkw) @@ -157,17 +161,18 @@ class BaseAction(object): NAME = None def __repr__(self): - state_str = ', '.join('%s=%r' % (field, getattr(self, field)) - for field in self.REPR_FIELDS) - return '%s(%s)' % (type(self).__name__, state_str) + state_str = ", ".join( + "%s=%r" % (field, getattr(self, field)) for field in self.REPR_FIELDS + ) + return "%s(%s)" % (type(self).__name__, state_str) def __str__(self): return repr(self) class RetryAction(BaseAction): - REPR_FIELDS = ('sleep',) - NAME = 'retry' + REPR_FIELDS = ("sleep",) + NAME = "retry" def __init__(self, sleep): self.sleep = float(sleep) @@ -213,16 +218,19 @@ def __exit__(self, exc_type, exc_value, traceback): class BaseRetrying(object): __metaclass__ = ABCMeta - def __init__(self, - sleep=sleep, - stop=stop_never, wait=wait_none(), - retry=retry_if_exception_type(), - before=before_nothing, - after=after_nothing, - before_sleep=None, - reraise=False, - retry_error_cls=RetryError, - retry_error_callback=None): + def __init__( + self, + sleep=sleep, + stop=stop_never, + wait=wait_none(), + retry=retry_if_exception_type(), + before=before_nothing, + after=after_nothing, + before_sleep=None, + reraise=False, + retry_error_cls=RetryError, + retry_error_callback=None, + ): self.sleep = sleep self.stop = stop self.wait = wait @@ -239,9 +247,17 @@ def __init__(self, # Retrying objects but kept for backward compatibility. self.fn = None - def copy(self, sleep=_unset, stop=_unset, wait=_unset, - retry=_unset, before=_unset, after=_unset, before_sleep=_unset, - reraise=_unset): + def copy( + self, + sleep=_unset, + stop=_unset, + wait=_unset, + retry=_unset, + before=_unset, + after=_unset, + before_sleep=_unset, + reraise=_unset, + ): """Copy this object with some parameters changed if needed.""" if before_sleep is _unset: before_sleep = self.before_sleep @@ -258,12 +274,14 @@ def copy(self, sleep=_unset, stop=_unset, wait=_unset, def __repr__(self): attrs = dict( - _utils.visible_attrs(self, attrs={'me': id(self)}), + _utils.visible_attrs(self, attrs={"me": id(self)}), __class__=self.__class__.__name__, ) - return ("<%(__class__)s object at 0x%(me)x (stop=%(stop)s, " - "wait=%(wait)s, sleep=%(sleep)s, retry=%(retry)s, " - "before=%(before)s, after=%(after)s)>") % (attrs) + return ( + "<%(__class__)s object at 0x%(me)x (stop=%(stop)s, " + "wait=%(wait)s, sleep=%(sleep)s, retry=%(retry)s, " + "before=%(before)s, after=%(after)s)>" + ) % (attrs) @property def statistics(self): @@ -298,6 +316,7 @@ def wraps(self, f): :param f: A function to wraps for retrying. """ + @_utils.wraps(f) def wrapped_f(*args, **kw): return self(f, *args, **kw) @@ -312,9 +331,9 @@ def retry_with(*args, **kwargs): def begin(self, fn): self.statistics.clear() - self.statistics['start_time'] = _utils.now() - self.statistics['attempt_number'] = 1 - self.statistics['idle_for'] = 0 + self.statistics["start_time"] = _utils.now() + self.statistics["attempt_number"] = 1 + self.statistics["idle_for"] = 0 self.fn = fn def iter(self, retry_state): # noqa @@ -324,16 +343,16 @@ def iter(self, retry_state): # noqa self.before(retry_state) return DoAttempt() - is_explicit_retry = retry_state.outcome.failed \ - and isinstance(retry_state.outcome.exception(), TryAgain) + is_explicit_retry = retry_state.outcome.failed and isinstance( + retry_state.outcome.exception(), TryAgain + ) if not (is_explicit_retry or self.retry(retry_state=retry_state)): return fut.result() if self.after is not None: self.after(retry_state=retry_state) - self.statistics['delay_since_first_attempt'] = \ - retry_state.seconds_since_start + self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start if self.stop(retry_state=retry_state): if self.retry_error_callback: return self.retry_error_callback(retry_state=retry_state) @@ -348,8 +367,8 @@ def iter(self, retry_state): # noqa sleep = 0.0 retry_state.next_action = RetryAction(sleep) retry_state.idle_for += sleep - self.statistics['idle_for'] += sleep - self.statistics['attempt_number'] += 1 + self.statistics["idle_for"] += sleep + self.statistics["attempt_number"] += 1 if self.before_sleep is not None: self.before_sleep(retry_state=retry_state) @@ -376,8 +395,10 @@ def __call__(self, *args, **kwargs): def call(self, *args, **kwargs): """Use ``__call__`` instead because this method is deprecated.""" - warnings.warn("'call()' method is deprecated. " + - "Use '__call__()' instead", DeprecationWarning) + warnings.warn( + "'call()' method is deprecated. " + "Use '__call__()' instead", + DeprecationWarning, + ) return self.__call__(*args, **kwargs) @@ -387,8 +408,7 @@ class Retrying(BaseRetrying): def __call__(self, fn, *args, **kwargs): self.begin(fn) - retry_state = RetryCallState( - retry_object=self, fn=fn, args=args, kwargs=kwargs) + retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: do = self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index de0f5fc2..979b6544 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -26,18 +26,14 @@ class AsyncRetrying(BaseRetrying): - - def __init__(self, - sleep=sleep, - **kwargs): + def __init__(self, sleep=sleep, **kwargs): super(AsyncRetrying, self).__init__(**kwargs) self.sleep = sleep async def __call__(self, fn, *args, **kwargs): self.begin(fn) - retry_state = RetryCallState( - retry_object=self, fn=fn, args=args, kwargs=kwargs) + retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: do = self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 6703bd9c..625d5901 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -40,12 +40,15 @@ def wraps(fn): Also, see https://github.com/benjaminp/six/issues/250. """ + def filter_hasattr(obj, attrs): return tuple(a for a in attrs if hasattr(obj, a)) + return six.wraps( fn, assigned=filter_hasattr(fn, WRAPPER_ASSIGNMENTS), - updated=filter_hasattr(fn, WRAPPER_UPDATES)) + updated=filter_hasattr(fn, WRAPPER_UPDATES), + ) def capture(fut, tb): # TODO(harlowja): delete this in future, since its @@ -55,6 +58,8 @@ def capture(fut, tb): def getargspec(func): # This was deprecated in Python 3. return inspect.getargspec(func) + + else: from functools import wraps # noqa @@ -80,13 +85,13 @@ def find_ordinal(pos_num): if pos_num == 0: return "th" elif pos_num == 1: - return 'st' + return "st" elif pos_num == 2: - return 'nd' + return "nd" elif pos_num == 3: - return 'rd' + return "rd" elif pos_num >= 4 and pos_num <= 20: - return 'th' + return "th" else: return find_ordinal(pos_num % 10) diff --git a/tenacity/after.py b/tenacity/after.py index 55522c99..c577d7c7 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -23,13 +23,18 @@ def after_nothing(retry_state): def after_log(logger, log_level, sec_format="%0.3f"): """After call strategy that logs to some logger the finished attempt.""" - log_tpl = ("Finished call to '%s' after " + str(sec_format) + "(s), " - "this was the %s time calling it.") + log_tpl = ( + "Finished call to '%s' after " + str(sec_format) + "(s), " + "this was the %s time calling it." + ) def log_it(retry_state): - logger.log(log_level, log_tpl, - _utils.get_callback_name(retry_state.fn), - retry_state.seconds_since_start, - _utils.to_ordinal(retry_state.attempt_number)) + logger.log( + log_level, + log_tpl, + _utils.get_callback_name(retry_state.fn), + retry_state.seconds_since_start, + _utils.to_ordinal(retry_state.attempt_number), + ) return log_it diff --git a/tenacity/before.py b/tenacity/before.py index 54259ddd..68a2ae61 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -23,10 +23,13 @@ def before_nothing(retry_state): def before_log(logger, log_level): """Before call strategy that logs to some logger the attempt.""" + def log_it(retry_state): - logger.log(log_level, - "Starting call to '%s', this is the %s time calling it.", - _utils.get_callback_name(retry_state.fn), - _utils.to_ordinal(retry_state.attempt_number)) + logger.log( + log_level, + "Starting call to '%s', this is the %s time calling it.", + _utils.get_callback_name(retry_state.fn), + _utils.to_ordinal(retry_state.attempt_number), + ) return log_it diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index c8b3d33c..d797a246 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -24,23 +24,28 @@ def before_sleep_nothing(retry_state): def before_sleep_log(logger, log_level, exc_info=False): """Before call strategy that logs to some logger the attempt.""" + def log_it(retry_state): if retry_state.outcome.failed: ex = retry_state.outcome.exception() - verb, value = 'raised', '%s: %s' % (type(ex).__name__, ex) + verb, value = "raised", "%s: %s" % (type(ex).__name__, ex) if exc_info: local_exc_info = get_exc_info_from_future(retry_state.outcome) else: local_exc_info = False else: - verb, value = 'returned', retry_state.outcome.result() + verb, value = "returned", retry_state.outcome.result() local_exc_info = False # exc_info does not apply when no exception - logger.log(log_level, - "Retrying %s in %s seconds as it %s %s.", - _utils.get_callback_name(retry_state.fn), - getattr(retry_state.next_action, 'sleep'), - verb, value, - exc_info=local_exc_info) + logger.log( + log_level, + "Retrying %s in %s seconds as it %s %s.", + _utils.get_callback_name(retry_state.fn), + getattr(retry_state.next_action, "sleep"), + verb, + value, + exc_info=local_exc_info, + ) + return log_it diff --git a/tenacity/retry.py b/tenacity/retry.py index 55e59510..ebf26df5 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -76,7 +76,8 @@ class retry_if_exception_type(retry_if_exception): def __init__(self, exception_types=Exception): self.exception_types = exception_types super(retry_if_exception_type, self).__init__( - lambda e: isinstance(e, exception_types)) + lambda e: isinstance(e, exception_types) + ) class retry_unless_exception_type(retry_if_exception): @@ -85,7 +86,8 @@ class retry_unless_exception_type(retry_if_exception): def __init__(self, exception_types=Exception): self.exception_types = exception_types super(retry_unless_exception_type, self).__init__( - lambda e: not isinstance(e, exception_types)) + lambda e: not isinstance(e, exception_types) + ) def __call__(self, retry_state): # always retry if no exception was raised @@ -127,23 +129,30 @@ def __init__(self, message=None, match=None): if message and match: raise TypeError( "{}() takes either 'message' or 'match', not both".format( - self.__class__.__name__)) + self.__class__.__name__ + ) + ) # set predicate if message: + def message_fnc(exception): return message == str(exception) + predicate = message_fnc elif match: prog = re.compile(match) def match_fnc(exception): return prog.match(str(exception)) + predicate = match_fnc else: raise TypeError( - "{}() missing 1 required argument 'message' or 'match'". - format(self.__class__.__name__)) + "{}() missing 1 required argument 'message' or 'match'".format( + self.__class__.__name__ + ) + ) super(retry_if_exception_message, self).__init__(predicate) @@ -155,8 +164,7 @@ def __init__(self, *args, **kwargs): super(retry_if_not_exception_message, self).__init__(*args, **kwargs) # invert predicate if_predicate = self.predicate - self.predicate = lambda *args_, **kwargs_: not if_predicate( - *args_, **kwargs_) + self.predicate = lambda *args_, **kwargs_: not if_predicate(*args_, **kwargs_) def __call__(self, retry_state): if not retry_state.outcome.failed: diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index cfd664cc..2057fd2d 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -93,8 +93,8 @@ def test_repr(self): repr(tasyncio.AsyncRetrying()) def test_retry_attributes(self): - assert hasattr(_retryable_coroutine, 'retry') - assert hasattr(_retryable_coroutine, 'retry_with') + assert hasattr(_retryable_coroutine, "retry") + assert hasattr(_retryable_coroutine, "retry_with") @asynctest async def test_attempt_number_is_correct_for_interleaved_coroutines(self): @@ -109,7 +109,8 @@ def after(retry_state): await asyncio.gather( _retryable_coroutine.retry_with(after=after)(thing1), - _retryable_coroutine.retry_with(after=after)(thing2)) + _retryable_coroutine.retry_with(after=after)(thing2), + ) # There's no waiting on retry, only a wait in the coroutine, so the # executions should be interleaved. diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index b800a3e3..0ffd26b4 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -42,31 +42,34 @@ def _make_unset_exception(func_name, **kwargs): for k, v in six.iteritems(kwargs): if v is _unset: missing.append(k) - missing_str = ', '.join(repr(s) for s in missing) - return TypeError(func_name + ' func missing parameters: ' + missing_str) + missing_str = ", ".join(repr(s) for s in missing) + return TypeError(func_name + " func missing parameters: " + missing_str) def _set_delay_since_start(retry_state, delay): # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to # avoid complexity in test code. retry_state.start_time = Fraction(retry_state.start_time) - retry_state.outcome_timestamp = (retry_state.start_time + Fraction(delay)) + retry_state.outcome_timestamp = retry_state.start_time + Fraction(delay) assert retry_state.seconds_since_start == delay -def make_retry_state(previous_attempt_number, delay_since_first_attempt, - last_result=None): +def make_retry_state( + previous_attempt_number, delay_since_first_attempt, last_result=None +): """Construct RetryCallState for given attempt number & delay. Only used in testing and thus is extra careful about timestamp arithmetics. """ - required_parameter_unset = (previous_attempt_number is _unset or - delay_since_first_attempt is _unset) + required_parameter_unset = ( + previous_attempt_number is _unset or delay_since_first_attempt is _unset + ) if required_parameter_unset: raise _make_unset_exception( - 'wait/stop', + "wait/stop", previous_attempt_number=previous_attempt_number, - delay_since_first_attempt=delay_since_first_attempt) + delay_since_first_attempt=delay_since_first_attempt, + ) retry_state = RetryCallState(None, None, (), {}) retry_state.attempt_number = previous_attempt_number @@ -79,7 +82,6 @@ def make_retry_state(previous_attempt_number, delay_since_first_attempt, class TestBase(unittest.TestCase): - def test_repr(self): class ConcreteRetrying(tenacity.BaseRetrying): def __call__(self): @@ -89,18 +91,18 @@ def __call__(self): class TestStopConditions(unittest.TestCase): - def test_never_stop(self): r = Retrying() self.assertFalse(r.stop(make_retry_state(3, 6546))) def test_stop_any(self): stop = tenacity.stop_any( - tenacity.stop_after_delay(1), - tenacity.stop_after_attempt(4)) + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -110,11 +112,12 @@ def s(*args): def test_stop_all(self): stop = tenacity.stop_all( - tenacity.stop_after_delay(1), - tenacity.stop_after_attempt(4)) + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -127,6 +130,7 @@ def test_stop_or(self): def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -139,6 +143,7 @@ def test_stop_and(self): def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -173,7 +178,6 @@ def stop_func(retry_state): class TestWaitConditions(unittest.TestCase): - def test_no_sleep(self): r = Retrying() self.assertEqual(0, r.wait(make_retry_state(18, 9879))) @@ -183,8 +187,7 @@ def test_fixed_sleep(self): self.assertEqual(1, r.wait(make_retry_state(12, 6546))) def test_incrementing_sleep(self): - r = Retrying(wait=tenacity.wait_incrementing( - start=500, increment=100)) + r = Retrying(wait=tenacity.wait_incrementing(start=500, increment=100)) self.assertEqual(500, r.wait(make_retry_state(1, 6546))) self.assertEqual(600, r.wait(make_retry_state(2, 6546))) self.assertEqual(700, r.wait(make_retry_state(3, 6546))) @@ -251,8 +254,7 @@ def test_exponential_with_min_wait(self): self.assertEqual(r.wait(make_retry_state(20, 0)), 524288) def test_exponential_with_max_wait_and_multiplier(self): - r = Retrying(wait=tenacity.wait_exponential( - max=50, multiplier=1)) + r = Retrying(wait=tenacity.wait_exponential(max=50, multiplier=1)) self.assertEqual(r.wait(make_retry_state(1, 0)), 1) self.assertEqual(r.wait(make_retry_state(2, 0)), 2) self.assertEqual(r.wait(make_retry_state(3, 0)), 4) @@ -264,8 +266,7 @@ def test_exponential_with_max_wait_and_multiplier(self): self.assertEqual(r.wait(make_retry_state(50, 0)), 50) def test_exponential_with_min_wait_and_multiplier(self): - r = Retrying(wait=tenacity.wait_exponential( - min=20, multiplier=2)) + r = Retrying(wait=tenacity.wait_exponential(min=20, multiplier=2)) self.assertEqual(r.wait(make_retry_state(1, 0)), 20) self.assertEqual(r.wait(make_retry_state(2, 0)), 20) self.assertEqual(r.wait(make_retry_state(3, 0)), 20) @@ -295,14 +296,18 @@ def test_legacy_explicit_wait_type(self): def test_wait_func(self): def wait_func(retry_state): return retry_state.attempt_number * retry_state.seconds_since_start + r = Retrying(wait=wait_func) self.assertEqual(r.wait(make_retry_state(1, 5)), 5) self.assertEqual(r.wait(make_retry_state(2, 11)), 22) self.assertEqual(r.wait(make_retry_state(10, 100)), 1000) def test_wait_combine(self): - r = Retrying(wait=tenacity.wait_combine(tenacity.wait_random(0, 3), - tenacity.wait_fixed(5))) + r = Retrying( + wait=tenacity.wait_combine( + tenacity.wait_random(0, 3), tenacity.wait_fixed(5) + ) + ) # Test it a few time since it's random for i in six.moves.range(1000): w = r.wait(make_retry_state(1, 5)) @@ -318,8 +323,11 @@ def test_wait_double_sum(self): self.assertGreaterEqual(w, 5) def test_wait_triple_sum(self): - r = Retrying(wait=tenacity.wait_fixed(1) + tenacity.wait_random(0, 3) + - tenacity.wait_fixed(5)) + r = Retrying( + wait=tenacity.wait_fixed(1) + + tenacity.wait_random(0, 3) + + tenacity.wait_fixed(5) + ) # Test it a few time since it's random for i in six.moves.range(1000): w = r.wait(make_retry_state(1, 5)) @@ -327,10 +335,16 @@ def test_wait_triple_sum(self): self.assertGreaterEqual(w, 6) def test_wait_arbitrary_sum(self): - r = Retrying(wait=sum([tenacity.wait_fixed(1), - tenacity.wait_random(0, 3), - tenacity.wait_fixed(5), - tenacity.wait_none()])) + r = Retrying( + wait=sum( + [ + tenacity.wait_fixed(1), + tenacity.wait_random(0, 3), + tenacity.wait_fixed(5), + tenacity.wait_none(), + ] + ) + ) # Test it a few time since it's random for i in six.moves.range(1000): w = r.wait(make_retry_state(1, 5)) @@ -350,10 +364,13 @@ def _assert_inclusive_epsilon(self, wait, target, epsilon): self.assertGreaterEqual(wait, target - epsilon) def test_wait_chain(self): - r = Retrying(wait=tenacity.wait_chain( - *[tenacity.wait_fixed(1) for i in six.moves.range(2)] + - [tenacity.wait_fixed(4) for i in six.moves.range(2)] + - [tenacity.wait_fixed(8) for i in six.moves.range(1)])) + r = Retrying( + wait=tenacity.wait_chain( + *[tenacity.wait_fixed(1) for i in six.moves.range(2)] + + [tenacity.wait_fixed(4) for i in six.moves.range(2)] + + [tenacity.wait_fixed(8) for i in six.moves.range(1)] + ) + ) for i in six.moves.range(10): w = r.wait(make_retry_state(i + 1, 1)) @@ -368,9 +385,9 @@ def test_wait_chain_multiple_invocations(self): sleep_intervals = [] r = Retrying( sleep=sleep_intervals.append, - wait=tenacity.wait_chain(*[ - tenacity.wait_fixed(i + 1) for i in six.moves.range(3) - ]), + wait=tenacity.wait_chain( + *[tenacity.wait_fixed(i + 1) for i in six.moves.range(3)] + ), stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_if_result(lambda x: x == 1), ) @@ -404,9 +421,7 @@ def test_wait_random_exponential(self): fn = tenacity.wait_random_exponential(10, 5) for _ in six.moves.range(1000): - self._assert_inclusive_range( - fn(make_retry_state(1, 0)), 0.00, 5.00 - ) + self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0.00, 5.00) # Default arguments exist fn = tenacity.wait_random_exponential() @@ -417,9 +432,7 @@ def test_wait_random_exponential_statistically(self): attempt = [] for i in six.moves.range(10): - attempt.append( - [fn(make_retry_state(i, 0)) for _ in six.moves.range(4000)] - ) + attempt.append([fn(make_retry_state(i, 0)) for _ in six.moves.range(4000)]) def mean(lst): return float(sum(lst)) / float(len(lst)) @@ -436,7 +449,6 @@ def mean(lst): self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56) def test_wait_retry_state_attributes(self): - class ExtractCallState(Exception): pass @@ -447,11 +459,15 @@ def waitfunc(retry_state): retrying = Retrying( wait=waitfunc, - retry=(tenacity.retry_if_exception_type() | - tenacity.retry_if_result(lambda result: result == 123))) + retry=( + tenacity.retry_if_exception_type() + | tenacity.retry_if_result(lambda result: result == 123) + ), + ) def returnval(): return 123 + try: retrying(returnval) except ExtractCallState as err: @@ -461,11 +477,11 @@ def returnval(): self.assertEqual(retry_state.kwargs, {}) self.assertEqual(retry_state.outcome.result(), 123) self.assertEqual(retry_state.attempt_number, 1) - self.assertGreaterEqual(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time) def dying(): raise Exception("Broken") + try: retrying(dying) except ExtractCallState as err: @@ -473,40 +489,42 @@ def dying(): self.assertIs(retry_state.fn, dying) self.assertEqual(retry_state.args, ()) self.assertEqual(retry_state.kwargs, {}) - self.assertEqual(str(retry_state.outcome.exception()), 'Broken') + self.assertEqual(str(retry_state.outcome.exception()), "Broken") self.assertEqual(retry_state.attempt_number, 1) - self.assertGreaterEqual(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time) class TestRetryConditions(unittest.TestCase): - def test_retry_if_result(self): - retry = (tenacity.retry_if_result(lambda x: x == 1)) + retry = tenacity.retry_if_result(lambda x: x == 1) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) def test_retry_if_not_result(self): - retry = (tenacity.retry_if_not_result(lambda x: x == 1)) + retry = tenacity.retry_if_not_result(lambda x: x == 1) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, False))) def test_retry_any(self): retry = tenacity.retry_any( tenacity.retry_if_result(lambda x: x == 1), - tenacity.retry_if_result(lambda x: x == 2)) + tenacity.retry_if_result(lambda x: x == 2), + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertTrue(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) @@ -515,35 +533,41 @@ def r(fut): def test_retry_all(self): retry = tenacity.retry_all( tenacity.retry_if_result(lambda x: x == 1), - tenacity.retry_if_result(lambda x: isinstance(x, int))) + tenacity.retry_if_result(lambda x: isinstance(x, int)), + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_and(self): - retry = (tenacity.retry_if_result(lambda x: x == 1) & - tenacity.retry_if_result(lambda x: isinstance(x, int))) + retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result( + lambda x: isinstance(x, int) + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_or(self): - retry = (tenacity.retry_if_result(lambda x: x == "foo") | - tenacity.retry_if_result(lambda x: isinstance(x, int))) + retry = tenacity.retry_if_result( + lambda x: x == "foo" + ) | tenacity.retry_if_result(lambda x: isinstance(x, int)) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, "foo", False))) self.assertFalse(r(tenacity.Future.construct(1, "foobar", False))) self.assertFalse(r(tenacity.Future.construct(1, 2.2, False))) @@ -556,32 +580,30 @@ def _raise_try_again(self): def test_retry_try_again(self): self._attempts = 0 - Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never)(self._raise_try_again) + Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)( + self._raise_try_again + ) self.assertEqual(3, self._attempts) def test_retry_try_again_forever(self): def _r(): raise tenacity.TryAgain - r = Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never) - self.assertRaises(tenacity.RetryError, - r, - _r) - self.assertEqual(5, r.statistics['attempt_number']) + r = Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never) + self.assertRaises(tenacity.RetryError, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) def test_retry_try_again_forever_reraise(self): def _r(): raise tenacity.TryAgain - r = Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never, - reraise=True) - self.assertRaises(tenacity.TryAgain, - r, - _r) - self.assertEqual(5, r.statistics['attempt_number']) + r = Retrying( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_never, + reraise=True, + ) + self.assertRaises(tenacity.TryAgain, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) def test_retry_if_exception_message_negative_no_inputs(self): with self.assertRaises(TypeError): @@ -589,8 +611,7 @@ def test_retry_if_exception_message_negative_no_inputs(self): def test_retry_if_exception_message_negative_too_many_inputs(self): with self.assertRaises(TypeError): - tenacity.retry_if_exception_message( - message="negative", match="negative") + tenacity.retry_if_exception_message(message="negative", match="negative") class NoneReturnUntilAfterCount(object): @@ -738,14 +759,18 @@ def current_time_ms(): return int(round(time.time() * 1000)) -@retry(wait=tenacity.wait_fixed(0.05), - retry=tenacity.retry_if_result(lambda result: result is None)) +@retry( + wait=tenacity.wait_fixed(0.05), + retry=tenacity.retry_if_result(lambda result: result is None), +) def _retryable_test_with_wait(thing): return thing.go() -@retry(stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_result(lambda result: result is None)) +@retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_result(lambda result: result is None), +) def _retryable_test_with_stop(thing): return thing.go() @@ -756,8 +781,8 @@ def _retryable_test_with_exception_type_io(thing): @retry( - stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_exception_type(IOError)) + stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError) +) def _retryable_test_with_exception_type_io_attempt_limit(thing): return thing.go() @@ -769,7 +794,8 @@ def _retryable_test_with_unless_exception_type_name(thing): @retry( stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_unless_exception_type(NameError)) + retry=tenacity.retry_unless_exception_type(NameError), +) def _retryable_test_with_unless_exception_type_name_attempt_limit(thing): return thing.go() @@ -782,31 +808,45 @@ def _retryable_test_with_unless_exception_type_no_input(thing): @retry( stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_if_exception_message( - message=NoCustomErrorAfterCount.derived_message)) + message=NoCustomErrorAfterCount.derived_message + ), +) def _retryable_test_if_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - message=NoCustomErrorAfterCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NoCustomErrorAfterCount.derived_message + ) +) def _retryable_test_if_not_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_not_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_not_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - message=NameErrorUntilCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NameErrorUntilCount.derived_message + ) +) def _retryable_test_not_exception_message_delay(thing): return thing.go() @@ -828,13 +868,13 @@ def _retryable_test_with_exception_type_custom(thing): @retry( stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_exception_type(CustomError)) + retry=tenacity.retry_if_exception_type(CustomError), +) def _retryable_test_with_exception_type_custom_attempt_limit(thing): return thing.go() class TestDecoratorWrapper(unittest.TestCase): - def test_with_wait(self): start = current_time_ms() result = _retryable_test_with_wait(NoneReturnUntilAfterCount(5)) @@ -844,8 +884,9 @@ def test_with_wait(self): def test_retry_with(self): start = current_time_ms() - result = _retryable_test_with_wait.retry_with( - wait=tenacity.wait_fixed(0.1))(NoneReturnUntilAfterCount(5)) + result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( + NoneReturnUntilAfterCount(5) + ) t = current_time_ms() - start self.assertGreaterEqual(t, 500) self.assertTrue(result) @@ -869,8 +910,7 @@ def test_with_stop_on_exception(self): print(re) def test_retry_if_exception_of_type(self): - self.assertTrue(_retryable_test_with_exception_type_io( - NoIOErrorAfterCount(5))) + self.assertTrue(_retryable_test_with_exception_type_io(NoIOErrorAfterCount(5))) try: _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5)) @@ -879,12 +919,12 @@ def test_retry_if_exception_of_type(self): self.assertTrue(isinstance(n, NameError)) print(n) - self.assertTrue(_retryable_test_with_exception_type_custom( - NoCustomErrorAfterCount(5))) + self.assertTrue( + _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5)) + ) try: - _retryable_test_with_exception_type_custom( - NoNameErrorAfterCount(5)) + _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5)) self.fail("Expected NameError") except NameError as n: self.assertTrue(isinstance(n, NameError)) @@ -892,12 +932,12 @@ def test_retry_if_exception_of_type(self): def test_retry_until_exception_of_type_attempt_number(self): try: - self.assertTrue(_retryable_test_with_unless_exception_type_name( - NameErrorUntilCount(5))) + self.assertTrue( + _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) + ) except NameError as e: - s = _retryable_test_with_unless_exception_type_name.\ - retry.statistics - self.assertTrue(s['attempt_number'] == 6) + s = _retryable_test_with_unless_exception_type_name.retry.statistics + self.assertTrue(s["attempt_number"] == 6) print(e) else: self.fail("Expected NameError") @@ -907,12 +947,12 @@ def test_retry_until_exception_of_type_no_type(self): # no input should catch all subclasses of Exception self.assertTrue( _retryable_test_with_unless_exception_type_no_input( - NameErrorUntilCount(5)) + NameErrorUntilCount(5) + ) ) except NameError as e: - s = _retryable_test_with_unless_exception_type_no_input.\ - retry.statistics - self.assertTrue(s['attempt_number'] == 6) + s = _retryable_test_with_unless_exception_type_no_input.retry.statistics + self.assertTrue(s["attempt_number"] == 6) print(e) else: self.fail("Expected NameError") @@ -921,7 +961,8 @@ def test_retry_until_exception_of_type_wrong_exception(self): try: # two iterations with IOError, one that returns True _retryable_test_with_unless_exception_type_name_attempt_limit( - IOErrorUntilCount(2)) + IOErrorUntilCount(2) + ) self.fail("Expected RetryError") except RetryError as e: self.assertTrue(isinstance(e, RetryError)) @@ -929,46 +970,52 @@ def test_retry_until_exception_of_type_wrong_exception(self): def test_retry_if_exception_message(self): try: - self.assertTrue(_retryable_test_if_exception_message_message( - NoCustomErrorAfterCount(3))) + self.assertTrue( + _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.retry.statistics) self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.\ - statistics - self.assertTrue(s['attempt_number'] == 1) + s = _retryable_test_if_not_exception_message_message.retry.statistics + self.assertTrue(s["attempt_number"] == 1) def test_retry_if_not_exception_message_delay(self): try: - self.assertTrue(_retryable_test_not_exception_message_delay( - NameErrorUntilCount(3))) + self.assertTrue( + _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) + ) except NameError: s = _retryable_test_not_exception_message_delay.retry.statistics - print(s['attempt_number']) - self.assertTrue(s['attempt_number'] == 4) + print(s["attempt_number"]) + self.assertTrue(s["attempt_number"] == 4) def test_retry_if_exception_message_match(self): try: - self.assertTrue(_retryable_test_if_exception_message_match( - NoCustomErrorAfterCount(3))) + self.assertTrue( + _retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3)) + ) except CustomError: self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message_match(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.\ - statistics - self.assertTrue(s['attempt_number'] == 1) + s = _retryable_test_if_not_exception_message_message.retry.statistics + self.assertTrue(s["attempt_number"] == 1) def test_defaults(self): self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) @@ -982,11 +1029,14 @@ def test_retry_function_object(self): It raises an error upon trying to wrap it in Py2, because __name__ attribute is missing. It's fixed in Py3 but was never backported. """ + class Hello(object): def __call__(self): return "Hello" - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3)) + + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") @@ -998,12 +1048,13 @@ def test_before_attempts(self): TestBeforeAfterAttempts._attempt_number = 0 def _before(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(1), - stop=tenacity.stop_after_attempt(1), - before=_before) + @retry( + wait=tenacity.wait_fixed(1), + stop=tenacity.stop_after_attempt(1), + before=_before, + ) def _test_before(): pass @@ -1015,12 +1066,13 @@ def test_after_attempts(self): TestBeforeAfterAttempts._attempt_number = 0 def _after(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(3), - after=_after) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(3), + after=_after, + ) def _test_after(): if TestBeforeAfterAttempts._attempt_number < 2: raise Exception("testing after_attempts handler") @@ -1036,9 +1088,11 @@ def _before_sleep(retry_state): self.assertGreater(retry_state.next_action.sleep, 0) _before_sleep.attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + @retry( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) def _test_before_sleep(): if _before_sleep.attempt_number < 2: raise Exception("testing before_sleep_attempts handler") @@ -1055,15 +1109,19 @@ def _before_sleep_log_raises(self, get_call_fn): logger.addHandler(handler) try: _before_sleep = tenacity.before_sleep_log(logger, logging.INFO) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) get_call_fn(retrying)(thing.go) finally: logger.removeHandler(handler) - etalon_re = (r"^Retrying .* in 0\.01 seconds as it raised " - r"(IO|OS)Error: Hi there, I'm an IOError\.$") + etalon_re = ( + r"^Retrying .* in 0\.01 seconds as it raised " + r"(IO|OS)Error: Hi there, I'm an IOError\.$" + ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) @@ -1083,21 +1141,25 @@ def test_before_sleep_log_raises_with_exc_info(self): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, - logging.INFO, - exc_info=True) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=True + ) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) retrying(thing.go) finally: logger.removeHandler(handler) - etalon_re = re.compile(r"^Retrying .* in 0\.01 seconds as it raised " - r"(IO|OS)Error: Hi there, I'm an IOError\.{0}" - r"Traceback \(most recent call last\):{0}" - r".*$".format('\n'), - flags=re.MULTILINE) + etalon_re = re.compile( + r"^Retrying .* in 0\.01 seconds as it raised " + r"(IO|OS)Error: Hi there, I'm an IOError\.{0}" + r"Traceback \(most recent call last\):{0}" + r".*$".format("\n"), + flags=re.MULTILINE, + ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) @@ -1111,18 +1173,21 @@ def test_before_sleep_log_returns(self, exc_info=False): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, - logging.INFO, - exc_info=exc_info) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=exc_info + ) _retry = tenacity.retry_if_result(lambda result: result is None) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - retry=_retry, before_sleep=_before_sleep) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + retry=_retry, + before_sleep=_before_sleep, + ) retrying(thing.go) finally: logger.removeHandler(handler) - etalon_re = r'^Retrying .* in 0\.01 seconds as it returned None\.$' + etalon_re = r"^Retrying .* in 0\.01 seconds as it returned None\.$" self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) @@ -1133,15 +1198,16 @@ def test_before_sleep_log_returns_with_exc_info(self): class TestReraiseExceptions(unittest.TestCase): - def test_reraise_by_default(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - reraise=True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + reraise=True, + ) def _reraised_by_default(): - calls.append('x') + calls.append("x") raise KeyError("Bad key") self.assertRaises(KeyError, _reraised_by_default) @@ -1150,10 +1216,9 @@ def _reraised_by_default(): def test_reraise_from_retry_error(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2)) + @retry(wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(2)) def _raise_key_error(): - calls.append('x') + calls.append("x") raise KeyError("Bad key") def _reraised_key_error(): @@ -1168,11 +1233,13 @@ def _reraised_key_error(): def test_reraise_timeout_from_retry_error(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - retry=lambda retry_state: True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + ) def _mock_fn(): - calls.append('x') + calls.append("x") def _reraised_mock_fn(): try: @@ -1186,19 +1253,20 @@ def _reraised_mock_fn(): def test_reraise_no_exception(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - retry=lambda retry_state: True, - reraise=True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + reraise=True, + ) def _mock_fn(): - calls.append('x') + calls.append("x") self.assertRaises(tenacity.RetryError, _mock_fn) self.assertEqual(2, len(calls)) class TestStatistics(unittest.TestCase): - def test_stats(self): @retry() def _foobar(): @@ -1206,7 +1274,7 @@ def _foobar(): self.assertEqual({}, _foobar.retry.statistics) _foobar() - self.assertEqual(1, _foobar.retry.statistics['attempt_number']) + self.assertEqual(1, _foobar.retry.statistics["attempt_number"]) def test_stats_failing(self): @retry(stop=tenacity.stop_after_attempt(2)) @@ -1216,13 +1284,12 @@ def _foobar(): self.assertEqual({}, _foobar.retry.statistics) try: _foobar() - except Exception: # noqa: B902 + except Exception: # noqa: B902 pass - self.assertEqual(2, _foobar.retry.statistics['attempt_number']) + self.assertEqual(2, _foobar.retry.statistics["attempt_number"]) class TestRetryErrorCallback(unittest.TestCase): - def setUp(self): self._attempt_number = 0 self._callback_called = False @@ -1240,8 +1307,10 @@ def retry_error_callback(retry_state): retry_error_callback.called_times = 0 - @retry(stop=tenacity.stop_after_attempt(num_attempts), - retry_error_callback=retry_error_callback) + @retry( + stop=tenacity.stop_after_attempt(num_attempts), + retry_error_callback=retry_error_callback, + ) def _foobar(): self._attempt_number += 1 raise Exception("This exception should not be raised") @@ -1326,6 +1395,7 @@ def f(): if len(f.calls) <= 1: raise Exception("Retry it!") return 42 + f.calls = [] retry = Retrying() @@ -1341,6 +1411,7 @@ def f(): if len(f.calls) <= 1: raise CustomError("Don't retry!") return 42 + f.calls = [] retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) @@ -1352,6 +1423,7 @@ def test_retry_error(self): def f(): f.calls.append(len(f.calls) + 1) raise Exception("Retry it!") + f.calls = [] retry = Retrying(stop=tenacity.stop_after_attempt(2)) @@ -1366,6 +1438,7 @@ class CustomError(Exception): def f(): f.calls.append(len(f.calls) + 1) raise CustomError("Retry it!") + f.calls = [] retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2)) @@ -1384,9 +1457,9 @@ def invoke(retry, f): class TestRetryException(unittest.TestCase): - def test_retry_error_is_pickleable(self): import pickle + expected = RetryError(last_attempt=123) pickled = pickle.dumps(expected) actual = pickle.loads(pickled) @@ -1394,10 +1467,8 @@ def test_retry_error_is_pickleable(self): class TestRetryTyping(unittest.TestCase): - @pytest.mark.skipif( - sys.version_info < (3, 0), - reason="typeguard not supported for python 2" + sys.version_info < (3, 0), reason="typeguard not supported for python 2" ) def test_retry_type_annotations(self): """The decorator should maintain types of decorated functions.""" @@ -1423,9 +1494,7 @@ def num_to_str(number): # These raise TypeError exceptions if they fail check_type("with_raw", with_raw, typing.Callable[[int], str]) check_type("with_raw_result", with_raw_result, str) - check_type( - "with_constructor", with_constructor, typing.Callable[[int], str] - ) + check_type("with_constructor", with_constructor, typing.Callable[[int], str]) check_type("with_constructor_result", with_constructor_result, str) @@ -1433,7 +1502,7 @@ def num_to_str(number): def reports_deprecation_warning(): __tracebackhide__ = True oldfilters = copy(warnings.filters) - warnings.simplefilter('always') + warnings.simplefilter("always") try: with pytest.warns(DeprecationWarning): yield @@ -1441,7 +1510,7 @@ def reports_deprecation_warning(): warnings.filters = oldfilters -class TestMockingSleep(): +class TestMockingSleep: RETRY_ARGS = dict( wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(5), @@ -1486,5 +1555,5 @@ def test_decorated_retry_with(self, mock_sleep): assert mock_sleep.call_count == 1 -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tenacity/tests/test_tornado.py b/tenacity/tests/test_tornado.py index 23380170..1f72148c 100644 --- a/tenacity/tests/test_tornado.py +++ b/tenacity/tests/test_tornado.py @@ -67,9 +67,10 @@ def test_old_tornado(self): @retry def retryable(thing): pass + finally: gen.is_coroutine_function = old_attr -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index bf734477..243cf5de 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -24,10 +24,7 @@ class TornadoRetrying(BaseRetrying): - - def __init__(self, - sleep=gen.sleep, - **kwargs): + def __init__(self, sleep=gen.sleep, **kwargs): super(TornadoRetrying, self).__init__(**kwargs) self.sleep = sleep @@ -35,8 +32,7 @@ def __init__(self, def __call__(self, fn, *args, **kwargs): self.begin(fn) - retry_state = RetryCallState( - retry_object=self, fn=fn, args=args, kwargs=kwargs) + retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: do = self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): diff --git a/tenacity/wait.py b/tenacity/wait.py index 235a24bd..2f981c89 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -67,9 +67,9 @@ def __init__(self, min=0, max=1): # noqa self.wait_random_max = max def __call__(self, retry_state): - return (self.wait_random_min + - (random.random() * - (self.wait_random_max - self.wait_random_min))) + return self.wait_random_min + ( + random.random() * (self.wait_random_max - self.wait_random_min) + ) class wait_combine(wait_base): @@ -102,8 +102,7 @@ def __init__(self, *strategies): self.strategies = strategies def __call__(self, retry_state): - wait_func_no = min(max(retry_state.attempt_number, 1), - len(self.strategies)) + wait_func_no = min(max(retry_state.attempt_number, 1), len(self.strategies)) wait_func = self.strategies[wait_func_no - 1] return wait_func(retry_state=retry_state) @@ -121,9 +120,7 @@ def __init__(self, start=0, increment=100, max=_utils.MAX_WAIT): # noqa self.max = max def __call__(self, retry_state): - result = self.start + ( - self.increment * (retry_state.attempt_number - 1) - ) + result = self.start + (self.increment * (retry_state.attempt_number - 1)) return max(0, min(result, self.max)) @@ -182,6 +179,5 @@ class wait_random_exponential(wait_exponential): """ def __call__(self, retry_state): - high = super(wait_random_exponential, self).__call__( - retry_state=retry_state) + high = super(wait_random_exponential, self).__call__(retry_state=retry_state) return random.uniform(0, high) diff --git a/tox.ini b/tox.ini index d9a7528c..20f3b12a 100644 --- a/tox.ini +++ b/tox.ini @@ -23,6 +23,7 @@ deps = flake8 flake8-docstrings flake8-rst-docstrings flake8-logging-format + flake8-black commands = flake8 [testenv:reno] @@ -33,5 +34,5 @@ commands = reno {posargs} [flake8] exclude = .tox,.eggs show-source = true -ignore = D100,D101,D102,D103,D104,D105,D107,G200,G201,W503,W504 +ignore = D100,D101,D102,D103,D104,D105,D107,G200,G201,W503,W504,E501 enable-extensions=G From 68faeb07c19a609b11e9e377ac210c266fa74435 Mon Sep 17 00:00:00 2001 From: Antoine Kapps Date: Mon, 1 Feb 2021 14:10:20 +0100 Subject: [PATCH 011/124] Copy whole internal state when retry_with (#233) (#278) * Copy whole internal state when retry_with (#233) Both `retry_error_cls` and `retry_error_callback` were missing from the copy, resulting in a copy that presents a different behavior than the original function. * Apply review feedback - define `_first_set` only once - get away with unittest - use pytest.raises --- tenacity/__init__.py | 28 +++++++++++------- tenacity/tests/test_tenacity.py | 50 +++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 74205127..3ba1b69a 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -181,6 +181,10 @@ def __init__(self, sleep): _unset = object() +def _first_set(first, second): + return second if first is _unset else first + + class RetryError(Exception): """Encapsulates the last attempt instance right before giving up.""" @@ -257,19 +261,23 @@ def copy( after=_unset, before_sleep=_unset, reraise=_unset, + retry_error_cls=_unset, + retry_error_callback=_unset, ): """Copy this object with some parameters changed if needed.""" - if before_sleep is _unset: - before_sleep = self.before_sleep return self.__class__( - sleep=self.sleep if sleep is _unset else sleep, - stop=self.stop if stop is _unset else stop, - wait=self.wait if wait is _unset else wait, - retry=self.retry if retry is _unset else retry, - before=self.before if before is _unset else before, - after=self.after if after is _unset else after, - before_sleep=before_sleep, - reraise=self.reraise if after is _unset else reraise, + sleep=_first_set(sleep, self.sleep), + stop=_first_set(stop, self.stop), + wait=_first_set(wait, self.wait), + retry=_first_set(retry, self.retry), + before=_first_set(before, self.before), + after=_first_set(after, self.after), + before_sleep=_first_set(before_sleep, self.before_sleep), + reraise=_first_set(reraise, self.reraise), + retry_error_cls=_first_set(retry_error_cls, self.retry_error_cls), + retry_error_callback=_first_set( + retry_error_callback, self.retry_error_callback + ), ) def __repr__(self): diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index 0ffd26b4..c685f5c6 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -882,15 +882,6 @@ def test_with_wait(self): self.assertGreaterEqual(t, 250) self.assertTrue(result) - def test_retry_with(self): - start = current_time_ms() - result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( - NoneReturnUntilAfterCount(5) - ) - t = current_time_ms() - start - self.assertGreaterEqual(t, 500) - self.assertTrue(result) - def test_with_stop_on_return_value(self): try: _retryable_test_with_stop(NoneReturnUntilAfterCount(5)) @@ -1041,6 +1032,47 @@ def __call__(self): self.assertEqual(h(), "Hello") +class TestRetryWith: + def test_redefine_wait(self): + start = current_time_ms() + result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( + NoneReturnUntilAfterCount(5) + ) + t = current_time_ms() - start + assert t >= 500 + assert result is True + + def test_redefine_stop(self): + result = _retryable_test_with_stop.retry_with( + stop=tenacity.stop_after_attempt(5) + )(NoneReturnUntilAfterCount(4)) + assert result is True + + def test_retry_error_cls_should_be_preserved(self): + @retry(stop=tenacity.stop_after_attempt(10), retry_error_cls=ValueError) + def _retryable(): + raise Exception("raised for test purposes") + + with pytest.raises(Exception) as exc_ctx: + _retryable.retry_with(stop=tenacity.stop_after_attempt(2))() + + assert exc_ctx.type is ValueError, "Should remap to specific exception type" + + def test_retry_error_callback_should_be_preserved(self): + def return_text(retry_state): + return "Calling %s keeps raising errors after %s attempts" % ( + retry_state.fn.__name__, + retry_state.attempt_number, + ) + + @retry(stop=tenacity.stop_after_attempt(10), retry_error_callback=return_text) + def _retryable(): + raise Exception("raised for test purposes") + + result = _retryable.retry_with(stop=tenacity.stop_after_attempt(5))() + assert result == "Calling _retryable keeps raising errors after 5 attempts" + + class TestBeforeAfterAttempts(unittest.TestCase): _attempt_number = 0 From 3e2244535ccfbb6a4b7fdd77bfc9aa61a1302302 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Tue, 2 Feb 2021 03:14:51 -0500 Subject: [PATCH 012/124] Warn when calling retry() on retry_base (#269) (#270) * Warn when calling retry() on retry_base (#269) * Wrap warning message string to 79 columns (PEP 8) --- tenacity/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 3ba1b69a..99ecec2e 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -40,6 +40,7 @@ from tenacity import _utils # Import all built-in retry strategies for easier usage. +from .retry import retry_base # noqa from .retry import retry_all # noqa from .retry import retry_always # noqa from .retry import retry_any # noqa @@ -117,6 +118,14 @@ def retry(*dargs, **dkw): # noqa else: def wrap(f): + if isinstance(f, retry_base): + warnings.warn( + ( + "Got retry_base instance ({cls}) as callable argument, " + + "this will probably hang indefinitely (did you mean " + + "retry={cls}(...)?)" + ).format(cls=f.__class__.__name__) + ) if iscoroutinefunction is not None and iscoroutinefunction(f): r = AsyncRetrying(*dargs, **dkw) elif ( From 3b5c6bff5815fb86627b1fc693524f936058b61f Mon Sep 17 00:00:00 2001 From: cjc7373 Date: Thu, 11 Feb 2021 16:55:12 +0800 Subject: [PATCH 013/124] avoid including tests when packaging (#281) --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index ebe4e7de..c9c1c5a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,9 @@ install_requires = typing>=3.7.4.1;python_version=='2.7' packages = tenacity +[options.packages.find] +exclude = tests + [options.package_data] tenacity = py.typed From a0ca34cb9908108e1c022531ac35ce8e852b83d9 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 11 Mar 2021 10:33:27 +0100 Subject: [PATCH 014/124] ci(mergify): force release notes to be present This should make sure we don't forget to put a release note before merging a PR. Related to #284 --- .mergify.yml | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 5fdbee1b..74b6ae25 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,5 +1,30 @@ pull_request_rules: - - name: automatic merge + - name: warn on no changelog + conditions: + - -files~=^releasenotes/notes/ + - label!=no-changelog + actions: + comment: + message: | + ⚠️ No release notes detected. Please make sure to use + [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add + a changelog entry. + - name: automatic merge without changelog + conditions: + - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: py27" + - "status-success=ci/circleci: py35" + - "status-success=ci/circleci: py36" + - "status-success=ci/circleci: py37" + - "status-success=ci/circleci: py38" + - "status-success=ci/circleci: py39" + - "#approved-reviews-by>=1" + - label=no-changelog + actions: + merge: + strict: "smart" + method: squash + - name: automatic merge with changelog conditions: - "status-success=ci/circleci: pep8" - "status-success=ci/circleci: py27" @@ -9,12 +34,27 @@ pull_request_rules: - "status-success=ci/circleci: py38" - "status-success=ci/circleci: py39" - "#approved-reviews-by>=1" - - label!=work-in-progress + - files~=^releasenotes/notes/ + actions: + merge: + strict: "smart" + method: squash + - name: automatic merge for jd without changelog + conditions: + - author=jd + - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: py27" + - "status-success=ci/circleci: py35" + - "status-success=ci/circleci: py36" + - "status-success=ci/circleci: py37" + - "status-success=ci/circleci: py38" + - "status-success=ci/circleci: py39" + - label=no-changelog actions: merge: strict: "smart" method: squash - - name: automatic merge for jd + - name: automatic merge for jd with changelog conditions: - author=jd - "status-success=ci/circleci: pep8" @@ -24,7 +64,7 @@ pull_request_rules: - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" - "status-success=ci/circleci: py39" - - label!=work-in-progress + - files~=^releasenotes/notes/ actions: merge: strict: "smart" From 42483eb3187d309ebae0db87a1b05b6078ea95f4 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 11 Mar 2021 13:44:47 +0100 Subject: [PATCH 015/124] ci(mergify): fix yaml block indicator for message --- .mergify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mergify.yml b/.mergify.yml index 74b6ae25..c0137fed 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -5,7 +5,7 @@ pull_request_rules: - label!=no-changelog actions: comment: - message: | + message: > ⚠️ No release notes detected. Please make sure to use [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add a changelog entry. From e03034ff9f23249641aacc5ba4dd11487e654212 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 11 Mar 2021 13:45:16 +0100 Subject: [PATCH 016/124] ci(mergify): do not message closed PR --- .mergify.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.mergify.yml b/.mergify.yml index c0137fed..33c15cdc 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -3,6 +3,7 @@ pull_request_rules: conditions: - -files~=^releasenotes/notes/ - label!=no-changelog + - -closed actions: comment: message: > From 446276f93fea5f97505b25ea7834bc320b21ea58 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 26 Apr 2021 19:07:43 +0200 Subject: [PATCH 017/124] ci: fix pep8 error (#297) --- tenacity/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 99ecec2e..734b8ce9 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -95,14 +95,12 @@ @t.overload def retry(fn): # type: (WrappedFn) -> WrappedFn - """Type signature for @retry as a raw decorator.""" pass @t.overload def retry(*dargs, **dkw): # noqa # type: (...) -> t.Callable[[WrappedFn], WrappedFn] - """Type signature for the @retry() decorator constructor.""" pass From e493087e1d3e4821fe623356c82009eeee8e5f17 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Mon, 26 Apr 2021 10:11:08 -0700 Subject: [PATCH 018/124] Make logger more compatible (#294) * Make logger more compatible * Add release note * Fix black formatting * Ignore D402 error in flake8 * Update PR Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- ...ke-logger-more-compatible-5da1ddf1bab77047.yaml | 4 ++++ tenacity/after.py | 14 ++++++-------- tenacity/before.py | 7 ++++--- tenacity/before_sleep.py | 13 +++++++------ 4 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml diff --git a/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml b/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml new file mode 100644 index 00000000..4e7c59c0 --- /dev/null +++ b/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Use str.format to format the logs internally to make logging compatible with other logger such as loguru. diff --git a/tenacity/after.py b/tenacity/after.py index c577d7c7..0b9ac7f1 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -23,18 +23,16 @@ def after_nothing(retry_state): def after_log(logger, log_level, sec_format="%0.3f"): """After call strategy that logs to some logger the finished attempt.""" - log_tpl = ( - "Finished call to '%s' after " + str(sec_format) + "(s), " - "this was the %s time calling it." - ) def log_it(retry_state): + sec = sec_format % _utils.get_callback_name(retry_state.fn) logger.log( log_level, - log_tpl, - _utils.get_callback_name(retry_state.fn), - retry_state.seconds_since_start, - _utils.to_ordinal(retry_state.attempt_number), + "Finished call to '{0}' after {1}(s), this was the {2} time calling it.".format( + sec, + retry_state.seconds_since_start, + _utils.to_ordinal(retry_state.attempt_number), + ), ) return log_it diff --git a/tenacity/before.py b/tenacity/before.py index 68a2ae61..e51ae4ce 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -27,9 +27,10 @@ def before_log(logger, log_level): def log_it(retry_state): logger.log( log_level, - "Starting call to '%s', this is the %s time calling it.", - _utils.get_callback_name(retry_state.fn), - _utils.to_ordinal(retry_state.attempt_number), + "Starting call to '{0}', this is the {1} time calling it.".format( + _utils.get_callback_name(retry_state.fn), + _utils.to_ordinal(retry_state.attempt_number), + ), ) return log_it diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index d797a246..5f551244 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -28,7 +28,7 @@ def before_sleep_log(logger, log_level, exc_info=False): def log_it(retry_state): if retry_state.outcome.failed: ex = retry_state.outcome.exception() - verb, value = "raised", "%s: %s" % (type(ex).__name__, ex) + verb, value = "raised", "{0}: {1}".format(type(ex).__name__, ex) if exc_info: local_exc_info = get_exc_info_from_future(retry_state.outcome) @@ -40,11 +40,12 @@ def log_it(retry_state): logger.log( log_level, - "Retrying %s in %s seconds as it %s %s.", - _utils.get_callback_name(retry_state.fn), - getattr(retry_state.next_action, "sleep"), - verb, - value, + "Retrying {0} in {1} seconds as it {2} {3}.".format( + _utils.get_callback_name(retry_state.fn), + getattr(retry_state.next_action, "sleep"), + verb, + value, + ), exc_info=local_exc_info, ) From e31e0119aa37919d90a388177add3ba027ec0f3f Mon Sep 17 00:00:00 2001 From: OMOTO Tsukasa Date: Wed, 26 May 2021 21:33:19 +0900 Subject: [PATCH 019/124] Add retry_if_not_exception_type() (#300) * Add retry_except_exception_type Fixes #256 * Apply suggestions from code review Co-authored-by: Julien Danjou * rename 'except' to 'if not' * fix test * rename again Co-authored-by: Julien Danjou --- ..._except_exception_type-31b31da1924d55f4.yaml | 3 +++ tenacity/__init__.py | 1 + tenacity/retry.py | 10 ++++++++++ tenacity/tests/test_tenacity.py | 17 +++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml diff --git a/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml b/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml new file mode 100644 index 00000000..bd89f84d --- /dev/null +++ b/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add ``retry_if_not_exception_type()`` that allows to retry if a raised exception doesn't match given exceptions. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 734b8ce9..9292cdb9 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -46,6 +46,7 @@ from .retry import retry_any # noqa from .retry import retry_if_exception # noqa from .retry import retry_if_exception_type # noqa +from .retry import retry_if_not_exception_type # noqa from .retry import retry_if_not_result # noqa from .retry import retry_if_result # noqa from .retry import retry_never # noqa diff --git a/tenacity/retry.py b/tenacity/retry.py index ebf26df5..405cd44a 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -80,6 +80,16 @@ def __init__(self, exception_types=Exception): ) +class retry_if_not_exception_type(retry_if_exception): + """Retries except an exception has been raised of one or more types.""" + + def __init__(self, exception_types=Exception): + self.exception_types = exception_types + super(retry_if_not_exception_type, self).__init__( + lambda e: not isinstance(e, exception_types) + ) + + class retry_unless_exception_type(retry_if_exception): """Retries until an exception is raised of one or more types.""" diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index c685f5c6..eca2618c 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -780,6 +780,11 @@ def _retryable_test_with_exception_type_io(thing): return thing.go() +@retry(retry=tenacity.retry_if_not_exception_type(IOError)) +def _retryable_test_if_not_exception_type_io(thing): + return thing.go() + + @retry( stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError) ) @@ -921,6 +926,18 @@ def test_retry_if_exception_of_type(self): self.assertTrue(isinstance(n, NameError)) print(n) + def test_retry_except_exception_of_type(self): + self.assertTrue( + _retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5)) + ) + + try: + _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5)) + self.fail("Expected IOError") + except IOError as err: + self.assertTrue(isinstance(err, IOError)) + print(err) + def test_retry_until_exception_of_type_attempt_number(self): try: self.assertTrue( From 7a463d10180cbd6e0e16488624168ddc6ba1a00e Mon Sep 17 00:00:00 2001 From: Andrey Semakin Date: Sat, 17 Apr 2021 22:39:31 +0500 Subject: [PATCH 020/124] Drop support for deprecated Python versions (2.7 and 3.5) --- .circleci/config.yml | 20 ------------------- .mergify.yml | 8 -------- ...ated-python-versions-69a05cb2e0f1034c.yaml | 4 ++++ setup.cfg | 8 ++------ tox.ini | 11 +++++----- 5 files changed, 11 insertions(+), 40 deletions(-) create mode 100644 releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index 43a8a4ed..c9baa7b3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,24 +10,6 @@ jobs: command: | sudo pip install tox tox -e pep8 - py27: - docker: - - image: circleci/python:2.7 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py27 - py35: - docker: - - image: circleci/python:3.5 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py35 py36: docker: - image: circleci/python:3.6 @@ -94,8 +76,6 @@ workflows: test: jobs: - pep8 - - py27 - - py35 - py36 - py37 - py38 diff --git a/.mergify.yml b/.mergify.yml index 33c15cdc..9ebabcb4 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -13,8 +13,6 @@ pull_request_rules: - name: automatic merge without changelog conditions: - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: py27" - - "status-success=ci/circleci: py35" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -28,8 +26,6 @@ pull_request_rules: - name: automatic merge with changelog conditions: - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: py27" - - "status-success=ci/circleci: py35" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -44,8 +40,6 @@ pull_request_rules: conditions: - author=jd - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: py27" - - "status-success=ci/circleci: py35" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -59,8 +53,6 @@ pull_request_rules: conditions: - author=jd - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: py27" - - "status-success=ci/circleci: py35" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" diff --git a/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml b/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml new file mode 100644 index 00000000..ca370f06 --- /dev/null +++ b/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml @@ -0,0 +1,4 @@ +--- +other: + - | + Drop support for deprecated Python versions (2.7 and 3.5) diff --git a/setup.cfg b/setup.cfg index c9c1c5a4..47b15e27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,10 +11,8 @@ classifier = Intended Audience :: Developers License :: OSI Approved :: Apache Software License Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 @@ -24,9 +22,7 @@ classifier = [options] install_requires = six>=1.9.0 - futures>=3.0;python_version=='2.7' - monotonic>=0.6;python_version=='2.7' - typing>=3.7.4.1;python_version=='2.7' +python_requires = >=3.6 packages = tenacity [options.packages.find] diff --git a/tox.ini b/tox.ini index 20f3b12a..6ded22f5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py35, py36, py37, py38, py39, pep8, pypy +envlist = py36, py37, py38, py39, pep8, pypy3 [testenv] usedevelop = True @@ -7,12 +7,11 @@ sitepackages = False deps = .[doc] pytest - typeguard;python_version>='3.0' + typeguard commands = - py{27,py}: pytest --ignore='tenacity/tests/test_asyncio.py' {posargs} - py3{5,6,7,8,9}: pytest {posargs} - py3{5,6,7,8,9}: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{5,6,7,8,9}: sphinx-build -a -E -W -b html doc/source doc/build + py{36,37,38,39,py3}: pytest {posargs} + py{36,37,38,39,py3}: sphinx-build -a -E -W -b doctest doc/source doc/build + py{36,37,38,39,py3}: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 89d890fc33a37cfc49f0bb18f1184a02bb94347a Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Wed, 23 Jun 2021 16:12:07 +0200 Subject: [PATCH 021/124] Fix #291: drop python < 3.6 (#304) * Removed all transitional requirements (including `six`) * Most type annotations fixed. * Python 3.10 tested. * Most part of code is type annotated. Co-authored-by: Julien Danjou Co-authored-by: Julien Danjou --- .../notes/py36_plus-c425fb3aa17c6682.yaml | 4 + setup.cfg | 2 +- tenacity/__init__.py | 205 +++++++++--------- tenacity/_asyncio.py | 2 +- tenacity/_utils.py | 101 +-------- tenacity/after.py | 17 +- tenacity/before.py | 13 +- tenacity/before_sleep.py | 20 +- tenacity/compat.py | 23 -- tenacity/nap.py | 12 +- tenacity/retry.py | 99 +++++---- tenacity/stop.py | 37 ++-- tenacity/tests/test_asyncio.py | 5 +- tenacity/tests/test_tenacity.py | 56 +++-- tenacity/tornadoweb.py | 2 +- tenacity/wait.py | 43 ++-- tox.ini | 9 +- 17 files changed, 308 insertions(+), 342 deletions(-) create mode 100644 releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml delete mode 100644 tenacity/compat.py diff --git a/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml b/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml new file mode 100644 index 00000000..7e6f5948 --- /dev/null +++ b/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml @@ -0,0 +1,4 @@ +--- +features: + - Most part of the code is type annotated. + - Python 3.10 support has been added. diff --git a/setup.cfg b/setup.cfg index 47b15e27..32e9913a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,11 +17,11 @@ classifier = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: Utilities [options] install_requires = - six>=1.9.0 python_requires = >=3.6 packages = tenacity diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 9292cdb9..540ff4d0 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -17,25 +17,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -try: - from inspect import iscoroutinefunction -except ImportError: - iscoroutinefunction = None - -try: - import tornado -except ImportError: - tornado = None - +import functools import sys import threading +import time import typing as t import warnings from abc import ABCMeta, abstractmethod from concurrent import futures - - -import six +from inspect import iscoroutinefunction from tenacity import _utils @@ -89,23 +79,32 @@ from .before_sleep import before_sleep_log # noqa from .before_sleep import before_sleep_nothing # noqa +try: + import tornado # type: ignore +except ImportError: + tornado = None # type: ignore + +if t.TYPE_CHECKING: + import types + + from .wait import wait_base + from .stop import stop_base + WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable) @t.overload -def retry(fn): - # type: (WrappedFn) -> WrappedFn +def retry(fn: WrappedFn) -> WrappedFn: pass @t.overload -def retry(*dargs, **dkw): # noqa - # type: (...) -> t.Callable[[WrappedFn], WrappedFn] +def retry(*dargs: t.Any, **dkw: t.Any) -> t.Callable[[WrappedFn], WrappedFn]: # noqa pass -def retry(*dargs, **dkw): # noqa +def retry(*dargs: t.Any, **dkw: t.Any) -> t.Union[WrappedFn, t.Callable[[WrappedFn], WrappedFn]]: # noqa """Wrap a function with a new `Retrying` object. :param dargs: positional arguments passed to Retrying object @@ -116,7 +115,7 @@ def retry(*dargs, **dkw): # noqa return retry()(dargs[0]) else: - def wrap(f): + def wrap(f: WrappedFn) -> WrappedFn: if isinstance(f, retry_base): warnings.warn( ( @@ -126,7 +125,7 @@ def wrap(f): ).format(cls=f.__class__.__name__) ) if iscoroutinefunction is not None and iscoroutinefunction(f): - r = AsyncRetrying(*dargs, **dkw) + r: "BaseRetrying" = AsyncRetrying(*dargs, **dkw) elif ( tornado and hasattr(tornado.gen, "is_coroutine_function") @@ -148,7 +147,7 @@ class TryAgain(Exception): NO_RESULT = object() -class DoAttempt(object): +class DoAttempt: pass @@ -156,7 +155,7 @@ class DoSleep(float): pass -class BaseAction(object): +class BaseAction: """Base class for representing actions to take by retry object. Concrete implementations must define: @@ -165,16 +164,16 @@ class BaseAction(object): - NAME: for identification in retry object methods and callbacks """ - REPR_FIELDS = () - NAME = None + REPR_FIELDS: t.Sequence[str] = () + NAME: t.Optional[str] = None - def __repr__(self): + def __repr__(self) -> str: state_str = ", ".join( "%s=%r" % (field, getattr(self, field)) for field in self.REPR_FIELDS ) return "%s(%s)" % (type(self).__name__, state_str) - def __str__(self): + def __str__(self) -> str: return repr(self) @@ -182,66 +181,71 @@ class RetryAction(BaseAction): REPR_FIELDS = ("sleep",) NAME = "retry" - def __init__(self, sleep): + def __init__(self, sleep: t.SupportsFloat) -> None: self.sleep = float(sleep) _unset = object() -def _first_set(first, second): +def _first_set(first: t.Union[t.Any, object], second: t.Any) -> t.Any: return second if first is _unset else first class RetryError(Exception): """Encapsulates the last attempt instance right before giving up.""" - def __init__(self, last_attempt): + def __init__(self, last_attempt: "Future") -> None: self.last_attempt = last_attempt - super(RetryError, self).__init__(last_attempt) + super().__init__(last_attempt) - def reraise(self): + def reraise(self) -> t.NoReturn: if self.last_attempt.failed: raise self.last_attempt.result() raise self - def __str__(self): + def __str__(self) -> str: return "{0}[{1}]".format(self.__class__.__name__, self.last_attempt) -class AttemptManager(object): +class AttemptManager: """Manage attempt context.""" - def __init__(self, retry_state): + def __init__(self, retry_state: "RetryCallState"): self.retry_state = retry_state - def __enter__(self): + def __enter__(self) -> None: pass - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, # noqa:BLK100 + exc_type: t.Optional[t.Type[BaseException]], + exc_value: t.Optional[BaseException], + traceback: t.Optional["types.TracebackType"], + ) -> t.Optional[bool]: if isinstance(exc_value, BaseException): self.retry_state.set_exception((exc_type, exc_value, traceback)) return True # Swallow exception. else: # We don't have the result, actually. self.retry_state.set_result(None) + return None -class BaseRetrying(object): - __metaclass__ = ABCMeta +class BaseRetrying(metaclass=ABCMeta): def __init__( self, - sleep=sleep, - stop=stop_never, - wait=wait_none(), - retry=retry_if_exception_type(), - before=before_nothing, - after=after_nothing, - before_sleep=None, - reraise=False, - retry_error_cls=RetryError, - retry_error_callback=None, + sleep: t.Callable[[t.Union[int, float]], None] = sleep, + stop: "stop_base" = stop_never, + wait: "wait_base" = wait_none(), + retry: retry_base = retry_if_exception_type(), + before: t.Callable[["RetryCallState"], None] = before_nothing, + after: t.Callable[["RetryCallState"], None] = after_nothing, + before_sleep: t.Optional[t.Callable[["RetryCallState"], None]] = None, + reraise: bool = False, + retry_error_cls: t.Type[RetryError] = RetryError, + retry_error_callback: t.Optional[t.Callable[["RetryCallState"], t.Any]] = None, ): self.sleep = sleep self.stop = stop @@ -257,21 +261,21 @@ def __init__( # This attribute was moved to RetryCallState and is deprecated on # Retrying objects but kept for backward compatibility. - self.fn = None + self.fn: t.Optional[WrappedFn] = None def copy( self, - sleep=_unset, - stop=_unset, - wait=_unset, - retry=_unset, - before=_unset, - after=_unset, - before_sleep=_unset, - reraise=_unset, - retry_error_cls=_unset, - retry_error_callback=_unset, - ): + sleep: t.Union[t.Callable[[t.Union[int, float]], None], object] = _unset, + stop: t.Union["stop_base", object] = _unset, + wait: t.Union["wait_base", object] = _unset, + retry: t.Union[retry_base, object] = _unset, + before: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, + after: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, + before_sleep: t.Union[t.Optional[t.Callable[["RetryCallState"], None]], object] = _unset, + reraise: t.Union[bool, object] = _unset, + retry_error_cls: t.Union[t.Type[RetryError], object] = _unset, + retry_error_callback: t.Union[t.Optional[t.Callable[["RetryCallState"], t.Any]], object] = _unset, + ) -> "BaseRetrying": """Copy this object with some parameters changed if needed.""" return self.__class__( sleep=_first_set(sleep, self.sleep), @@ -288,7 +292,7 @@ def copy( ), ) - def __repr__(self): + def __repr__(self) -> str: attrs = dict( _utils.visible_attrs(self, attrs={"me": id(self)}), __class__=self.__class__.__name__, @@ -300,7 +304,7 @@ def __repr__(self): ) % (attrs) @property - def statistics(self): + def statistics(self) -> t.Dict[str, t.Any]: """Return a dictionary of runtime statistics. This dictionary will be empty when the controller has never been @@ -327,17 +331,17 @@ def statistics(self): self._local.statistics = {} return self._local.statistics - def wraps(self, f): + def wraps(self, f: WrappedFn) -> WrappedFn: """Wrap a function for retrying. :param f: A function to wraps for retrying. """ - @_utils.wraps(f) - def wrapped_f(*args, **kw): + @functools.wraps(f) + def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: return self(f, *args, **kw) - def retry_with(*args, **kwargs): + def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return self.copy(*args, **kwargs).wraps(f) wrapped_f.retry = self @@ -345,14 +349,14 @@ def retry_with(*args, **kwargs): return wrapped_f - def begin(self, fn): + def begin(self, fn: t.Optional[WrappedFn]) -> None: self.statistics.clear() - self.statistics["start_time"] = _utils.now() + self.statistics["start_time"] = time.monotonic() self.statistics["attempt_number"] = 1 self.statistics["idle_for"] = 0 self.fn = fn - def iter(self, retry_state): # noqa + def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa fut = retry_state.outcome if fut is None: if self.before is not None: @@ -366,16 +370,16 @@ def iter(self, retry_state): # noqa return fut.result() if self.after is not None: - self.after(retry_state=retry_state) + self.after(retry_state) self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start if self.stop(retry_state=retry_state): if self.retry_error_callback: - return self.retry_error_callback(retry_state=retry_state) + return self.retry_error_callback(retry_state) retry_exc = self.retry_error_cls(fut) if self.reraise: raise retry_exc.reraise() - six.raise_from(retry_exc, fut.exception()) + raise retry_exc from fut.exception() if self.wait: sleep = self.wait(retry_state=retry_state) @@ -387,11 +391,11 @@ def iter(self, retry_state): # noqa self.statistics["attempt_number"] += 1 if self.before_sleep is not None: - self.before_sleep(retry_state=retry_state) + self.before_sleep(retry_state) return DoSleep(sleep) - def __iter__(self): + def __iter__(self) -> t.Generator[AttemptManager, None, None]: self.begin(None) retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) @@ -406,10 +410,10 @@ def __iter__(self): break @abstractmethod - def __call__(self, *args, **kwargs): + def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: pass - def call(self, *args, **kwargs): + def call(self, *args: t.Any, **kwargs: t.Any) -> t.Union[DoAttempt, DoSleep, t.Any]: """Use ``__call__`` instead because this method is deprecated.""" warnings.warn( "'call()' method is deprecated. " + "Use '__call__()' instead", @@ -421,7 +425,7 @@ def call(self, *args, **kwargs): class Retrying(BaseRetrying): """Retrying controller.""" - def __call__(self, fn, *args, **kwargs): + def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: self.begin(fn) retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) @@ -444,17 +448,17 @@ def __call__(self, fn, *args, **kwargs): class Future(futures.Future): """Encapsulates a (future or past) attempted call to a target function.""" - def __init__(self, attempt_number): - super(Future, self).__init__() + def __init__(self, attempt_number: int) -> None: + super().__init__() self.attempt_number = attempt_number @property - def failed(self): + def failed(self) -> bool: """Return whether a exception is being held in this future.""" return self.exception() is not None @classmethod - def construct(cls, attempt_number, value, has_exception): + def construct(cls, attempt_number: int, value: t.Any, has_exception: bool) -> "Future": """Construct a new Future object.""" fut = cls(attempt_number) if has_exception: @@ -464,12 +468,18 @@ def construct(cls, attempt_number, value, has_exception): return fut -class RetryCallState(object): +class RetryCallState: """State related to a single call wrapped with Retrying.""" - def __init__(self, retry_object, fn, args, kwargs): + def __init__( + self, + retry_object: BaseRetrying, + fn: t.Optional[WrappedFn], + args: t.Any, + kwargs: t.Any, + ) -> None: #: Retry call start timestamp - self.start_time = _utils.now() + self.start_time = time.monotonic() #: Retry manager object self.retry_object = retry_object #: Function wrapped by this retry call @@ -480,43 +490,42 @@ def __init__(self, retry_object, fn, args, kwargs): self.kwargs = kwargs #: The number of the current attempt - self.attempt_number = 1 + self.attempt_number: int = 1 #: Last outcome (result or exception) produced by the function - self.outcome = None + self.outcome: t.Optional[Future] = None #: Timestamp of the last outcome - self.outcome_timestamp = None + self.outcome_timestamp: t.Optional[float] = None #: Time spent sleeping in retries - self.idle_for = 0 + self.idle_for: float = 0. #: Next action as decided by the retry manager - self.next_action = None + self.next_action: t.Optional[RetryAction] = None @property - def seconds_since_start(self): + def seconds_since_start(self) -> t.Optional[float]: if self.outcome_timestamp is None: return None return self.outcome_timestamp - self.start_time - def prepare_for_next_attempt(self): + def prepare_for_next_attempt(self) -> None: self.outcome = None self.outcome_timestamp = None self.attempt_number += 1 self.next_action = None - def set_result(self, val): - ts = _utils.now() + def set_result(self, val: t.Any) -> None: + ts = time.monotonic() fut = Future(self.attempt_number) fut.set_result(val) self.outcome, self.outcome_timestamp = fut, ts - def set_exception(self, exc_info): - ts = _utils.now() + def set_exception(self, exc_info: t.Tuple[t.Type[BaseException], BaseException, "types.TracebackType"]) -> None: + ts = time.monotonic() fut = Future(self.attempt_number) - _utils.capture(fut, exc_info) + fut.set_exception(exc_info[1]) self.outcome, self.outcome_timestamp = fut, ts -if iscoroutinefunction: - from tenacity._asyncio import AsyncRetrying +from tenacity._asyncio import AsyncRetrying # noqa:E402,I100 if tornado: from tenacity.tornadoweb import TornadoRetrying diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 979b6544..ba8e208f 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -27,7 +27,7 @@ class AsyncRetrying(BaseRetrying): def __init__(self, sleep=sleep, **kwargs): - super(AsyncRetrying, self).__init__(**kwargs) + super().__init__(**kwargs) self.sleep = sleep async def __call__(self, fn, *args, **kwargs): diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 625d5901..9e2b7f15 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -16,61 +16,18 @@ import inspect import sys -import time -from functools import update_wrapper +import typing -import six -# sys.maxint / 2, since Python 3.2 doesn't have a sys.maxint... -try: - MAX_WAIT = sys.maxint / 2 -except AttributeError: - MAX_WAIT = 1073741823 +# sys.maxsize: +# An integer giving the maximum value a variable of type Py_ssize_t can take. +MAX_WAIT = sys.maxsize / 2 -if six.PY2: - from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES - - def wraps(fn): - """Do the same as six.wraps but only copy attributes that exist. - - For example, object instances don't have __name__ attribute, so - six.wraps fails. This is fixed in Python 3 - (https://bugs.python.org/issue3445), but didn't get backported to six. - - Also, see https://github.com/benjaminp/six/issues/250. - """ - - def filter_hasattr(obj, attrs): - return tuple(a for a in attrs if hasattr(obj, a)) - - return six.wraps( - fn, - assigned=filter_hasattr(fn, WRAPPER_ASSIGNMENTS), - updated=filter_hasattr(fn, WRAPPER_UPDATES), - ) - - def capture(fut, tb): - # TODO(harlowja): delete this in future, since its - # has to repeatedly calculate this crap. - fut.set_exception_info(tb[1], tb[2]) - - def getargspec(func): - # This was deprecated in Python 3. - return inspect.getargspec(func) - - -else: - from functools import wraps # noqa - - def capture(fut, tb): - fut.set_exception(tb[1]) - - def getargspec(func): - return inspect.getfullargspec(func) - - -def visible_attrs(obj, attrs=None): +def visible_attrs( + obj: typing.Any, + attrs: typing.Optional[typing.Dict[str, typing.Any]] = None, +) -> typing.Dict[str, typing.Any]: if attrs is None: attrs = {} for attr_name, attr in inspect.getmembers(obj): @@ -80,7 +37,7 @@ def visible_attrs(obj, attrs=None): return attrs -def find_ordinal(pos_num): +def find_ordinal(pos_num: int) -> str: # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers if pos_num == 0: return "th" @@ -90,17 +47,17 @@ def find_ordinal(pos_num): return "nd" elif pos_num == 3: return "rd" - elif pos_num >= 4 and pos_num <= 20: + elif 4 <= pos_num <= 20: return "th" else: return find_ordinal(pos_num % 10) -def to_ordinal(pos_num): +def to_ordinal(pos_num: int) -> str: return "%i%s" % (pos_num, find_ordinal(pos_num)) -def get_callback_name(cb): +def get_callback_name(cb: typing.Callable[..., typing.Any]) -> str: """Get a callback fully-qualified name. If no name can be produced ``repr(cb)`` is called and returned. @@ -111,14 +68,6 @@ def get_callback_name(cb): except AttributeError: try: segments.append(cb.__name__) - if inspect.ismethod(cb): - try: - # This attribute doesn't exist on py3.x or newer, so - # we optionally ignore it... (on those versions of - # python `__qualname__` should have been found anyway). - segments.insert(0, cb.im_class.__name__) - except AttributeError: - pass except AttributeError: pass if not segments: @@ -131,29 +80,3 @@ def get_callback_name(cb): except AttributeError: pass return ".".join(segments) - - -try: - now = time.monotonic # noqa -except AttributeError: - from monotonic import monotonic as now # noqa - - -class cached_property(object): - """A property that is computed once per instance. - - Upon being computed it replaces itself with an ordinary attribute. Deleting - the attribute resets the property. - - Source: https://github.com/bottlepy/bottle/blob/1de24157e74a6971d136550afe1b63eec5b0df2b/bottle.py#L234-L246 - """ # noqa: E501 - - def __init__(self, func): - update_wrapper(self, func) - self.func = func - - def __get__(self, obj, cls): - if obj is None: - return self - value = obj.__dict__[self.func.__name__] = self.func(obj) - return value diff --git a/tenacity/after.py b/tenacity/after.py index 0b9ac7f1..97c94071 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -14,17 +14,28 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + from tenacity import _utils +if typing.TYPE_CHECKING: + import logging + + from tenacity import RetryCallState + -def after_nothing(retry_state): +def after_nothing(retry_state: "RetryCallState") -> None: """After call strategy that does nothing.""" -def after_log(logger, log_level, sec_format="%0.3f"): +def after_log( + logger: "logging.Logger", + log_level: int, + sec_format: str = "%0.3f", +) -> typing.Callable[["RetryCallState"], None]: """After call strategy that logs to some logger the finished attempt.""" - def log_it(retry_state): + def log_it(retry_state: "RetryCallState") -> None: sec = sec_format % _utils.get_callback_name(retry_state.fn) logger.log( log_level, diff --git a/tenacity/before.py b/tenacity/before.py index e51ae4ce..2e72f3ac 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -14,17 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + from tenacity import _utils +if typing.TYPE_CHECKING: + import logging + + from tenacity import RetryCallState + -def before_nothing(retry_state): +def before_nothing(retry_state: "RetryCallState") -> None: """Before call strategy that does nothing.""" -def before_log(logger, log_level): +def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["RetryCallState"], None]: # noqa:BLK100 """Before call strategy that logs to some logger the attempt.""" - def log_it(retry_state): + def log_it(retry_state: "RetryCallState") -> None: logger.log( log_level, "Starting call to '{0}', this is the {1} time calling it.".format( diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index 5f551244..e9a332b0 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -14,24 +14,34 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + from tenacity import _utils -from tenacity.compat import get_exc_info_from_future + +if typing.TYPE_CHECKING: + import logging + + from tenacity import RetryCallState -def before_sleep_nothing(retry_state): +def before_sleep_nothing(retry_state: "RetryCallState") -> None: """Before call strategy that does nothing.""" -def before_sleep_log(logger, log_level, exc_info=False): +def before_sleep_log( + logger: "logging.Logger", + log_level: int, + exc_info: bool = False, +) -> typing.Callable[["RetryCallState"], None]: """Before call strategy that logs to some logger the attempt.""" - def log_it(retry_state): + def log_it(retry_state: "RetryCallState") -> None: if retry_state.outcome.failed: ex = retry_state.outcome.exception() verb, value = "raised", "{0}: {1}".format(type(ex).__name__, ex) if exc_info: - local_exc_info = get_exc_info_from_future(retry_state.outcome) + local_exc_info = retry_state.outcome.exception() else: local_exc_info = False else: diff --git a/tenacity/compat.py b/tenacity/compat.py deleted file mode 100644 index c08ff774..00000000 --- a/tenacity/compat.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Utilities for providing backward compatibility.""" -import six - - -def get_exc_info_from_future(future): - """ - Get an exc_info value from a Future. - - Given a a Future instance, retrieve an exc_info value suitable for passing - in as the exc_info parameter to logging.Logger.log() and related methods. - - On Python 2, this will be a (type, value, traceback) triple. - On Python 3, this will be an exception instance (with embedded traceback). - - If there was no exception, None is returned on both versions of Python. - """ - if six.PY3: - return future.exception() - else: - ex, tb = future.exception_info() - if ex is None: - return None - return type(ex), ex, tb diff --git a/tenacity/nap.py b/tenacity/nap.py index 83ff839c..80f34b0c 100644 --- a/tenacity/nap.py +++ b/tenacity/nap.py @@ -17,9 +17,13 @@ # limitations under the License. import time +import typing +if typing.TYPE_CHECKING: + import threading -def sleep(seconds): + +def sleep(seconds: float) -> None: """ Sleep strategy that delays execution for a given number of seconds. @@ -28,13 +32,13 @@ def sleep(seconds): time.sleep(seconds) -class sleep_using_event(object): +class sleep_using_event: """Sleep strategy that waits on an event to be set.""" - def __init__(self, event): + def __init__(self, event: "threading.Event") -> None: self.event = event - def __call__(self, timeout): + def __call__(self, timeout: typing.Optional[float]) -> None: # NOTE(harlowja): this may *not* actually wait for timeout # seconds if the event is set (ie this may eject out early). self.event.wait(timeout=timeout) diff --git a/tenacity/retry.py b/tenacity/retry.py index 405cd44a..a03b6009 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -18,29 +18,30 @@ import abc import re +import typing -import six +if typing.TYPE_CHECKING: + from tenacity import RetryCallState -@six.add_metaclass(abc.ABCMeta) -class retry_base(object): +class retry_base(metaclass=abc.ABCMeta): """Abstract base class for retry strategies.""" @abc.abstractmethod - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: pass - def __and__(self, other): + def __and__(self, other: "retry_base") -> "retry_all": return retry_all(self, other) - def __or__(self, other): + def __or__(self, other: "retry_base") -> "retry_any": return retry_any(self, other) class _retry_never(retry_base): """Retry strategy that never rejects any result.""" - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return False @@ -50,7 +51,7 @@ def __call__(self, retry_state): class _retry_always(retry_base): """Retry strategy that always rejects any result.""" - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return True @@ -60,10 +61,10 @@ def __call__(self, retry_state): class retry_if_exception(retry_base): """Retry strategy that retries if an exception verifies a predicate.""" - def __init__(self, predicate): + def __init__(self, predicate: typing.Callable[[BaseException], bool]) -> None: self.predicate = predicate - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: if retry_state.outcome.failed: return self.predicate(retry_state.outcome.exception()) else: @@ -73,33 +74,45 @@ def __call__(self, retry_state): class retry_if_exception_type(retry_if_exception): """Retries if an exception has been raised of one or more types.""" - def __init__(self, exception_types=Exception): + def __init__( + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, # noqa:BLK100 + ) -> None: self.exception_types = exception_types - super(retry_if_exception_type, self).__init__( - lambda e: isinstance(e, exception_types) - ) + super().__init__(lambda e: isinstance(e, exception_types)) class retry_if_not_exception_type(retry_if_exception): """Retries except an exception has been raised of one or more types.""" - def __init__(self, exception_types=Exception): + def __init__( + self, # noqa:BLK100 + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: self.exception_types = exception_types - super(retry_if_not_exception_type, self).__init__( - lambda e: not isinstance(e, exception_types) - ) + super().__init__(lambda e: not isinstance(e, exception_types)) class retry_unless_exception_type(retry_if_exception): """Retries until an exception is raised of one or more types.""" - def __init__(self, exception_types=Exception): + def __init__( + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception + ) -> None: self.exception_types = exception_types - super(retry_unless_exception_type, self).__init__( - lambda e: not isinstance(e, exception_types) - ) + super().__init__(lambda e: not isinstance(e, exception_types)) - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: # always retry if no exception was raised if not retry_state.outcome.failed: return True @@ -109,10 +122,10 @@ def __call__(self, retry_state): class retry_if_result(retry_base): """Retries if the result verifies a predicate.""" - def __init__(self, predicate): + def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None: self.predicate = predicate - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: if not retry_state.outcome.failed: return self.predicate(retry_state.outcome.result()) else: @@ -122,10 +135,10 @@ def __call__(self, retry_state): class retry_if_not_result(retry_base): """Retries if the result refutes a predicate.""" - def __init__(self, predicate): + def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None: self.predicate = predicate - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: if not retry_state.outcome.failed: return not self.predicate(retry_state.outcome.result()) else: @@ -135,7 +148,11 @@ def __call__(self, retry_state): class retry_if_exception_message(retry_if_exception): """Retries if an exception message equals or matches.""" - def __init__(self, message=None, match=None): + def __init__( + self, + message: typing.Optional[str] = None, + match: typing.Optional[str] = None, + ) -> None: if message and match: raise TypeError( "{}() takes either 'message' or 'match', not both".format( @@ -146,15 +163,15 @@ def __init__(self, message=None, match=None): # set predicate if message: - def message_fnc(exception): + def message_fnc(exception: BaseException) -> bool: return message == str(exception) predicate = message_fnc elif match: prog = re.compile(match) - def match_fnc(exception): - return prog.match(str(exception)) + def match_fnc(exception: BaseException) -> bool: + return bool(prog.match(str(exception))) predicate = match_fnc else: @@ -164,19 +181,23 @@ def match_fnc(exception): ) ) - super(retry_if_exception_message, self).__init__(predicate) + super().__init__(predicate) class retry_if_not_exception_message(retry_if_exception_message): """Retries until an exception message equals or matches.""" - def __init__(self, *args, **kwargs): - super(retry_if_not_exception_message, self).__init__(*args, **kwargs) + def __init__( + self, + message: typing.Optional[str] = None, + match: typing.Optional[str] = None, + ) -> None: + super().__init__(message, match) # invert predicate if_predicate = self.predicate self.predicate = lambda *args_, **kwargs_: not if_predicate(*args_, **kwargs_) - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: if not retry_state.outcome.failed: return True return self.predicate(retry_state.outcome.exception()) @@ -185,18 +206,18 @@ def __call__(self, retry_state): class retry_any(retry_base): """Retries if any of the retries condition is valid.""" - def __init__(self, *retries): + def __init__(self, *retries: retry_base) -> None: self.retries = retries - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return any(r(retry_state) for r in self.retries) class retry_all(retry_base): """Retries if all the retries condition are valid.""" - def __init__(self, *retries): + def __init__(self, *retries: retry_base) -> None: self.retries = retries - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return all(r(retry_state) for r in self.retries) diff --git a/tenacity/stop.py b/tenacity/stop.py index 94a0f329..2e641307 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -16,49 +16,52 @@ # See the License for the specific language governing permissions and # limitations under the License. import abc +import typing -import six +if typing.TYPE_CHECKING: + import threading + from tenacity import RetryCallState -@six.add_metaclass(abc.ABCMeta) -class stop_base(object): + +class stop_base(metaclass=abc.ABCMeta): """Abstract base class for stop strategies.""" @abc.abstractmethod - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: pass - def __and__(self, other): + def __and__(self, other: "stop_base") -> "stop_all": return stop_all(self, other) - def __or__(self, other): + def __or__(self, other: "stop_base") -> "stop_any": return stop_any(self, other) class stop_any(stop_base): """Stop if any of the stop condition is valid.""" - def __init__(self, *stops): + def __init__(self, *stops: stop_base) -> None: self.stops = stops - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return any(x(retry_state) for x in self.stops) class stop_all(stop_base): """Stop if all the stop conditions are valid.""" - def __init__(self, *stops): + def __init__(self, *stops: stop_base) -> None: self.stops = stops - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return all(x(retry_state) for x in self.stops) class _stop_never(stop_base): """Never stop.""" - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return False @@ -68,28 +71,28 @@ def __call__(self, retry_state): class stop_when_event_set(stop_base): """Stop when the given event is set.""" - def __init__(self, event): + def __init__(self, event: "threading.Event") -> None: self.event = event - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return self.event.is_set() class stop_after_attempt(stop_base): """Stop when the previous attempt >= max_attempt.""" - def __init__(self, max_attempt_number): + def __init__(self, max_attempt_number: int) -> None: self.max_attempt_number = max_attempt_number - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return retry_state.attempt_number >= self.max_attempt_number class stop_after_delay(stop_base): """Stop when the time from the first attempt >= limit.""" - def __init__(self, max_delay): + def __init__(self, max_delay: float) -> None: self.max_delay = max_delay - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return retry_state.seconds_since_start >= self.max_delay diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index 2057fd2d..ccaf3369 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -16,11 +16,10 @@ import asyncio import inspect import unittest +from functools import wraps import pytest -import six - from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio from tenacity import retry, stop_after_attempt @@ -29,7 +28,7 @@ def asynctest(callable_): - @six.wraps(callable_) + @wraps(callable_) def wrapper(*a, **kw): loop = asyncio.get_event_loop() return loop.run_until_complete(callable_(*a, **kw)) diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index eca2618c..1d209331 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -28,8 +28,6 @@ import pytest -import six.moves - import tenacity from tenacity import RetryCallState, RetryError, Retrying, retry @@ -39,7 +37,7 @@ def _make_unset_exception(func_name, **kwargs): missing = [] - for k, v in six.iteritems(kwargs): + for k, v in kwargs.items(): if v is _unset: missing.append(k) missing_str = ", ".join(repr(s) for s in missing) @@ -84,7 +82,7 @@ def make_retry_state( class TestBase(unittest.TestCase): def test_repr(self): class ConcreteRetrying(tenacity.BaseRetrying): - def __call__(self): + def __call__(self, fn, *args, **kwargs): pass repr(ConcreteRetrying()) @@ -195,7 +193,7 @@ def test_incrementing_sleep(self): def test_random_sleep(self): r = Retrying(wait=tenacity.wait_random(min=1, max=20)) times = set() - for x in six.moves.range(1000): + for x in range(1000): times.add(r.wait(make_retry_state(1, 6546))) # this is kind of non-deterministic... @@ -309,7 +307,7 @@ def test_wait_combine(self): ) ) # Test it a few time since it's random - for i in six.moves.range(1000): + for i in range(1000): w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 8) self.assertGreaterEqual(w, 5) @@ -317,7 +315,7 @@ def test_wait_combine(self): def test_wait_double_sum(self): r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) # Test it a few time since it's random - for i in six.moves.range(1000): + for i in range(1000): w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 8) self.assertGreaterEqual(w, 5) @@ -329,7 +327,7 @@ def test_wait_triple_sum(self): + tenacity.wait_fixed(5) ) # Test it a few time since it's random - for i in six.moves.range(1000): + for i in range(1000): w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) @@ -346,7 +344,7 @@ def test_wait_arbitrary_sum(self): ) ) # Test it a few time since it's random - for i in six.moves.range(1000): + for i in range(1000): w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) @@ -366,13 +364,13 @@ def _assert_inclusive_epsilon(self, wait, target, epsilon): def test_wait_chain(self): r = Retrying( wait=tenacity.wait_chain( - *[tenacity.wait_fixed(1) for i in six.moves.range(2)] - + [tenacity.wait_fixed(4) for i in six.moves.range(2)] - + [tenacity.wait_fixed(8) for i in six.moves.range(1)] + *[tenacity.wait_fixed(1) for i in range(2)] + + [tenacity.wait_fixed(4) for i in range(2)] + + [tenacity.wait_fixed(8) for i in range(1)] ) ) - for i in six.moves.range(10): + for i in range(10): w = r.wait(make_retry_state(i + 1, 1)) if i < 2: self._assert_range(w, 1, 2) @@ -385,9 +383,7 @@ def test_wait_chain_multiple_invocations(self): sleep_intervals = [] r = Retrying( sleep=sleep_intervals.append, - wait=tenacity.wait_chain( - *[tenacity.wait_fixed(i + 1) for i in six.moves.range(3)] - ), + wait=tenacity.wait_chain(*[tenacity.wait_fixed(i + 1) for i in range(3)]), stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_if_result(lambda x: x == 1), ) @@ -408,7 +404,7 @@ def always_return_1(): def test_wait_random_exponential(self): fn = tenacity.wait_random_exponential(0.5, 60.0) - for _ in six.moves.range(1000): + for _ in range(1000): self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0, 0.5) self._assert_inclusive_range(fn(make_retry_state(2, 0)), 0, 1.0) self._assert_inclusive_range(fn(make_retry_state(3, 0)), 0, 2.0) @@ -420,7 +416,7 @@ def test_wait_random_exponential(self): self._assert_inclusive_range(fn(make_retry_state(9, 0)), 0, 60.0) fn = tenacity.wait_random_exponential(10, 5) - for _ in six.moves.range(1000): + for _ in range(1000): self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0.00, 5.00) # Default arguments exist @@ -431,8 +427,8 @@ def test_wait_random_exponential_statistically(self): fn = tenacity.wait_random_exponential(0.5, 60.0) attempt = [] - for i in six.moves.range(10): - attempt.append([fn(make_retry_state(i, 0)) for _ in six.moves.range(4000)]) + for i in range(10): + attempt.append([fn(make_retry_state(i, 0)) for _ in range(4000)]) def mean(lst): return float(sum(lst)) / float(len(lst)) @@ -614,7 +610,7 @@ def test_retry_if_exception_message_negative_too_many_inputs(self): tenacity.retry_if_exception_message(message="negative", match="negative") -class NoneReturnUntilAfterCount(object): +class NoneReturnUntilAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -632,7 +628,7 @@ def go(self): return True -class NoIOErrorAfterCount(object): +class NoIOErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -650,7 +646,7 @@ def go(self): return True -class NoNameErrorAfterCount(object): +class NoNameErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -668,7 +664,7 @@ def go(self): return True -class NameErrorUntilCount(object): +class NameErrorUntilCount: """Holds counter state for invoking a method several times in a row.""" derived_message = "Hi there, I'm a NameError" @@ -688,7 +684,7 @@ def go(self): raise NameError(self.derived_message) -class IOErrorUntilCount(object): +class IOErrorUntilCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -724,7 +720,7 @@ def __str__(self): return self.value -class NoCustomErrorAfterCount(object): +class NoCustomErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" derived_message = "This is a Custom exception class" @@ -748,7 +744,7 @@ class CapturingHandler(logging.Handler): """Captures log records for inspection.""" def __init__(self, *args, **kwargs): - super(CapturingHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.records = [] def emit(self, record): @@ -1032,13 +1028,13 @@ def test_defaults(self): self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5))) def test_retry_function_object(self): - """Test that six.wraps doesn't cause problems with callable objects. + """Test that funсtools.wraps doesn't cause problems with callable objects. It raises an error upon trying to wrap it in Py2, because __name__ attribute is missing. It's fixed in Py3 but was never backported. """ - class Hello(object): + class Hello: def __call__(self): return "Hello" @@ -1574,7 +1570,7 @@ def _decorated_fail(self): @pytest.fixture() def mock_sleep(self, monkeypatch): - class MockSleep(object): + class MockSleep: call_count = 0 def __call__(self, seconds): diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 243cf5de..552ddad0 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -25,7 +25,7 @@ class TornadoRetrying(BaseRetrying): def __init__(self, sleep=gen.sleep, **kwargs): - super(TornadoRetrying, self).__init__(**kwargs) + super().__init__(**kwargs) self.sleep = sleep @gen.coroutine diff --git a/tenacity/wait.py b/tenacity/wait.py index 2f981c89..07c993a3 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -18,24 +18,25 @@ import abc import random - -import six +import typing from tenacity import _utils +if typing.TYPE_CHECKING: + from tenacity import RetryCallState + -@six.add_metaclass(abc.ABCMeta) -class wait_base(object): +class wait_base(metaclass=abc.ABCMeta): """Abstract base class for wait strategies.""" @abc.abstractmethod - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: pass - def __add__(self, other): + def __add__(self, other: "wait_base") -> "wait_combine": return wait_combine(self, other) - def __radd__(self, other): + def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_base"]: # make it possible to use multiple waits with the built-in sum function if other == 0: return self @@ -45,28 +46,28 @@ def __radd__(self, other): class wait_fixed(wait_base): """Wait strategy that waits a fixed amount of time between each retry.""" - def __init__(self, wait): + def __init__(self, wait: float) -> None: self.wait_fixed = wait - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_fixed class wait_none(wait_fixed): """Wait strategy that doesn't wait at all before retrying.""" - def __init__(self): - super(wait_none, self).__init__(0) + def __init__(self) -> None: + super().__init__(0) class wait_random(wait_base): """Wait strategy that waits a random amount of time between min/max.""" - def __init__(self, min=0, max=1): # noqa + def __init__(self, min: typing.Union[int, float] = 0, max: typing.Union[int, float] = 1) -> None: # noqa self.wait_random_min = min self.wait_random_max = max - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_random_min + ( random.random() * (self.wait_random_max - self.wait_random_min) ) @@ -75,10 +76,10 @@ def __call__(self, retry_state): class wait_combine(wait_base): """Combine several waiting strategies.""" - def __init__(self, *strategies): + def __init__(self, *strategies: wait_base) -> None: self.wait_funcs = strategies - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: return sum(x(retry_state=retry_state) for x in self.wait_funcs) @@ -98,10 +99,10 @@ def wait_chained(): thereafter.") """ - def __init__(self, *strategies): + def __init__(self, *strategies: wait_base) -> None: self.strategies = strategies - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: wait_func_no = min(max(retry_state.attempt_number, 1), len(self.strategies)) wait_func = self.strategies[wait_func_no - 1] return wait_func(retry_state=retry_state) @@ -119,7 +120,7 @@ def __init__(self, start=0, increment=100, max=_utils.MAX_WAIT): # noqa self.increment = increment self.max = max - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: result = self.start + (self.increment * (retry_state.attempt_number - 1)) return max(0, min(result, self.max)) @@ -143,7 +144,7 @@ def __init__(self, multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0): # noq self.max = max self.exp_base = exp_base - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> float: try: exp = self.exp_base ** (retry_state.attempt_number - 1) result = self.multiplier * exp @@ -178,6 +179,6 @@ class wait_random_exponential(wait_exponential): """ - def __call__(self, retry_state): - high = super(wait_random_exponential, self).__call__(retry_state=retry_state) + def __call__(self, retry_state: "RetryCallState") -> float: + high = super().__call__(retry_state=retry_state) return random.uniform(0, high) diff --git a/tox.ini b/tox.ini index 6ded22f5..f7147e2b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ [tox] -envlist = py36, py37, py38, py39, pep8, pypy3 +envlist = py3{6,7,8,9,10}, pep8, pypy3 +skip_missing_interpreters = True [testenv] usedevelop = True @@ -9,9 +10,9 @@ deps = pytest typeguard commands = - py{36,37,38,39,py3}: pytest {posargs} - py{36,37,38,39,py3}: sphinx-build -a -E -W -b doctest doc/source doc/build - py{36,37,38,39,py3}: sphinx-build -a -E -W -b html doc/source doc/build + py3{6,7,8,9,10},pypy3: pytest {posargs} + py3{6,7,8,9,10},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{6,7,8,9,10},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 5304dc6be93a3c561a80e888fe30534a2c53f6fe Mon Sep 17 00:00:00 2001 From: Aleksei Stepanov Date: Wed, 23 Jun 2021 16:31:56 +0200 Subject: [PATCH 022/124] Use black instead of "flake8-black" on CI. - Use `black` for code formatting and validate using `black --check`. Code compatibility: py26-py39. - Enforce maximal line length to 120 symbols --- .circleci/config.yml | 10 ++ .editorconfig | 22 +++ .mergify.yml | 4 + pyproject.toml | 16 ++ ...validate-using-black-39ec9d57d4691778.yaml | 4 + setup.cfg | 4 +- tenacity/__init__.py | 39 ++--- tenacity/before.py | 2 +- tenacity/retry.py | 40 ++--- tenacity/tests/test_asyncio.py | 8 +- tenacity/tests/test_tenacity.py | 152 ++++-------------- tenacity/wait.py | 4 +- tox.ini | 15 +- 13 files changed, 139 insertions(+), 181 deletions(-) create mode 100644 .editorconfig create mode 100644 pyproject.toml create mode 100644 releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index c9baa7b3..418db0c3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,15 @@ jobs: command: | sudo pip install tox tox -e pep8 + black: + docker: + - image: circleci/python:3.9 + steps: + - checkout + - run: + command: | + sudo pip install tox + tox -e black-ci py36: docker: - image: circleci/python:3.6 @@ -76,6 +85,7 @@ workflows: test: jobs: - pep8 + - black - py36 - py37 - py38 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ce78f355 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,pyx,pxd,pyi}] +indent_size = 4 +max_line_length = 120 + +[*.ini] +indent_size = 4 + +[*.rst] +max_line_length = 150 + +[Makefile] +indent_style = tab diff --git a/.mergify.yml b/.mergify.yml index 9ebabcb4..697c6207 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -13,6 +13,7 @@ pull_request_rules: - name: automatic merge without changelog conditions: - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: black" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -26,6 +27,7 @@ pull_request_rules: - name: automatic merge with changelog conditions: - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: black" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -40,6 +42,7 @@ pull_request_rules: conditions: - author=jd - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: black" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" @@ -53,6 +56,7 @@ pull_request_rules: conditions: - author=jd - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: black" - "status-success=ci/circleci: py36" - "status-success=ci/circleci: py37" - "status-success=ci/circleci: py38" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..89668361 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +# Minimum requirements for the build system to execute. +# PEP 508 specifications for PEP 518. +# Banned setuptools versions have well-known issues +requires = [ + "setuptools >= 21.0.0,!=24.0.0,!=34.0.0,!=34.0.1,!=34.0.2,!=34.0.3,!=34.1.0,!=34.1.1,!=34.2.0,!=34.3.0,!=34.3.1,!=34.3.2,!=36.2.0", # PSF/ZPL + "setuptools_scm[toml]>=3.4", +] +build-backend="setuptools.build_meta" + +[tool.black] +line-length = 120 +safe = true +target-version = ["py36", "py37", "py38", "py39"] + +[tool.setuptools_scm] diff --git a/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml b/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml new file mode 100644 index 00000000..188dc8da --- /dev/null +++ b/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml @@ -0,0 +1,4 @@ +--- +other: + - "Use `black` for code formatting and validate using `black --check`. Code compatibility: py26-py39." + - "Enforce maximal line length to 120 symbols" diff --git a/setup.cfg b/setup.cfg index 32e9913a..8491b6a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,8 +5,8 @@ url = https://github.com/jd/tenacity summary = Retry code until it succeeds long_description = Tenacity is a general-purpose retrying library to simplify the task of adding retry behavior to just about anything. author = Julien Danjou -author-email = julien@danjou.info -home-page = https://github.com/jd/tenacity +author_email = julien@danjou.info +home_page = https://github.com/jd/tenacity classifier = Intended Audience :: Developers License :: OSI Approved :: Apache Software License diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 540ff4d0..7e27f8eb 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -126,11 +126,7 @@ def wrap(f: WrappedFn) -> WrappedFn: ) if iscoroutinefunction is not None and iscoroutinefunction(f): r: "BaseRetrying" = AsyncRetrying(*dargs, **dkw) - elif ( - tornado - and hasattr(tornado.gen, "is_coroutine_function") - and tornado.gen.is_coroutine_function(f) - ): + elif tornado and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f): r = TornadoRetrying(*dargs, **dkw) else: r = Retrying(*dargs, **dkw) @@ -168,9 +164,7 @@ class BaseAction: NAME: t.Optional[str] = None def __repr__(self) -> str: - state_str = ", ".join( - "%s=%r" % (field, getattr(self, field)) for field in self.REPR_FIELDS - ) + state_str = ", ".join("%s=%r" % (field, getattr(self, field)) for field in self.REPR_FIELDS) return "%s(%s)" % (type(self).__name__, state_str) def __str__(self) -> str: @@ -218,10 +212,10 @@ def __enter__(self) -> None: pass def __exit__( - self, # noqa:BLK100 - exc_type: t.Optional[t.Type[BaseException]], - exc_value: t.Optional[BaseException], - traceback: t.Optional["types.TracebackType"], + self, + exc_type: t.Optional[t.Type[BaseException]], + exc_value: t.Optional[BaseException], + traceback: t.Optional["types.TracebackType"], ) -> t.Optional[bool]: if isinstance(exc_value, BaseException): self.retry_state.set_exception((exc_type, exc_value, traceback)) @@ -233,7 +227,6 @@ def __exit__( class BaseRetrying(metaclass=ABCMeta): - def __init__( self, sleep: t.Callable[[t.Union[int, float]], None] = sleep, @@ -287,9 +280,7 @@ def copy( before_sleep=_first_set(before_sleep, self.before_sleep), reraise=_first_set(reraise, self.reraise), retry_error_cls=_first_set(retry_error_cls, self.retry_error_cls), - retry_error_callback=_first_set( - retry_error_callback, self.retry_error_callback - ), + retry_error_callback=_first_set(retry_error_callback, self.retry_error_callback), ) def __repr__(self) -> str: @@ -363,9 +354,7 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A self.before(retry_state) return DoAttempt() - is_explicit_retry = retry_state.outcome.failed and isinstance( - retry_state.outcome.exception(), TryAgain - ) + is_explicit_retry = retry_state.outcome.failed and isinstance(retry_state.outcome.exception(), TryAgain) if not (is_explicit_retry or self.retry(retry_state=retry_state)): return fut.result() @@ -472,11 +461,11 @@ class RetryCallState: """State related to a single call wrapped with Retrying.""" def __init__( - self, - retry_object: BaseRetrying, - fn: t.Optional[WrappedFn], - args: t.Any, - kwargs: t.Any, + self, + retry_object: BaseRetrying, + fn: t.Optional[WrappedFn], + args: t.Any, + kwargs: t.Any, ) -> None: #: Retry call start timestamp self.start_time = time.monotonic() @@ -496,7 +485,7 @@ def __init__( #: Timestamp of the last outcome self.outcome_timestamp: t.Optional[float] = None #: Time spent sleeping in retries - self.idle_for: float = 0. + self.idle_for: float = 0.0 #: Next action as decided by the retry manager self.next_action: t.Optional[RetryAction] = None diff --git a/tenacity/before.py b/tenacity/before.py index 2e72f3ac..fd3ea733 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -28,7 +28,7 @@ def before_nothing(retry_state: "RetryCallState") -> None: """Before call strategy that does nothing.""" -def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["RetryCallState"], None]: # noqa:BLK100 +def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["RetryCallState"], None]: """Before call strategy that logs to some logger the attempt.""" def log_it(retry_state: "RetryCallState") -> None: diff --git a/tenacity/retry.py b/tenacity/retry.py index a03b6009..8e5bfe55 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -79,7 +79,7 @@ def __init__( exception_types: typing.Union[ typing.Type[BaseException], typing.Tuple[typing.Type[BaseException], ...], - ] = Exception, # noqa:BLK100 + ] = Exception, ) -> None: self.exception_types = exception_types super().__init__(lambda e: isinstance(e, exception_types)) @@ -89,11 +89,11 @@ class retry_if_not_exception_type(retry_if_exception): """Retries except an exception has been raised of one or more types.""" def __init__( - self, # noqa:BLK100 - exception_types: typing.Union[ - typing.Type[BaseException], - typing.Tuple[typing.Type[BaseException], ...], - ] = Exception, + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, ) -> None: self.exception_types = exception_types super().__init__(lambda e: not isinstance(e, exception_types)) @@ -103,11 +103,11 @@ class retry_unless_exception_type(retry_if_exception): """Retries until an exception is raised of one or more types.""" def __init__( - self, - exception_types: typing.Union[ - typing.Type[BaseException], - typing.Tuple[typing.Type[BaseException], ...], - ] = Exception + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, ) -> None: self.exception_types = exception_types super().__init__(lambda e: not isinstance(e, exception_types)) @@ -154,11 +154,7 @@ def __init__( match: typing.Optional[str] = None, ) -> None: if message and match: - raise TypeError( - "{}() takes either 'message' or 'match', not both".format( - self.__class__.__name__ - ) - ) + raise TypeError("{}() takes either 'message' or 'match', not both".format(self.__class__.__name__)) # set predicate if message: @@ -175,11 +171,7 @@ def match_fnc(exception: BaseException) -> bool: predicate = match_fnc else: - raise TypeError( - "{}() missing 1 required argument 'message' or 'match'".format( - self.__class__.__name__ - ) - ) + raise TypeError("{}() missing 1 required argument 'message' or 'match'".format(self.__class__.__name__)) super().__init__(predicate) @@ -188,9 +180,9 @@ class retry_if_not_exception_message(retry_if_exception_message): """Retries until an exception message equals or matches.""" def __init__( - self, - message: typing.Optional[str] = None, - match: typing.Optional[str] = None, + self, + message: typing.Optional[str] = None, + match: typing.Optional[str] = None, ) -> None: super().__init__(message, match) # invert predicate diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py index ccaf3369..04ca0c9d 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tenacity/tests/test_asyncio.py @@ -145,9 +145,7 @@ class CustomError(Exception): pass try: - async for attempt in tasyncio.AsyncRetrying( - stop=stop_after_attempt(1), reraise=True - ): + async for attempt in tasyncio.AsyncRetrying(stop=stop_after_attempt(1), reraise=True): with attempt: raise CustomError() except CustomError: @@ -159,9 +157,7 @@ class CustomError(Exception): async def test_sleeps(self): start = current_time_ms() try: - async for attempt in tasyncio.AsyncRetrying( - stop=stop_after_attempt(1), wait=wait_fixed(1) - ): + async for attempt in tasyncio.AsyncRetrying(stop=stop_after_attempt(1), wait=wait_fixed(1)): with attempt: raise Exception() except RetryError: diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index 1d209331..6af071f3 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -52,16 +52,12 @@ def _set_delay_since_start(retry_state, delay): assert retry_state.seconds_since_start == delay -def make_retry_state( - previous_attempt_number, delay_since_first_attempt, last_result=None -): +def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_result=None): """Construct RetryCallState for given attempt number & delay. Only used in testing and thus is extra careful about timestamp arithmetics. """ - required_parameter_unset = ( - previous_attempt_number is _unset or delay_since_first_attempt is _unset - ) + required_parameter_unset = previous_attempt_number is _unset or delay_since_first_attempt is _unset if required_parameter_unset: raise _make_unset_exception( "wait/stop", @@ -94,9 +90,7 @@ def test_never_stop(self): self.assertFalse(r.stop(make_retry_state(3, 6546))) def test_stop_any(self): - stop = tenacity.stop_any( - tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) - ) + stop = tenacity.stop_any(tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)) def s(*args): return stop(make_retry_state(*args)) @@ -109,9 +103,7 @@ def s(*args): self.assertTrue(s(4, 1.8)) def test_stop_all(self): - stop = tenacity.stop_all( - tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) - ) + stop = tenacity.stop_all(tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)) def s(*args): return stop(make_retry_state(*args)) @@ -301,11 +293,7 @@ def wait_func(retry_state): self.assertEqual(r.wait(make_retry_state(10, 100)), 1000) def test_wait_combine(self): - r = Retrying( - wait=tenacity.wait_combine( - tenacity.wait_random(0, 3), tenacity.wait_fixed(5) - ) - ) + r = Retrying(wait=tenacity.wait_combine(tenacity.wait_random(0, 3), tenacity.wait_fixed(5))) # Test it a few time since it's random for i in range(1000): w = r.wait(make_retry_state(1, 5)) @@ -321,11 +309,7 @@ def test_wait_double_sum(self): self.assertGreaterEqual(w, 5) def test_wait_triple_sum(self): - r = Retrying( - wait=tenacity.wait_fixed(1) - + tenacity.wait_random(0, 3) - + tenacity.wait_fixed(5) - ) + r = Retrying(wait=tenacity.wait_fixed(1) + tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) # Test it a few time since it's random for i in range(1000): w = r.wait(make_retry_state(1, 5)) @@ -455,10 +439,7 @@ def waitfunc(retry_state): retrying = Retrying( wait=waitfunc, - retry=( - tenacity.retry_if_exception_type() - | tenacity.retry_if_result(lambda result: result == 123) - ), + retry=(tenacity.retry_if_exception_type() | tenacity.retry_if_result(lambda result: result == 123)), ) def returnval(): @@ -542,9 +523,7 @@ def r(fut): self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_and(self): - retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result( - lambda x: isinstance(x, int) - ) + retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result(lambda x: isinstance(x, int)) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) @@ -556,9 +535,7 @@ def r(fut): self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_or(self): - retry = tenacity.retry_if_result( - lambda x: x == "foo" - ) | tenacity.retry_if_result(lambda x: isinstance(x, int)) + retry = tenacity.retry_if_result(lambda x: x == "foo") | tenacity.retry_if_result(lambda x: isinstance(x, int)) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) @@ -576,9 +553,7 @@ def _raise_try_again(self): def test_retry_try_again(self): self._attempts = 0 - Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)( - self._raise_try_again - ) + Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)(self._raise_try_again) self.assertEqual(3, self._attempts) def test_retry_try_again_forever(self): @@ -781,9 +756,7 @@ def _retryable_test_if_not_exception_type_io(thing): return thing.go() -@retry( - stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError) -) +@retry(stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError)) def _retryable_test_with_exception_type_io_attempt_limit(thing): return thing.go() @@ -808,46 +781,28 @@ def _retryable_test_with_unless_exception_type_no_input(thing): @retry( stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_if_exception_message( - message=NoCustomErrorAfterCount.derived_message - ), + retry=tenacity.retry_if_exception_message(message=NoCustomErrorAfterCount.derived_message), ) def _retryable_test_if_exception_message_message(thing): return thing.go() -@retry( - retry=tenacity.retry_if_not_exception_message( - message=NoCustomErrorAfterCount.derived_message - ) -) +@retry(retry=tenacity.retry_if_not_exception_message(message=NoCustomErrorAfterCount.derived_message)) def _retryable_test_if_not_exception_message_message(thing): return thing.go() -@retry( - retry=tenacity.retry_if_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*" - ) -) +@retry(retry=tenacity.retry_if_exception_message(match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) def _retryable_test_if_exception_message_match(thing): return thing.go() -@retry( - retry=tenacity.retry_if_not_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*" - ) -) +@retry(retry=tenacity.retry_if_not_exception_message(match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) def _retryable_test_if_not_exception_message_match(thing): return thing.go() -@retry( - retry=tenacity.retry_if_not_exception_message( - message=NameErrorUntilCount.derived_message - ) -) +@retry(retry=tenacity.retry_if_not_exception_message(message=NameErrorUntilCount.derived_message)) def _retryable_test_not_exception_message_delay(thing): return thing.go() @@ -911,9 +866,7 @@ def test_retry_if_exception_of_type(self): self.assertTrue(isinstance(n, NameError)) print(n) - self.assertTrue( - _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5)) - ) + self.assertTrue(_retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5))) try: _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5)) @@ -923,9 +876,7 @@ def test_retry_if_exception_of_type(self): print(n) def test_retry_except_exception_of_type(self): - self.assertTrue( - _retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5)) - ) + self.assertTrue(_retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5))) try: _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5)) @@ -936,9 +887,7 @@ def test_retry_except_exception_of_type(self): def test_retry_until_exception_of_type_attempt_number(self): try: - self.assertTrue( - _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) - ) + self.assertTrue(_retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))) except NameError as e: s = _retryable_test_with_unless_exception_type_name.retry.statistics self.assertTrue(s["attempt_number"] == 6) @@ -949,11 +898,7 @@ def test_retry_until_exception_of_type_attempt_number(self): def test_retry_until_exception_of_type_no_type(self): try: # no input should catch all subclasses of Exception - self.assertTrue( - _retryable_test_with_unless_exception_type_no_input( - NameErrorUntilCount(5) - ) - ) + self.assertTrue(_retryable_test_with_unless_exception_type_no_input(NameErrorUntilCount(5))) except NameError as e: s = _retryable_test_with_unless_exception_type_no_input.retry.statistics self.assertTrue(s["attempt_number"] == 6) @@ -964,9 +909,7 @@ def test_retry_until_exception_of_type_no_type(self): def test_retry_until_exception_of_type_wrong_exception(self): try: # two iterations with IOError, one that returns True - _retryable_test_with_unless_exception_type_name_attempt_limit( - IOErrorUntilCount(2) - ) + _retryable_test_with_unless_exception_type_name_attempt_limit(IOErrorUntilCount(2)) self.fail("Expected RetryError") except RetryError as e: self.assertTrue(isinstance(e, RetryError)) @@ -974,29 +917,21 @@ def test_retry_until_exception_of_type_wrong_exception(self): def test_retry_if_exception_message(self): try: - self.assertTrue( - _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3)) - ) + self.assertTrue(_retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))) except CustomError: print(_retryable_test_if_exception_message_message.retry.statistics) self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message(self): try: - self.assertTrue( - _retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2) - ) - ) + self.assertTrue(_retryable_test_if_not_exception_message_message(NoCustomErrorAfterCount(2))) except CustomError: s = _retryable_test_if_not_exception_message_message.retry.statistics self.assertTrue(s["attempt_number"] == 1) def test_retry_if_not_exception_message_delay(self): try: - self.assertTrue( - _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) - ) + self.assertTrue(_retryable_test_not_exception_message_delay(NameErrorUntilCount(3))) except NameError: s = _retryable_test_not_exception_message_delay.retry.statistics print(s["attempt_number"]) @@ -1004,19 +939,13 @@ def test_retry_if_not_exception_message_delay(self): def test_retry_if_exception_message_match(self): try: - self.assertTrue( - _retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3)) - ) + self.assertTrue(_retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3))) except CustomError: self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message_match(self): try: - self.assertTrue( - _retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2) - ) - ) + self.assertTrue(_retryable_test_if_not_exception_message_message(NoCustomErrorAfterCount(2))) except CustomError: s = _retryable_test_if_not_exception_message_message.retry.statistics self.assertTrue(s["attempt_number"] == 1) @@ -1038,9 +967,7 @@ class Hello: def __call__(self): return "Hello" - retrying = Retrying( - wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) - ) + retrying = Retrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") @@ -1048,17 +975,13 @@ def __call__(self): class TestRetryWith: def test_redefine_wait(self): start = current_time_ms() - result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( - NoneReturnUntilAfterCount(5) - ) + result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))(NoneReturnUntilAfterCount(5)) t = current_time_ms() - start assert t >= 500 assert result is True def test_redefine_stop(self): - result = _retryable_test_with_stop.retry_with( - stop=tenacity.stop_after_attempt(5) - )(NoneReturnUntilAfterCount(4)) + result = _retryable_test_with_stop.retry_with(stop=tenacity.stop_after_attempt(5))(NoneReturnUntilAfterCount(4)) assert result is True def test_retry_error_cls_should_be_preserved(self): @@ -1163,10 +1086,7 @@ def _before_sleep_log_raises(self, get_call_fn): finally: logger.removeHandler(handler) - etalon_re = ( - r"^Retrying .* in 0\.01 seconds as it raised " - r"(IO|OS)Error: Hi there, I'm an IOError\.$" - ) + etalon_re = r"^Retrying .* in 0\.01 seconds as it raised " r"(IO|OS)Error: Hi there, I'm an IOError\.$" self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) @@ -1186,9 +1106,7 @@ def test_before_sleep_log_raises_with_exc_info(self): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log( - logger, logging.INFO, exc_info=True - ) + _before_sleep = tenacity.before_sleep_log(logger, logging.INFO, exc_info=True) retrying = Retrying( wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3), @@ -1218,9 +1136,7 @@ def test_before_sleep_log_returns(self, exc_info=False): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log( - logger, logging.INFO, exc_info=exc_info - ) + _before_sleep = tenacity.before_sleep_log(logger, logging.INFO, exc_info=exc_info) _retry = tenacity.retry_if_result(lambda result: result is None) retrying = Retrying( wait=tenacity.wait_fixed(0.01), @@ -1512,9 +1428,7 @@ def test_retry_error_is_pickleable(self): class TestRetryTyping(unittest.TestCase): - @pytest.mark.skipif( - sys.version_info < (3, 0), reason="typeguard not supported for python 2" - ) + @pytest.mark.skipif(sys.version_info < (3, 0), reason="typeguard not supported for python 2") def test_retry_type_annotations(self): """The decorator should maintain types of decorated functions.""" # Just in case this is run with unit-test, return early for py2 diff --git a/tenacity/wait.py b/tenacity/wait.py index 07c993a3..48733dc3 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -68,9 +68,7 @@ def __init__(self, min: typing.Union[int, float] = 0, max: typing.Union[int, flo self.wait_random_max = max def __call__(self, retry_state: "RetryCallState") -> float: - return self.wait_random_min + ( - random.random() * (self.wait_random_max - self.wait_random_min) - ) + return self.wait_random_min + (random.random() * (self.wait_random_max - self.wait_random_min)) class wait_combine(wait_base): diff --git a/tox.ini b/tox.ini index f7147e2b..65a622c4 100644 --- a/tox.ini +++ b/tox.ini @@ -23,9 +23,21 @@ deps = flake8 flake8-docstrings flake8-rst-docstrings flake8-logging-format - flake8-black commands = flake8 +[testenv:black] +deps = + black +commands = + black . + +[testenv:black-ci] +deps = + black + {[testenv:black]deps} +commands = + black --check --diff . + [testenv:reno] basepython = python3 deps = reno @@ -36,3 +48,4 @@ exclude = .tox,.eggs show-source = true ignore = D100,D101,D102,D103,D104,D105,D107,G200,G201,W503,W504,E501 enable-extensions=G +max-line-length = 120 From 9d93bdef40d289ea45f4b693307c2c0385f1e90f Mon Sep 17 00:00:00 2001 From: Andrey Semakin Date: Thu, 24 Jun 2021 14:57:37 +0500 Subject: [PATCH 023/124] Remove encoding declarations (#309) --- doc/source/conf.py | 1 - tenacity/__init__.py | 1 - tenacity/_asyncio.py | 1 - tenacity/nap.py | 1 - tenacity/retry.py | 2 -- tenacity/stop.py | 2 -- tenacity/tests/test_tenacity.py | 2 -- tenacity/tornadoweb.py | 1 - tenacity/wait.py | 2 -- 9 files changed, 13 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 8bb88192..cb1ff711 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016 Étienne Bersac # Copyright 2016 Julien Danjou # Copyright 2016 Joshua Harlow diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 7e27f8eb..ebc4b142 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016-2018 Julien Danjou # Copyright 2017 Elisey Zanko # Copyright 2016 Étienne Bersac diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index ba8e208f..7387d6bf 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016 Étienne Bersac # Copyright 2016 Julien Danjou # Copyright 2016 Joshua Harlow diff --git a/tenacity/nap.py b/tenacity/nap.py index 80f34b0c..72aa5bfd 100644 --- a/tenacity/nap.py +++ b/tenacity/nap.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016 Étienne Bersac # Copyright 2016 Julien Danjou # Copyright 2016 Joshua Harlow diff --git a/tenacity/retry.py b/tenacity/retry.py index 8e5bfe55..d62bd663 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- -# # Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder diff --git a/tenacity/stop.py b/tenacity/stop.py index 2e641307..24dcd1f4 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- -# # Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py index 6af071f3..6b13de6c 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tenacity/tests/test_tenacity.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- -# # Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013 Ray Holder diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 552ddad0..8059045c 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2017 Elisey Zanko # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tenacity/wait.py b/tenacity/wait.py index 48733dc3..5ff9b739 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- -# # Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder From b4d8d6bdc3d574ba558f016432c1ae676460ffd5 Mon Sep 17 00:00:00 2001 From: Andrey Semakin Date: Thu, 24 Jun 2021 15:00:12 +0500 Subject: [PATCH 024/124] Replace abc.ABCMeta with abc.ABC (#310) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tenacity/__init__.py | 4 ++-- tenacity/retry.py | 2 +- tenacity/stop.py | 2 +- tenacity/wait.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index ebc4b142..c9ea8933 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -22,7 +22,7 @@ import time import typing as t import warnings -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod from concurrent import futures from inspect import iscoroutinefunction @@ -225,7 +225,7 @@ def __exit__( return None -class BaseRetrying(metaclass=ABCMeta): +class BaseRetrying(ABC): def __init__( self, sleep: t.Callable[[t.Union[int, float]], None] = sleep, diff --git a/tenacity/retry.py b/tenacity/retry.py index d62bd663..04db430c 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -22,7 +22,7 @@ from tenacity import RetryCallState -class retry_base(metaclass=abc.ABCMeta): +class retry_base(abc.ABC): """Abstract base class for retry strategies.""" @abc.abstractmethod diff --git a/tenacity/stop.py b/tenacity/stop.py index 24dcd1f4..35072244 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -22,7 +22,7 @@ from tenacity import RetryCallState -class stop_base(metaclass=abc.ABCMeta): +class stop_base(abc.ABC): """Abstract base class for stop strategies.""" @abc.abstractmethod diff --git a/tenacity/wait.py b/tenacity/wait.py index 5ff9b739..2c1f4fb6 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -24,7 +24,7 @@ from tenacity import RetryCallState -class wait_base(metaclass=abc.ABCMeta): +class wait_base(abc.ABC): """Abstract base class for wait strategies.""" @abc.abstractmethod From dc60786ac1d15c54bd2279293c199544f50a3867 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Thu, 24 Jun 2021 12:04:32 +0200 Subject: [PATCH 025/124] Use f-strings instead of `str.format` as faster and easier to read (#311) Since python 3.6 we are able to use it. Use attributes in `repr` directly instead of chained construction -> remove unused private helper no-changelog Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tenacity/__init__.py | 31 +++++++++++++------------------ tenacity/_utils.py | 16 +--------------- tenacity/after.py | 7 ++----- tenacity/before.py | 6 ++---- tenacity/before_sleep.py | 10 +++------- tenacity/retry.py | 4 ++-- 6 files changed, 23 insertions(+), 51 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index c9ea8933..9b8b3c52 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -26,8 +26,6 @@ from concurrent import futures from inspect import iscoroutinefunction -from tenacity import _utils - # Import all built-in retry strategies for easier usage. from .retry import retry_base # noqa from .retry import retry_all # noqa @@ -117,11 +115,8 @@ def retry(*dargs: t.Any, **dkw: t.Any) -> t.Union[WrappedFn, t.Callable[[Wrapped def wrap(f: WrappedFn) -> WrappedFn: if isinstance(f, retry_base): warnings.warn( - ( - "Got retry_base instance ({cls}) as callable argument, " - + "this will probably hang indefinitely (did you mean " - + "retry={cls}(...)?)" - ).format(cls=f.__class__.__name__) + f"Got retry_base instance ({f.__class__.__name__}) as callable argument, " + f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" ) if iscoroutinefunction is not None and iscoroutinefunction(f): r: "BaseRetrying" = AsyncRetrying(*dargs, **dkw) @@ -163,8 +158,8 @@ class BaseAction: NAME: t.Optional[str] = None def __repr__(self) -> str: - state_str = ", ".join("%s=%r" % (field, getattr(self, field)) for field in self.REPR_FIELDS) - return "%s(%s)" % (type(self).__name__, state_str) + state_str = ", ".join(f"{field}={getattr(self, field)!r}" for field in self.REPR_FIELDS) + return f"{self.__class__.__name__}({state_str})" def __str__(self) -> str: return repr(self) @@ -198,7 +193,7 @@ def reraise(self) -> t.NoReturn: raise self def __str__(self) -> str: - return "{0}[{1}]".format(self.__class__.__name__, self.last_attempt) + return f"{self.__class__.__name__}[{self.last_attempt}]" class AttemptManager: @@ -283,15 +278,15 @@ def copy( ) def __repr__(self) -> str: - attrs = dict( - _utils.visible_attrs(self, attrs={"me": id(self)}), - __class__=self.__class__.__name__, - ) return ( - "<%(__class__)s object at 0x%(me)x (stop=%(stop)s, " - "wait=%(wait)s, sleep=%(sleep)s, retry=%(retry)s, " - "before=%(before)s, after=%(after)s)>" - ) % (attrs) + f"<{self.__class__.__name__} object at 0x{id(self):x} (" + f"stop={self.stop}, " + f"wait={self.wait}, " + f"sleep={self.sleep}, " + f"retry={self.retry}, " + f"before={self.before}, " + f"after={self.after})>" + ) @property def statistics(self) -> t.Dict[str, t.Any]: diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 9e2b7f15..d5c4c9de 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -14,7 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import inspect import sys import typing @@ -24,19 +23,6 @@ MAX_WAIT = sys.maxsize / 2 -def visible_attrs( - obj: typing.Any, - attrs: typing.Optional[typing.Dict[str, typing.Any]] = None, -) -> typing.Dict[str, typing.Any]: - if attrs is None: - attrs = {} - for attr_name, attr in inspect.getmembers(obj): - if attr_name.startswith("_"): - continue - attrs[attr_name] = attr - return attrs - - def find_ordinal(pos_num: int) -> str: # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers if pos_num == 0: @@ -54,7 +40,7 @@ def find_ordinal(pos_num: int) -> str: def to_ordinal(pos_num: int) -> str: - return "%i%s" % (pos_num, find_ordinal(pos_num)) + return f"{pos_num}{find_ordinal(pos_num)}" def get_callback_name(cb: typing.Callable[..., typing.Any]) -> str: diff --git a/tenacity/after.py b/tenacity/after.py index 97c94071..9dc55726 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -39,11 +39,8 @@ def log_it(retry_state: "RetryCallState") -> None: sec = sec_format % _utils.get_callback_name(retry_state.fn) logger.log( log_level, - "Finished call to '{0}' after {1}(s), this was the {2} time calling it.".format( - sec, - retry_state.seconds_since_start, - _utils.to_ordinal(retry_state.attempt_number), - ), + f"Finished call to '{sec}' after {retry_state.seconds_since_start}(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) return log_it diff --git a/tenacity/before.py b/tenacity/before.py index fd3ea733..6a95406c 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -34,10 +34,8 @@ def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["Re def log_it(retry_state: "RetryCallState") -> None: logger.log( log_level, - "Starting call to '{0}', this is the {1} time calling it.".format( - _utils.get_callback_name(retry_state.fn), - _utils.to_ordinal(retry_state.attempt_number), - ), + f"Starting call to '{_utils.get_callback_name(retry_state.fn)}', " + f"this is the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) return log_it diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index e9a332b0..44b9f70e 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -38,7 +38,7 @@ def before_sleep_log( def log_it(retry_state: "RetryCallState") -> None: if retry_state.outcome.failed: ex = retry_state.outcome.exception() - verb, value = "raised", "{0}: {1}".format(type(ex).__name__, ex) + verb, value = "raised", f"{ex.__class__.__name__}: {ex}" if exc_info: local_exc_info = retry_state.outcome.exception() @@ -50,12 +50,8 @@ def log_it(retry_state: "RetryCallState") -> None: logger.log( log_level, - "Retrying {0} in {1} seconds as it {2} {3}.".format( - _utils.get_callback_name(retry_state.fn), - getattr(retry_state.next_action, "sleep"), - verb, - value, - ), + f"Retrying {_utils.get_callback_name(retry_state.fn)} " + f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", exc_info=local_exc_info, ) diff --git a/tenacity/retry.py b/tenacity/retry.py index 04db430c..dd271175 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -152,7 +152,7 @@ def __init__( match: typing.Optional[str] = None, ) -> None: if message and match: - raise TypeError("{}() takes either 'message' or 'match', not both".format(self.__class__.__name__)) + raise TypeError(f"{self.__class__.__name__}() takes either 'message' or 'match', not both") # set predicate if message: @@ -169,7 +169,7 @@ def match_fnc(exception: BaseException) -> bool: predicate = match_fnc else: - raise TypeError("{}() missing 1 required argument 'message' or 'match'".format(self.__class__.__name__)) + raise TypeError(f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'") super().__init__(predicate) From 52ce97c61bf7f661a7e9d7f32896151238f202b3 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Thu, 24 Jun 2021 14:05:10 +0200 Subject: [PATCH 026/124] Fix issue #288 : __name__ and other attributes for async functions (#312) `iscoroutinefunction` is always not Nons -> remove check --- releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml | 3 +++ tenacity/__init__.py | 2 +- tenacity/_asyncio.py | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml diff --git a/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml b/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml new file mode 100644 index 00000000..1ce0b71b --- /dev/null +++ b/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml @@ -0,0 +1,3 @@ +--- +fixes: + - "Fix issue #288 : __name__ and other attributes for async functions" diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 9b8b3c52..c7cbb8e3 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -118,7 +118,7 @@ def wrap(f: WrappedFn) -> WrappedFn: f"Got retry_base instance ({f.__class__.__name__}) as callable argument, " f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" ) - if iscoroutinefunction is not None and iscoroutinefunction(f): + if iscoroutinefunction(f): r: "BaseRetrying" = AsyncRetrying(*dargs, **dkw) elif tornado and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f): r = TornadoRetrying(*dargs, **dkw) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 7387d6bf..38932eba 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -14,6 +14,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +import functools import sys from asyncio import sleep @@ -70,6 +72,7 @@ def wraps(self, fn): fn = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. + @functools.wraps(fn) async def async_wrapped(*args, **kwargs): return await fn(*args, **kwargs) From d442271252b90d15a56f3fd1c622e3902fc4d3cd Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Thu, 24 Jun 2021 14:07:21 +0200 Subject: [PATCH 027/124] Do not package tests with tenacity (#308) * move tests outside of package Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml | 3 +++ {tenacity/tests => tests}/__init__.py | 0 {tenacity/tests => tests}/test_asyncio.py | 3 ++- {tenacity/tests => tests}/test_tenacity.py | 0 {tenacity/tests => tests}/test_tornado.py | 3 ++- 5 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml rename {tenacity/tests => tests}/__init__.py (100%) rename {tenacity/tests => tests}/test_asyncio.py (98%) rename {tenacity/tests => tests}/test_tenacity.py (100%) rename {tenacity/tests => tests}/test_tornado.py (97%) diff --git a/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml b/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml new file mode 100644 index 00000000..ec8da2ca --- /dev/null +++ b/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml @@ -0,0 +1,3 @@ +--- +other: + - Do not package tests with tenacity. diff --git a/tenacity/tests/__init__.py b/tests/__init__.py similarity index 100% rename from tenacity/tests/__init__.py rename to tests/__init__.py diff --git a/tenacity/tests/test_asyncio.py b/tests/test_asyncio.py similarity index 98% rename from tenacity/tests/test_asyncio.py rename to tests/test_asyncio.py index 04ca0c9d..b638c001 100644 --- a/tenacity/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -23,9 +23,10 @@ from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio from tenacity import retry, stop_after_attempt -from tenacity.tests.test_tenacity import NoIOErrorAfterCount, current_time_ms from tenacity.wait import wait_fixed +from .test_tenacity import NoIOErrorAfterCount, current_time_ms + def asynctest(callable_): @wraps(callable_) diff --git a/tenacity/tests/test_tenacity.py b/tests/test_tenacity.py similarity index 100% rename from tenacity/tests/test_tenacity.py rename to tests/test_tenacity.py diff --git a/tenacity/tests/test_tornado.py b/tests/test_tornado.py similarity index 97% rename from tenacity/tests/test_tornado.py rename to tests/test_tornado.py index 1f72148c..ec5a3fd5 100644 --- a/tenacity/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -17,11 +17,12 @@ from tenacity import RetryError, retry, stop_after_attempt from tenacity import tornadoweb -from tenacity.tests.test_tenacity import NoIOErrorAfterCount from tornado import gen from tornado import testing +from .test_tenacity import NoIOErrorAfterCount + @retry @gen.coroutine From 786997edf9efb87704ce684d8765eb67d196820d Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Thu, 24 Jun 2021 16:15:34 +0200 Subject: [PATCH 028/124] Fix DeprecationWarnings in tests (#313) `assertRegexpMatches` is deprecated in favor of `assertRegex` no-changelog --- tests/test_tenacity.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 6b13de6c..74175372 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1087,8 +1087,8 @@ def _before_sleep_log_raises(self, get_call_fn): etalon_re = r"^Retrying .* in 0\.01 seconds as it raised " r"(IO|OS)Error: Hi there, I'm an IOError\.$" self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_raises(self): self._before_sleep_log_raises(lambda x: x) @@ -1123,8 +1123,8 @@ def test_before_sleep_log_raises_with_exc_info(self): ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_returns(self, exc_info=False): thing = NoneReturnUntilAfterCount(2) @@ -1149,8 +1149,8 @@ def test_before_sleep_log_returns(self, exc_info=False): etalon_re = r"^Retrying .* in 0\.01 seconds as it returned None\.$" self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_returns_with_exc_info(self): self.test_before_sleep_log_returns(exc_info=True) From c18dcfbf4b6a719f668f12fdb4999afaeef62648 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Thu, 24 Jun 2021 18:16:27 +0200 Subject: [PATCH 029/124] Fix #307 : Drop deprecated APIs (#314) * `BaseRetrying.call` was long time deprecated and produced `DeprecationWarning` * `BaseRetrying.fn` was noted as deprecated --- .../drop_deprecated-7ea90b212509b082.yaml | 5 +++++ tenacity/__init__.py | 19 +++---------------- tenacity/_asyncio.py | 4 ++-- tenacity/tornadoweb.py | 2 +- tests/test_asyncio.py | 10 ---------- tests/test_tenacity.py | 18 ------------------ 6 files changed, 11 insertions(+), 47 deletions(-) create mode 100644 releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml diff --git a/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml b/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml new file mode 100644 index 00000000..888233ce --- /dev/null +++ b/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - "Removed `BaseRetrying.call`: was long time deprecated and produced `DeprecationWarning`" + - "Removed `BaseRetrying.fn`: was noted as deprecated" + - "API change: `BaseRetrying.begin()` do not require arguments anymore as it not setting `BaseRetrying.fn`" diff --git a/tenacity/__init__.py b/tenacity/__init__.py index c7cbb8e3..e258d8af 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -246,10 +246,6 @@ def __init__( self.retry_error_cls = retry_error_cls self.retry_error_callback = retry_error_callback - # This attribute was moved to RetryCallState and is deprecated on - # Retrying objects but kept for backward compatibility. - self.fn: t.Optional[WrappedFn] = None - def copy( self, sleep: t.Union[t.Callable[[t.Union[int, float]], None], object] = _unset, @@ -334,12 +330,11 @@ def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return wrapped_f - def begin(self, fn: t.Optional[WrappedFn]) -> None: + def begin(self) -> None: self.statistics.clear() self.statistics["start_time"] = time.monotonic() self.statistics["attempt_number"] = 1 self.statistics["idle_for"] = 0 - self.fn = fn def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa fut = retry_state.outcome @@ -379,7 +374,7 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A return DoSleep(sleep) def __iter__(self) -> t.Generator[AttemptManager, None, None]: - self.begin(None) + self.begin() retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) while True: @@ -396,20 +391,12 @@ def __iter__(self) -> t.Generator[AttemptManager, None, None]: def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: pass - def call(self, *args: t.Any, **kwargs: t.Any) -> t.Union[DoAttempt, DoSleep, t.Any]: - """Use ``__call__`` instead because this method is deprecated.""" - warnings.warn( - "'call()' method is deprecated. " + "Use '__call__()' instead", - DeprecationWarning, - ) - return self.__call__(*args, **kwargs) - class Retrying(BaseRetrying): """Retrying controller.""" def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: - self.begin(fn) + self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 38932eba..7b2447de 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -32,7 +32,7 @@ def __init__(self, sleep=sleep, **kwargs): self.sleep = sleep async def __call__(self, fn, *args, **kwargs): - self.begin(fn) + self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: @@ -51,7 +51,7 @@ async def __call__(self, fn, *args, **kwargs): return do def __aiter__(self): - self.begin(None) + self.begin() self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) return self diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 8059045c..1175bec8 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -29,7 +29,7 @@ def __init__(self, sleep=gen.sleep, **kwargs): @gen.coroutine def __call__(self, fn, *args, **kwargs): - self.begin(fn) + self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index b638c001..b370e29c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -18,8 +18,6 @@ import unittest from functools import wraps -import pytest - from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio from tenacity import retry, stop_after_attempt @@ -73,14 +71,6 @@ async def test_retry_using_async_retying(self): await retrying(_async_function, thing) assert thing.counter == thing.count - @asynctest - async def test_retry_using_async_retying_legacy_method(self): - thing = NoIOErrorAfterCount(5) - retrying = AsyncRetrying() - with pytest.warns(DeprecationWarning): - await retrying.call(_async_function, thing) - assert thing.counter == thing.count - @asynctest async def test_stop_after_attempt(self): thing = NoIOErrorAfterCount(2) diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 74175372..f2438a75 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1093,9 +1093,6 @@ def _before_sleep_log_raises(self, get_call_fn): def test_before_sleep_log_raises(self): self._before_sleep_log_raises(lambda x: x) - def test_before_sleep_log_raises_deprecated_call(self): - self._before_sleep_log_raises(lambda x: x.call) - def test_before_sleep_log_raises_with_exc_info(self): thing = NoIOErrorAfterCount(2) logger = logging.getLogger(self.id()) @@ -1406,15 +1403,6 @@ def f(): assert f.calls == [1, 2] -class TestInvokeViaLegacyCallMethod(TestInvokeAsCallable): - """Retrying.call() method should work the same as Retrying.__call__().""" - - @staticmethod - def invoke(retry, f): - with reports_deprecation_warning(): - return retry.call(f) - - class TestRetryException(unittest.TestCase): def test_retry_error_is_pickleable(self): import pickle @@ -1492,12 +1480,6 @@ def __call__(self, seconds): monkeypatch.setattr(tenacity.nap.time, "sleep", sleep) yield sleep - def test_call(self, mock_sleep): - retrying = Retrying(**self.RETRY_ARGS) - with pytest.raises(RetryError): - retrying.call(self._fail) - assert mock_sleep.call_count == 4 - def test_decorated(self, mock_sleep): with pytest.raises(RetryError): self._decorated_fail() From 58fbe614433212e27484f70ed7984f33b1bdb707 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Wed, 30 Jun 2021 14:38:47 +0200 Subject: [PATCH 030/124] Add type annotations to cover all code. (#315) `tenacity` is marked as "typed" package, which means all public APIs should be annotated --- .../notes/annotate_code-197b93130df14042.yaml | 3 +++ tenacity/__init__.py | 5 +++-- tenacity/_asyncio.py | 21 +++++++++++++------ tenacity/tornadoweb.py | 15 +++++++++++-- tenacity/wait.py | 15 +++++++++++-- 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/annotate_code-197b93130df14042.yaml diff --git a/releasenotes/notes/annotate_code-197b93130df14042.yaml b/releasenotes/notes/annotate_code-197b93130df14042.yaml new file mode 100644 index 00000000..faf41635 --- /dev/null +++ b/releasenotes/notes/annotate_code-197b93130df14042.yaml @@ -0,0 +1,3 @@ +--- +other: + - Add type annotations to cover all public API. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index e258d8af..bc52383b 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -89,6 +89,7 @@ WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable) +_RetValT = t.TypeVar("_RetValT") @t.overload @@ -388,14 +389,14 @@ def __iter__(self) -> t.Generator[AttemptManager, None, None]: break @abstractmethod - def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: + def __call__(self, fn: t.Callable[..., _RetValT], *args: t.Any, **kwargs: t.Any) -> _RetValT: pass class Retrying(BaseRetrying): """Retrying controller.""" - def __call__(self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any) -> t.Any: + def __call__(self, fn: t.Callable[..., _RetValT], *args: t.Any, **kwargs: t.Any) -> _RetValT: self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 7b2447de..374ef206 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -17,6 +17,7 @@ import functools import sys +import typing from asyncio import sleep from tenacity import AttemptManager @@ -25,13 +26,21 @@ from tenacity import DoSleep from tenacity import RetryCallState +WrappedFn = typing.TypeVar("WrappedFn", bound=typing.Callable) +_RetValT = typing.TypeVar("_RetValT") + class AsyncRetrying(BaseRetrying): - def __init__(self, sleep=sleep, **kwargs): + def __init__(self, sleep: typing.Callable[[float], typing.Awaitable] = sleep, **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.sleep = sleep - async def __call__(self, fn, *args, **kwargs): + async def __call__( # type: ignore # Change signature from supertype + self, + fn: typing.Callable[..., typing.Awaitable[_RetValT]], + *args: typing.Any, + **kwargs: typing.Any, + ) -> _RetValT: self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) @@ -50,12 +59,12 @@ async def __call__(self, fn, *args, **kwargs): else: return do - def __aiter__(self): + def __aiter__(self) -> "AsyncRetrying": self.begin() self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) return self - async def __anext__(self): + async def __anext__(self) -> typing.Union[AttemptManager, typing.Any]: while True: do = self.iter(retry_state=self._retry_state) if do is None: @@ -68,12 +77,12 @@ async def __anext__(self): else: return do - def wraps(self, fn): + def wraps(self, fn: WrappedFn) -> WrappedFn: fn = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. @functools.wraps(fn) - async def async_wrapped(*args, **kwargs): + async def async_wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: return await fn(*args, **kwargs) # Preserve attributes diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 1175bec8..9d7b3959 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -13,6 +13,7 @@ # limitations under the License. import sys +import typing from tenacity import BaseRetrying from tenacity import DoAttempt @@ -21,14 +22,24 @@ from tornado import gen +if typing.TYPE_CHECKING: + from tornado.concurrent import Future + +_RetValT = typing.TypeVar("_RetValT") + class TornadoRetrying(BaseRetrying): - def __init__(self, sleep=gen.sleep, **kwargs): + def __init__(self, sleep: "typing.Callable[[float], Future[None]]" = gen.sleep, **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.sleep = sleep @gen.coroutine - def __call__(self, fn, *args, **kwargs): + def __call__( # type: ignore # Change signature from supertype + self, + fn: "typing.Callable[..., typing.Union[typing.Generator[typing.Any, typing.Any, _RetValT], Future[_RetValT]]]", + *args: typing.Any, + **kwargs: typing.Any, + ) -> "typing.Generator[typing.Any, typing.Any, _RetValT]": self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) diff --git a/tenacity/wait.py b/tenacity/wait.py index 2c1f4fb6..aacb58d6 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -111,7 +111,12 @@ class wait_incrementing(wait_base): (and restricting the upper limit to some maximum value). """ - def __init__(self, start=0, increment=100, max=_utils.MAX_WAIT): # noqa + def __init__( + self, + start: typing.Union[int, float] = 0, + increment: typing.Union[int, float] = 100, + max: typing.Union[int, float] = _utils.MAX_WAIT, # noqa + ) -> None: self.start = start self.increment = increment self.max = max @@ -134,7 +139,13 @@ class wait_exponential(wait_base): wait_random_exponential for the latter case. """ - def __init__(self, multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0): # noqa + def __init__( + self, + multiplier: typing.Union[int, float] = 1, + max: typing.Union[int, float] = _utils.MAX_WAIT, # noqa + exp_base: typing.Union[int, float] = 2, + min: typing.Union[int, float] = 0, # noqa + ) -> None: self.multiplier = multiplier self.min = min self.max = max From 20d9228351d17dd38f8e2af091c99af98b5fcd88 Mon Sep 17 00:00:00 2001 From: Zac Bentley Date: Mon, 5 Jul 2021 04:53:27 -0700 Subject: [PATCH 031/124] Add a __repr__ method to RetryCallState objects (#302) * Add a __repr__ method to RetryCallState objects * Update __init__.py * Address reviewer comments * Blacken * Make python 2 compatible * Blacken * Reblacken files * Switch to f-strings Co-authored-by: Julien Danjou Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../retrycallstate-repr-94947f7b00ee15e1.yaml | 3 +++ tenacity/__init__.py | 15 ++++++++++++++- tests/test_tenacity.py | 11 ++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml diff --git a/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml b/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml new file mode 100644 index 00000000..3f57bf23 --- /dev/null +++ b/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add a ``__repr__`` method to ``RetryCallState`` objects for easier debugging. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index bc52383b..1e3ff865 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -151,7 +151,7 @@ class BaseAction: Concrete implementations must define: - __init__: to initialize all necessary fields - - REPR_ATTRS: class variable specifying attributes to include in repr(self) + - REPR_FIELDS: class variable specifying attributes to include in repr(self) - NAME: for identification in retry object methods and callbacks """ @@ -495,6 +495,19 @@ def set_exception(self, exc_info: t.Tuple[t.Type[BaseException], BaseException, fut.set_exception(exc_info[1]) self.outcome, self.outcome_timestamp = fut, ts + def __repr__(self): + if self.outcome is None: + result = "none yet" + elif self.outcome.failed: + exception = self.outcome.exception() + result = f"failed ({exception.__class__.__name__} {exception})" + else: + result = f"returned {self.outcome.result()}" + + slept = float(round(self.idle_for, 2)) + clsname = self.__class__.__name__ + return f"<{clsname} {id(self)}: attempt #{self.attempt_number}; slept for {slept}; last result: {result}>" + from tenacity._asyncio import AsyncRetrying # noqa:E402,I100 diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index f2438a75..b9016e71 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -74,13 +74,22 @@ def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_re class TestBase(unittest.TestCase): - def test_repr(self): + def test_retrying_repr(self): class ConcreteRetrying(tenacity.BaseRetrying): def __call__(self, fn, *args, **kwargs): pass repr(ConcreteRetrying()) + def test_callstate_repr(self): + rs = RetryCallState(None, None, (), {}) + rs.idle_for = 1.1111111 + assert repr(rs).endswith("attempt #1; slept for 1.11; last result: none yet>") + rs = make_retry_state(2, 5) + assert repr(rs).endswith("attempt #2; slept for 0.0; last result: returned None>") + rs = make_retry_state(0, 0, last_result=tenacity.Future.construct(1, ValueError("aaa"), True)) + assert repr(rs).endswith("attempt #0; slept for 0.0; last result: failed (ValueError aaa)>") + class TestStopConditions(unittest.TestCase): def test_never_stop(self): From 06413c391f7fed0d286e35ce2226c1ac577cce67 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sydorenko Date: Wed, 7 Jul 2021 15:07:56 +0300 Subject: [PATCH 032/124] Drop `py2` tag from the wheel name (#320) * Drop `py2` tag from the wheel name * Add a change note for PR #320 --- releasenotes/notes/pr320-py3-only-wheel-tag.yaml | 5 +++++ setup.cfg | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/pr320-py3-only-wheel-tag.yaml diff --git a/releasenotes/notes/pr320-py3-only-wheel-tag.yaml b/releasenotes/notes/pr320-py3-only-wheel-tag.yaml new file mode 100644 index 00000000..e85f85f0 --- /dev/null +++ b/releasenotes/notes/pr320-py3-only-wheel-tag.yaml @@ -0,0 +1,5 @@ +--- +other: >- + Corrected the PyPI-published wheel tag to match the + metadata saying that the release is Python 3 only. +... diff --git a/setup.cfg b/setup.cfg index 8491b6a8..3e4fbee2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,9 +37,6 @@ doc = sphinx tornado>=4.5 -[wheel] -universal = 1 - [tool:pytest] filterwarnings = # Show any DeprecationWarnings once From a7f548520e4ee871ad8aeb354ecfa1a324c8ca19 Mon Sep 17 00:00:00 2001 From: Alexey Stepanov Date: Mon, 12 Jul 2021 11:34:42 +0200 Subject: [PATCH 033/124] Fix after_log logger format (#317) Co-authored-by: akgnah <1024@setq.me> Co-authored-by: akgnah <1024@setq.me> --- .../notes/after_log-50f4d73b24ce9203.yaml | 3 ++ tenacity/after.py | 4 +- tests/test_after.py | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/after_log-50f4d73b24ce9203.yaml create mode 100644 tests/test_after.py diff --git a/releasenotes/notes/after_log-50f4d73b24ce9203.yaml b/releasenotes/notes/after_log-50f4d73b24ce9203.yaml new file mode 100644 index 00000000..b8023e11 --- /dev/null +++ b/releasenotes/notes/after_log-50f4d73b24ce9203.yaml @@ -0,0 +1,3 @@ +--- +fixes: + - "Fix after_log logger format: function name was used with delay formatting." diff --git a/tenacity/after.py b/tenacity/after.py index 9dc55726..a38eae79 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -36,10 +36,10 @@ def after_log( """After call strategy that logs to some logger the finished attempt.""" def log_it(retry_state: "RetryCallState") -> None: - sec = sec_format % _utils.get_callback_name(retry_state.fn) logger.log( log_level, - f"Finished call to '{sec}' after {retry_state.seconds_since_start}(s), " + f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"after {sec_format % retry_state.seconds_since_start}(s), " f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) diff --git a/tests/test_after.py b/tests/test_after.py new file mode 100644 index 00000000..d98e3095 --- /dev/null +++ b/tests/test_after.py @@ -0,0 +1,50 @@ +import logging +import random +import unittest.mock + +from tenacity import after_log +from tenacity import _utils # noqa + +from . import test_tenacity + + +class TestAfterLogFormat(unittest.TestCase): + def setUp(self) -> None: + self.log_level = random.choice((logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL)) + self.previous_attempt_number = random.randint(1, 512) + + def test_01_default(self): + """Test log formatting.""" + log = unittest.mock.MagicMock(spec="logging.Logger.log") + logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) + + sec_format = "%0.3f" + delay_since_first_attempt = 0.1 + + retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) + fun = after_log(logger=logger, log_level=self.log_level) # use default sec_format + fun(retry_state) + log.assert_called_once_with( + self.log_level, + f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"after {sec_format % retry_state.seconds_since_start}(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", + ) + + def test_02_custom_sec_format(self): + """Test log formatting with custom int format..""" + log = unittest.mock.MagicMock(spec="logging.Logger.log") + logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) + + sec_format = "%.1f" + delay_since_first_attempt = 0.1 + + retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) + fun = after_log(logger=logger, log_level=self.log_level, sec_format=sec_format) + fun(retry_state) + log.assert_called_once_with( + self.log_level, + f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"after {sec_format % retry_state.seconds_since_start}(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", + ) From abf859fad54f15fb7cf7a10abdf876c6057bef63 Mon Sep 17 00:00:00 2001 From: Andrej Shadura Date: Mon, 4 Oct 2021 15:31:42 +0200 Subject: [PATCH 034/124] Rickroll a function name in one of the examples (#331) Signed-off-by: Andrej Shadura --- doc/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index 2f025ef1..f8ee2e78 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -95,7 +95,7 @@ an exception is raised. .. testcode:: @retry - def never_give_up_never_surrender(): + def never_gonna_give_you_up(): print("Retry forever ignoring Exceptions, don't wait between retries") raise Exception From 5a8649955a7ed623748a748b27717ffa2e7d2301 Mon Sep 17 00:00:00 2001 From: William Silversmith Date: Wed, 5 Jan 2022 14:53:44 -0500 Subject: [PATCH 035/124] docs: show how to use retry_if_not_exception_type --- doc/source/index.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/source/index.rst b/doc/source/index.rst index f8ee2e78..89525281 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -210,6 +210,11 @@ exceptions, as in the cases here. print("Retry forever with no wait if an IOError occurs, raise any other errors") raise Exception + @retry(retry=retry_if_not_exception_type(ClientError)) + def might_client_error(): + print("Retry forever with no wait if any error other than ClientError occurs. Immediately raise ClientError.") + raise Exception + We can also use the result of the function to alter the behavior of retrying. .. testcode:: From dfcf348367e7923ecc01bddf0cd1eb5a83a25e93 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 24 Jan 2022 14:55:50 +0100 Subject: [PATCH 036/124] ci: fix Mergify config --- .mergify.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 697c6207..f1a61c72 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,3 +1,13 @@ +queue_rules: + - name: default + conditions: + - "status-success=ci/circleci: pep8" + - "status-success=ci/circleci: black" + - "status-success=ci/circleci: py36" + - "status-success=ci/circleci: py37" + - "status-success=ci/circleci: py38" + - "status-success=ci/circleci: py39" + pull_request_rules: - name: warn on no changelog conditions: @@ -21,8 +31,8 @@ pull_request_rules: - "#approved-reviews-by>=1" - label=no-changelog actions: - merge: - strict: "smart" + queue: + name: default method: squash - name: automatic merge with changelog conditions: @@ -35,8 +45,8 @@ pull_request_rules: - "#approved-reviews-by>=1" - files~=^releasenotes/notes/ actions: - merge: - strict: "smart" + queue: + name: default method: squash - name: automatic merge for jd without changelog conditions: @@ -49,8 +59,8 @@ pull_request_rules: - "status-success=ci/circleci: py39" - label=no-changelog actions: - merge: - strict: "smart" + queue: + name: default method: squash - name: automatic merge for jd with changelog conditions: @@ -63,8 +73,8 @@ pull_request_rules: - "status-success=ci/circleci: py39" - files~=^releasenotes/notes/ actions: - merge: - strict: "smart" + queue: + name: default method: squash - name: dismiss reviews conditions: [] From a85896547de516efed5884dad1e6a0c7a6e7b2e4 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Wed, 27 Apr 2022 09:31:24 -0700 Subject: [PATCH 037/124] Define a ClientError to fix Sphinx build errors (#352) Co-authored-by: Isaac Good --- doc/source/index.rst | 3 +++ releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index 89525281..748995f5 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -205,6 +205,9 @@ exceptions, as in the cases here. .. testcode:: + class ClientError(Exception): + """Some type of client error.""" + @retry(retry=retry_if_exception_type(IOError)) def might_io_error(): print("Retry forever with no wait if an IOError occurs, raise any other errors") diff --git a/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml b/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml new file mode 100644 index 00000000..ccef1c0a --- /dev/null +++ b/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml @@ -0,0 +1,2 @@ +--- +fixes: Sphinx build error where Sphinx complains about an undefined class. From da1bfc9cfdf259b5f28c0f89408065b03afb9894 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Wed, 27 Apr 2022 09:43:10 -0700 Subject: [PATCH 038/124] Implement a wait.wait_exponential_jitter per Google's storage guide (#351) * Implement a wait.wait_exponential_jitter per Google's storage retry guide * Define a ClientError so Sphinx does not fail * Fix spelling typos * Simplify typing, replacing `int | float` with `float` * Drop needless `#noqa` Co-authored-by: Isaac Good --- ...t_exponential_jitter-6ffc81dddcbaa6d3.yaml | 5 +++ tenacity/__init__.py | 1 + tenacity/wait.py | 34 +++++++++++++++++++ tests/test_tenacity.py | 22 ++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml diff --git a/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml b/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml new file mode 100644 index 00000000..870380ca --- /dev/null +++ b/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Implement a wait.wait_exponential_jitter per Google's storage retry guide. + See https://cloud.google.com/storage/docs/retry-strategy diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 1e3ff865..fd403761 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -63,6 +63,7 @@ from .wait import wait_random # noqa from .wait import wait_random_exponential # noqa from .wait import wait_random_exponential as wait_full_jitter # noqa +from .wait import wait_exponential_jitter # noqa # Import all built-in before strategies for easier usage. from .before import before_log # noqa diff --git a/tenacity/wait.py b/tenacity/wait.py index aacb58d6..289705c7 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -189,3 +189,37 @@ class wait_random_exponential(wait_exponential): def __call__(self, retry_state: "RetryCallState") -> float: high = super().__call__(retry_state=retry_state) return random.uniform(0, high) + + +class wait_exponential_jitter(wait_base): + """Wait strategy that applies exponential backoff and jitter. + + It allows for a customized initial wait, maximum wait and jitter. + + This implements the strategy described here: + https://cloud.google.com/storage/docs/retry-strategy + + The wait time is min(initial * (2**n + random.uniform(0, jitter)), maximum) + where n is the retry count. + """ + + def __init__( + self, + initial: float = 1, + max: float = _utils.MAX_WAIT, # noqa + exp_base: float = 2, + jitter: float = 1, + ) -> None: + self.initial = initial + self.max = max + self.exp_base = exp_base + self.jitter = jitter + + def __call__(self, retry_state: "RetryCallState") -> float: + jitter = random.uniform(0, self.jitter) + try: + exp = self.exp_base ** (retry_state.attempt_number - 1) + result = self.initial * exp + jitter + except OverflowError: + result = self.max + return max(0, min(result, self.max)) diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b9016e71..d9a48583 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -435,6 +435,28 @@ def mean(lst): self._assert_inclusive_epsilon(mean(attempt[8]), 30, 2.56) self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56) + def test_wait_exponential_jitter(self): + fn = tenacity.wait_exponential_jitter(max=60) + + for _ in range(1000): + self._assert_inclusive_range(fn(make_retry_state(1, 0)), 1, 2) + self._assert_inclusive_range(fn(make_retry_state(2, 0)), 2, 3) + self._assert_inclusive_range(fn(make_retry_state(3, 0)), 4, 5) + self._assert_inclusive_range(fn(make_retry_state(4, 0)), 8, 9) + self._assert_inclusive_range(fn(make_retry_state(5, 0)), 16, 17) + self._assert_inclusive_range(fn(make_retry_state(6, 0)), 32, 33) + self.assertEqual(fn(make_retry_state(7, 0)), 60) + self.assertEqual(fn(make_retry_state(8, 0)), 60) + self.assertEqual(fn(make_retry_state(9, 0)), 60) + + fn = tenacity.wait_exponential_jitter(10, 5) + for _ in range(1000): + self.assertEqual(fn(make_retry_state(1, 0)), 5) + + # Default arguments exist + fn = tenacity.wait_exponential_jitter() + fn(make_retry_state(0, 0)) + def test_wait_retry_state_attributes(self): class ExtractCallState(Exception): pass From f6465c082fc153ec389b281aabc323f3c2d0ced9 Mon Sep 17 00:00:00 2001 From: Jack Desert Date: Fri, 27 May 2022 10:18:26 -0500 Subject: [PATCH 039/124] Show All Exception Predicates in Docs (#332) Co-authored-by: Jack Desert Co-authored-by: Julien Danjou --- doc/source/index.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/doc/source/index.rst b/doc/source/index.rst index 748995f5..7b27e657 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -230,6 +230,21 @@ We can also use the result of the function to alter the behavior of retrying. def might_return_none(): print("Retry with no wait if return value is None") +See also these methods: + +.. testcode:: + + retry_if_exception + retry_if_exception_type + retry_if_not_exception_type + retry_unless_exception_type + retry_if_result + retry_if_not_result + retry_if_exception_message + retry_if_not_exception_message + retry_any + retry_all + We can also combine several conditions: .. testcode:: From 18d05a6f049ea734cc6da02de6e3f9ecaffbc877 Mon Sep 17 00:00:00 2001 From: Noam Bloom Date: Mon, 30 May 2022 10:51:04 +0100 Subject: [PATCH 040/124] Support `datetime.timedelta` as a valid wait unit type (#342) * Support `datetime.timedelta` as a valid wait unit type Signed-off-by: Noam Bloom * Add datetime.timedelta support tests Signed-off-by: Noam Bloom Co-authored-by: Noam Bloom Co-authored-by: Julien Danjou --- ...delta-wait-unit-type-5ba1e9fc0fe45523.yaml | 3 + tenacity/wait.py | 37 ++++++---- tests/test_tenacity.py | 72 ++++++++++--------- 3 files changed, 65 insertions(+), 47 deletions(-) create mode 100644 releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml diff --git a/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml b/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml new file mode 100644 index 00000000..bc7e62dc --- /dev/null +++ b/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add ``datetime.timedelta`` as accepted wait unit type. diff --git a/tenacity/wait.py b/tenacity/wait.py index 289705c7..1d876728 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -17,12 +17,19 @@ import abc import random import typing +from datetime import timedelta from tenacity import _utils if typing.TYPE_CHECKING: from tenacity import RetryCallState +wait_unit_type = typing.Union[int, float, timedelta] + + +def to_seconds(wait_unit: wait_unit_type) -> float: + return float(wait_unit.total_seconds() if isinstance(wait_unit, timedelta) else wait_unit) + class wait_base(abc.ABC): """Abstract base class for wait strategies.""" @@ -44,8 +51,8 @@ def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_bas class wait_fixed(wait_base): """Wait strategy that waits a fixed amount of time between each retry.""" - def __init__(self, wait: float) -> None: - self.wait_fixed = wait + def __init__(self, wait: wait_unit_type) -> None: + self.wait_fixed = to_seconds(wait) def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_fixed @@ -61,9 +68,9 @@ def __init__(self) -> None: class wait_random(wait_base): """Wait strategy that waits a random amount of time between min/max.""" - def __init__(self, min: typing.Union[int, float] = 0, max: typing.Union[int, float] = 1) -> None: # noqa - self.wait_random_min = min - self.wait_random_max = max + def __init__(self, min: wait_unit_type = 0, max: wait_unit_type = 1) -> None: # noqa + self.wait_random_min = to_seconds(min) + self.wait_random_max = to_seconds(max) def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_random_min + (random.random() * (self.wait_random_max - self.wait_random_min)) @@ -113,13 +120,13 @@ class wait_incrementing(wait_base): def __init__( self, - start: typing.Union[int, float] = 0, - increment: typing.Union[int, float] = 100, - max: typing.Union[int, float] = _utils.MAX_WAIT, # noqa + start: wait_unit_type = 0, + increment: wait_unit_type = 100, + max: wait_unit_type = _utils.MAX_WAIT, # noqa ) -> None: - self.start = start - self.increment = increment - self.max = max + self.start = to_seconds(start) + self.increment = to_seconds(increment) + self.max = to_seconds(max) def __call__(self, retry_state: "RetryCallState") -> float: result = self.start + (self.increment * (retry_state.attempt_number - 1)) @@ -142,13 +149,13 @@ class wait_exponential(wait_base): def __init__( self, multiplier: typing.Union[int, float] = 1, - max: typing.Union[int, float] = _utils.MAX_WAIT, # noqa + max: wait_unit_type = _utils.MAX_WAIT, # noqa exp_base: typing.Union[int, float] = 2, - min: typing.Union[int, float] = 0, # noqa + min: wait_unit_type = 0, # noqa ) -> None: self.multiplier = multiplier - self.min = min - self.max = max + self.min = to_seconds(min) + self.max = to_seconds(max) self.exp_base = exp_base def __call__(self, retry_state: "RetryCallState") -> float: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index d9a48583..b6f6bbb0 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -13,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import datetime import logging import re import sys @@ -29,7 +30,6 @@ import tenacity from tenacity import RetryCallState, RetryError, Retrying, retry - _unset = object() @@ -180,28 +180,34 @@ def test_no_sleep(self): self.assertEqual(0, r.wait(make_retry_state(18, 9879))) def test_fixed_sleep(self): - r = Retrying(wait=tenacity.wait_fixed(1)) - self.assertEqual(1, r.wait(make_retry_state(12, 6546))) + for wait in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(wait=tenacity.wait_fixed(wait)) + self.assertEqual(1, r.wait(make_retry_state(12, 6546))) def test_incrementing_sleep(self): - r = Retrying(wait=tenacity.wait_incrementing(start=500, increment=100)) - self.assertEqual(500, r.wait(make_retry_state(1, 6546))) - self.assertEqual(600, r.wait(make_retry_state(2, 6546))) - self.assertEqual(700, r.wait(make_retry_state(3, 6546))) + for start, increment in ((500, 100), (datetime.timedelta(seconds=500), datetime.timedelta(seconds=100))): + with self.subTest(): + r = Retrying(wait=tenacity.wait_incrementing(start=start, increment=increment)) + self.assertEqual(500, r.wait(make_retry_state(1, 6546))) + self.assertEqual(600, r.wait(make_retry_state(2, 6546))) + self.assertEqual(700, r.wait(make_retry_state(3, 6546))) def test_random_sleep(self): - r = Retrying(wait=tenacity.wait_random(min=1, max=20)) - times = set() - for x in range(1000): - times.add(r.wait(make_retry_state(1, 6546))) - - # this is kind of non-deterministic... - self.assertTrue(len(times) > 1) - for t in times: - self.assertTrue(t >= 1) - self.assertTrue(t < 20) - - def test_random_sleep_without_min(self): + for min_, max_ in ((1, 20), (datetime.timedelta(seconds=1), datetime.timedelta(seconds=20))): + with self.subTest(): + r = Retrying(wait=tenacity.wait_random(min=min_, max=max_)) + times = set() + for _ in range(1000): + times.add(r.wait(make_retry_state(1, 6546))) + + # this is kind of non-deterministic... + self.assertTrue(len(times) > 1) + for t in times: + self.assertTrue(t >= 1) + self.assertTrue(t < 20) + + def test_random_sleep_withoutmin_(self): r = Retrying(wait=tenacity.wait_random(max=2)) times = set() times.add(r.wait(make_retry_state(1, 6546))) @@ -274,18 +280,20 @@ def test_exponential_with_min_wait_and_multiplier(self): self.assertEqual(r.wait(make_retry_state(8, 0)), 256) self.assertEqual(r.wait(make_retry_state(20, 0)), 1048576) - def test_exponential_with_min_wait_and_max_wait(self): - r = Retrying(wait=tenacity.wait_exponential(min=10, max=100)) - self.assertEqual(r.wait(make_retry_state(1, 0)), 10) - self.assertEqual(r.wait(make_retry_state(2, 0)), 10) - self.assertEqual(r.wait(make_retry_state(3, 0)), 10) - self.assertEqual(r.wait(make_retry_state(4, 0)), 10) - self.assertEqual(r.wait(make_retry_state(5, 0)), 16) - self.assertEqual(r.wait(make_retry_state(6, 0)), 32) - self.assertEqual(r.wait(make_retry_state(7, 0)), 64) - self.assertEqual(r.wait(make_retry_state(8, 0)), 100) - self.assertEqual(r.wait(make_retry_state(9, 0)), 100) - self.assertEqual(r.wait(make_retry_state(20, 0)), 100) + def test_exponential_with_min_wait_andmax__wait(self): + for min_, max_ in ((10, 100), (datetime.timedelta(seconds=10), datetime.timedelta(seconds=100))): + with self.subTest(): + r = Retrying(wait=tenacity.wait_exponential(min=min_, max=max_)) + self.assertEqual(r.wait(make_retry_state(1, 0)), 10) + self.assertEqual(r.wait(make_retry_state(2, 0)), 10) + self.assertEqual(r.wait(make_retry_state(3, 0)), 10) + self.assertEqual(r.wait(make_retry_state(4, 0)), 10) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 100) + self.assertEqual(r.wait(make_retry_state(9, 0)), 100) + self.assertEqual(r.wait(make_retry_state(20, 0)), 100) def test_legacy_explicit_wait_type(self): Retrying(wait="exponential_sleep") @@ -335,7 +343,7 @@ def test_wait_arbitrary_sum(self): ) ) # Test it a few time since it's random - for i in range(1000): + for _ in range(1000): w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) From 014b8e6c39d0052d9bb80ad85bae9a390d1aad09 Mon Sep 17 00:00:00 2001 From: Greesb Date: Wed, 21 Sep 2022 14:20:00 +0200 Subject: [PATCH 041/124] feat: Add retry_if_exception_cause_type (#362) Add a new retry_base class called `retry_if_exception_cause_type` that checks that the cause of the raised exception is of a certain type. Co-authored-by: Guillaume RISBOURG --- ...exception_cause_type-d16b918ace4ae0ad.yaml | 5 ++ tenacity/__init__.py | 1 + tenacity/retry.py | 27 ++++++++ tests/test_tenacity.py | 64 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml diff --git a/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml b/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml new file mode 100644 index 00000000..8b5a420f --- /dev/null +++ b/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add a new `retry_base` class called `retry_if_exception_cause_type` that + checks, recursively, if any of the causes of the raised exception is of a certain type. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index fd403761..008049a8 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -33,6 +33,7 @@ from .retry import retry_any # noqa from .retry import retry_if_exception # noqa from .retry import retry_if_exception_type # noqa +from .retry import retry_if_exception_cause_type # noqa from .retry import retry_if_not_exception_type # noqa from .retry import retry_if_not_result # noqa from .retry import retry_if_result # noqa diff --git a/tenacity/retry.py b/tenacity/retry.py index dd271175..1305d3f0 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -117,6 +117,33 @@ def __call__(self, retry_state: "RetryCallState") -> bool: return self.predicate(retry_state.outcome.exception()) +class retry_if_exception_cause_type(retry_base): + """Retries if any of the causes of the raised exception is of one or more types. + + The check on the type of the cause of the exception is done recursively (until finding + an exception in the chain that has no `__cause__`) + """ + + def __init__( + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: + self.exception_cause_types = exception_types + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome.failed: + exc = retry_state.outcome.exception() + while exc is not None: + if isinstance(exc.__cause__, self.exception_cause_types): + return True + exc = exc.__cause__ + + return False + + class retry_if_result(retry_base): """Retries if the result verifies a predicate.""" diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b6f6bbb0..2e5febd8 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -676,6 +676,56 @@ def go(self): return True +class NoNameErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count): + self.counter = 0 + self.count = count + + def go2(self): + raise NameError("Hi there, I'm a NameError") + + def go(self): + """Raise an IOError with a NameError as cause until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + try: + self.go2() + except NameError as e: + raise IOError() from e + + return True + + +class NoIOErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count): + self.counter = 0 + self.count = count + + def go2(self): + raise IOError("Hi there, I'm an IOError") + + def go(self): + """Raise a NameError with an IOError as cause until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + try: + self.go2() + except IOError as e: + raise NameError() from e + + return True + + class NameErrorUntilCount: """Holds counter state for invoking a method several times in a row.""" @@ -783,6 +833,11 @@ def _retryable_test_with_stop(thing): return thing.go() +@retry(retry=tenacity.retry_if_exception_cause_type(NameError)) +def _retryable_test_with_exception_cause_type(thing): + return thing.go() + + @retry(retry=tenacity.retry_if_exception_type(IOError)) def _retryable_test_with_exception_type_io(thing): return thing.go() @@ -987,6 +1042,15 @@ def test_retry_if_not_exception_message_match(self): s = _retryable_test_if_not_exception_message_message.retry.statistics self.assertTrue(s["attempt_number"] == 1) + def test_retry_if_exception_cause_type(self): + self.assertTrue(_retryable_test_with_exception_cause_type(NoNameErrorCauseAfterCount(5))) + + try: + _retryable_test_with_exception_cause_type(NoIOErrorCauseAfterCount(5)) + self.fail("Expected exception without NameError as cause") + except NameError: + pass + def test_defaults(self): self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5))) From de409a00c18b1d26a030292f7820ee26cdad2aa1 Mon Sep 17 00:00:00 2001 From: Theo Pilbeam Date: Wed, 7 Dec 2022 17:18:31 +0000 Subject: [PATCH 042/124] feat: accept `datetime.timedelta` instances as argument to `stop_after_delay` (#371) Rather than just accepting seconds as a float, accept `timedelta` instances, like the `wait` methods. --- .../timedelta-for-stop-ef6bf71b88ce9988.yaml | 4 ++ tenacity/_utils.py | 8 ++++ tenacity/stop.py | 6 ++- tenacity/wait.py | 37 ++++++++----------- tests/test_tenacity.py | 10 +++-- 5 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml diff --git a/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml b/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml new file mode 100644 index 00000000..9f792f0a --- /dev/null +++ b/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + - accept ``datetime.timedelta`` instances as argument to ``tenacity.stop.stop_after_delay`` diff --git a/tenacity/_utils.py b/tenacity/_utils.py index d5c4c9de..f14ff320 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -16,6 +16,7 @@ import sys import typing +from datetime import timedelta # sys.maxsize: @@ -66,3 +67,10 @@ def get_callback_name(cb: typing.Callable[..., typing.Any]) -> str: except AttributeError: pass return ".".join(segments) + + +time_unit_type = typing.Union[int, float, timedelta] + + +def to_seconds(time_unit: time_unit_type) -> float: + return float(time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit) diff --git a/tenacity/stop.py b/tenacity/stop.py index 35072244..bb48c818 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -16,6 +16,8 @@ import abc import typing +from tenacity import _utils + if typing.TYPE_CHECKING: import threading @@ -89,8 +91,8 @@ def __call__(self, retry_state: "RetryCallState") -> bool: class stop_after_delay(stop_base): """Stop when the time from the first attempt >= limit.""" - def __init__(self, max_delay: float) -> None: - self.max_delay = max_delay + def __init__(self, max_delay: _utils.time_unit_type) -> None: + self.max_delay = _utils.to_seconds(max_delay) def __call__(self, retry_state: "RetryCallState") -> bool: return retry_state.seconds_since_start >= self.max_delay diff --git a/tenacity/wait.py b/tenacity/wait.py index 1d876728..01c94a43 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -17,19 +17,12 @@ import abc import random import typing -from datetime import timedelta from tenacity import _utils if typing.TYPE_CHECKING: from tenacity import RetryCallState -wait_unit_type = typing.Union[int, float, timedelta] - - -def to_seconds(wait_unit: wait_unit_type) -> float: - return float(wait_unit.total_seconds() if isinstance(wait_unit, timedelta) else wait_unit) - class wait_base(abc.ABC): """Abstract base class for wait strategies.""" @@ -51,8 +44,8 @@ def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_bas class wait_fixed(wait_base): """Wait strategy that waits a fixed amount of time between each retry.""" - def __init__(self, wait: wait_unit_type) -> None: - self.wait_fixed = to_seconds(wait) + def __init__(self, wait: _utils.time_unit_type) -> None: + self.wait_fixed = _utils.to_seconds(wait) def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_fixed @@ -68,9 +61,9 @@ def __init__(self) -> None: class wait_random(wait_base): """Wait strategy that waits a random amount of time between min/max.""" - def __init__(self, min: wait_unit_type = 0, max: wait_unit_type = 1) -> None: # noqa - self.wait_random_min = to_seconds(min) - self.wait_random_max = to_seconds(max) + def __init__(self, min: _utils.time_unit_type = 0, max: _utils.time_unit_type = 1) -> None: # noqa + self.wait_random_min = _utils.to_seconds(min) + self.wait_random_max = _utils.to_seconds(max) def __call__(self, retry_state: "RetryCallState") -> float: return self.wait_random_min + (random.random() * (self.wait_random_max - self.wait_random_min)) @@ -120,13 +113,13 @@ class wait_incrementing(wait_base): def __init__( self, - start: wait_unit_type = 0, - increment: wait_unit_type = 100, - max: wait_unit_type = _utils.MAX_WAIT, # noqa + start: _utils.time_unit_type = 0, + increment: _utils.time_unit_type = 100, + max: _utils.time_unit_type = _utils.MAX_WAIT, # noqa ) -> None: - self.start = to_seconds(start) - self.increment = to_seconds(increment) - self.max = to_seconds(max) + self.start = _utils.to_seconds(start) + self.increment = _utils.to_seconds(increment) + self.max = _utils.to_seconds(max) def __call__(self, retry_state: "RetryCallState") -> float: result = self.start + (self.increment * (retry_state.attempt_number - 1)) @@ -149,13 +142,13 @@ class wait_exponential(wait_base): def __init__( self, multiplier: typing.Union[int, float] = 1, - max: wait_unit_type = _utils.MAX_WAIT, # noqa + max: _utils.time_unit_type = _utils.MAX_WAIT, # noqa exp_base: typing.Union[int, float] = 2, - min: wait_unit_type = 0, # noqa + min: _utils.time_unit_type = 0, # noqa ) -> None: self.multiplier = multiplier - self.min = to_seconds(min) - self.max = to_seconds(max) + self.min = _utils.to_seconds(min) + self.max = _utils.to_seconds(max) self.exp_base = exp_base def __call__(self, retry_state: "RetryCallState") -> float: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 2e5febd8..82806a69 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -155,10 +155,12 @@ def test_stop_after_attempt(self): self.assertTrue(r.stop(make_retry_state(4, 6546))) def test_stop_after_delay(self): - r = Retrying(stop=tenacity.stop_after_delay(1)) - self.assertFalse(r.stop(make_retry_state(2, 0.999))) - self.assertTrue(r.stop(make_retry_state(2, 1))) - self.assertTrue(r.stop(make_retry_state(2, 1.001))) + for delay in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(stop=tenacity.stop_after_delay(delay)) + self.assertFalse(r.stop(make_retry_state(2, 0.999))) + self.assertTrue(r.stop(make_retry_state(2, 1))) + self.assertTrue(r.stop(make_retry_state(2, 1.001))) def test_legacy_explicit_stop_type(self): Retrying(stop="stop_after_attempt") From 8b17b0028d7c54758448f1cb2aefd2af3bde42f2 Mon Sep 17 00:00:00 2001 From: Wenjin Rao <19flipped@gmail.com> Date: Fri, 16 Dec 2022 18:37:13 +0800 Subject: [PATCH 043/124] fix: docstring for wait_exponential_jitter (#372) --- tenacity/wait.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenacity/wait.py b/tenacity/wait.py index 01c94a43..21d40cb1 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -199,7 +199,7 @@ class wait_exponential_jitter(wait_base): This implements the strategy described here: https://cloud.google.com/storage/docs/retry-strategy - The wait time is min(initial * (2**n + random.uniform(0, jitter)), maximum) + The wait time is min(initial * 2**n + random.uniform(0, jitter), maximum) where n is the retry count. """ From 98bc9d82c1e115b29e1a9cf61a38c6e13348d190 Mon Sep 17 00:00:00 2001 From: Jack Desert Date: Mon, 23 Jan 2023 05:45:49 -0500 Subject: [PATCH 044/124] Clarify the effect of `reraise=True` (#377) Co-authored-by: Jack Desert --- doc/source/index.rst | 8 ++++++-- .../notes/clarify-reraise-option-6829667eacf4f599.yaml | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index 7b27e657..e9743d61 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -274,8 +274,12 @@ exception: Error Handling ~~~~~~~~~~~~~~ -While callables that "timeout" retrying raise a `RetryError` by default, -we can reraise the last attempt's exception if needed: +Normally when your function fails its final time (and will not be retried again based on your settings), +a `RetryError` is raised. The exception your code encountered will be shown somewhere in the *middle* +of the stack trace. + +If you would rather see the exception your code encountered at the *end* of the stack trace (where it +is most visible), you can set `reraise=True`. .. testcode:: diff --git a/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml b/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml new file mode 100644 index 00000000..31be8350 --- /dev/null +++ b/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml @@ -0,0 +1,3 @@ +--- +prelude: > + Clarify usage of `reraise` keyword argument From 1aa3e41559e9f14468841b3d142b8d92e3795e5f Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 09:20:17 +0100 Subject: [PATCH 045/124] ci: add github action jobs --- .circleci/config.yml | 60 --------------------------------------- .github/workflows/ci.yaml | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index 418db0c3..ff36fb7e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,60 +1,6 @@ version: 2 jobs: - pep8: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e pep8 - black: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e black-ci - py36: - docker: - - image: circleci/python:3.6 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py36 - py37: - docker: - - image: circleci/python:3.7 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py37 - py38: - docker: - - image: circleci/python:3.8 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py38 - py39: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - run: - command: | - sudo pip install tox - tox -e py39 deploy: docker: - image: circleci/python:3.9 @@ -84,12 +30,6 @@ workflows: test: jobs: - - pep8 - - black - - py36 - - py37 - - py38 - - py39 - deploy: filters: tags: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..23cbfba9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,51 @@ +name: Continuous Integration +permissions: read-all + +on: + pull_request: + branches: + - main + +concurrency: + # yamllint disable-line rule:line-length + group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}" + cancel-in-progress: true + +jobs: + test: + timeout-minutes: 20 + runs-on: ubuntu-20.04 + strategy: + matrix: + python: [3.6, 3.7, 3.8, 3.9, 3.10, 3.11] + tox: [36, 37, 38, 39, 310, 311, pep8, black-ci] + include: + - python: 3.6 + tox: 36 + - python: 3.7 + tox: 37 + - python: 3.8 + tox: 38 + - python: 3.9 + tox: 39 + - python: 3.10 + tox: 310 + - python: 3.11 + tox: 311 + - python: 3.11 + tox: pep8 + - python: 3.11 + tox: black-ci + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v3.3.0 + + - name: Setup Python 🔧 + uses: actions/setup-python@v4.5.0 + with: + python-version: ${{ matrix.python }} + + - name: Build 🔧 & Test 🔍 + run: | + pip install tox + tox -e ${{ matrix.tox }} From c3ec31cb2481305d68b0c893d6ab97cbc89d98d5 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 09:20:17 +0100 Subject: [PATCH 046/124] ci(mergify): replace CircleCI by GitHub action names This also fixes the matrix and add missing py310, py311 targets in tox, black and setup.cfg. --- .github/workflows/ci.yaml | 32 ++++++++++++++-------------- .mergify.yml | 44 ++++++++++++--------------------------- pyproject.toml | 2 +- setup.cfg | 1 + tox.ini | 8 +++---- 5 files changed, 35 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 23cbfba9..bb94bd0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,28 +17,28 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9, 3.10, 3.11] - tox: [36, 37, 38, 39, 310, 311, pep8, black-ci] include: - - python: 3.6 - tox: 36 - - python: 3.7 - tox: 37 - - python: 3.8 - tox: 38 - - python: 3.9 - tox: 39 - - python: 3.10 - tox: 310 - - python: 3.11 - tox: 311 - - python: 3.11 + - python: "3.6" + tox: py36 + - python: "3.7" + tox: py37 + - python: "3.8" + tox: py38 + - python: "3.9" + tox: py39 + - python: "3.10" + tox: py310 + - python: "3.11" + tox: py311 + - python: "3.11" tox: pep8 - - python: 3.11 + - python: "3.11" tox: black-ci steps: - name: Checkout 🛎️ uses: actions/checkout@v3.3.0 + with: + fetch-depth: 0 - name: Setup Python 🔧 uses: actions/setup-python@v4.5.0 diff --git a/.mergify.yml b/.mergify.yml index f1a61c72..3a351e96 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,12 +1,14 @@ queue_rules: - name: default - conditions: - - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: black" - - "status-success=ci/circleci: py36" - - "status-success=ci/circleci: py37" - - "status-success=ci/circleci: py38" - - "status-success=ci/circleci: py39" + conditions: &CheckRuns + - "check-success=test (3.6, py36)" + - "check-success=test (3.7, py37)" + - "check-success=test (3.8, py38)" + - "check-success=test (3.9, py39)" + - "check-success=test (3.10, py310)" + - "check-success=test (3.11, py311)" + - "check-success=test (3.11, black-ci)" + - "check-success=test (3.11, pep8)" pull_request_rules: - name: warn on no changelog @@ -22,12 +24,7 @@ pull_request_rules: a changelog entry. - name: automatic merge without changelog conditions: - - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: black" - - "status-success=ci/circleci: py36" - - "status-success=ci/circleci: py37" - - "status-success=ci/circleci: py38" - - "status-success=ci/circleci: py39" + - and: *CheckRuns - "#approved-reviews-by>=1" - label=no-changelog actions: @@ -36,12 +33,7 @@ pull_request_rules: method: squash - name: automatic merge with changelog conditions: - - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: black" - - "status-success=ci/circleci: py36" - - "status-success=ci/circleci: py37" - - "status-success=ci/circleci: py38" - - "status-success=ci/circleci: py39" + - and: *CheckRuns - "#approved-reviews-by>=1" - files~=^releasenotes/notes/ actions: @@ -51,12 +43,7 @@ pull_request_rules: - name: automatic merge for jd without changelog conditions: - author=jd - - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: black" - - "status-success=ci/circleci: py36" - - "status-success=ci/circleci: py37" - - "status-success=ci/circleci: py38" - - "status-success=ci/circleci: py39" + - and: *CheckRuns - label=no-changelog actions: queue: @@ -65,12 +52,7 @@ pull_request_rules: - name: automatic merge for jd with changelog conditions: - author=jd - - "status-success=ci/circleci: pep8" - - "status-success=ci/circleci: black" - - "status-success=ci/circleci: py36" - - "status-success=ci/circleci: py37" - - "status-success=ci/circleci: py38" - - "status-success=ci/circleci: py39" + - and: *CheckRuns - files~=^releasenotes/notes/ actions: queue: diff --git a/pyproject.toml b/pyproject.toml index 89668361..24c16bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,6 @@ build-backend="setuptools.build_meta" [tool.black] line-length = 120 safe = true -target-version = ["py36", "py37", "py38", "py39"] +target-version = ["py36", "py37", "py38", "py39", "py310", "py311"] [tool.setuptools_scm] diff --git a/setup.cfg b/setup.cfg index 3e4fbee2..b89917cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifier = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Utilities [options] diff --git a/tox.ini b/tox.ini index 65a622c4..5b7dd3f7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{6,7,8,9,10}, pep8, pypy3 +envlist = py3{6,7,8,9,10,11}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -10,9 +10,9 @@ deps = pytest typeguard commands = - py3{6,7,8,9,10},pypy3: pytest {posargs} - py3{6,7,8,9,10},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{6,7,8,9,10},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{6,7,8,9,10,11},pypy3: pytest {posargs} + py3{6,7,8,9,10,11},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{6,7,8,9,10,11},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From e1323a0e0ff9f7d867d3632c3ebf582f446b0a91 Mon Sep 17 00:00:00 2001 From: Tomas Gareau Date: Mon, 30 Jan 2023 03:15:28 -0600 Subject: [PATCH 047/124] Explicitly export convenience symbols from tenacity root module (#347) --- doc/source/index.rst | 3 + ...-convenience-symbols-981d9611c8b754f3.yaml | 3 + tenacity/__init__.py | 55 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index e9743d61..bfcc6b93 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -302,6 +302,7 @@ by using the before callback function: .. testcode:: import logging + import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) @@ -316,6 +317,7 @@ In the same spirit, It's possible to execute after a call that failed: .. testcode:: import logging + import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) @@ -332,6 +334,7 @@ retries happen after a wait interval, so the keyword argument is called .. testcode:: import logging + import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) diff --git a/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml b/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml new file mode 100644 index 00000000..aa990970 --- /dev/null +++ b/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml @@ -0,0 +1,3 @@ +--- +features: + - Explicitly export convenience symbols from tenacity root module diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 008049a8..78310e94 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -515,3 +515,58 @@ def __repr__(self): if tornado: from tenacity.tornadoweb import TornadoRetrying + + +__all__ = [ + "retry_base", + "retry_all", + "retry_always", + "retry_any", + "retry_if_exception", + "retry_if_exception_type", + "retry_if_not_exception_type", + "retry_if_not_result", + "retry_if_result", + "retry_never", + "retry_unless_exception_type", + "retry_if_exception_message", + "retry_if_not_exception_message", + "sleep", + "sleep_using_event", + "stop_after_attempt", + "stop_after_delay", + "stop_all", + "stop_any", + "stop_never", + "stop_when_event_set", + "wait_chain", + "wait_combine", + "wait_exponential", + "wait_fixed", + "wait_incrementing", + "wait_none", + "wait_random", + "wait_random_exponential", + "wait_full_jitter", + "before_log", + "before_nothing", + "after_log", + "after_nothing", + "before_sleep_log", + "before_sleep_nothing", + "retry", + "WrappedFn", + "TryAgain", + "NO_RESULT", + "DoAttempt", + "DoSleep", + "BaseAction", + "RetryAction", + "RetryError", + "AttemptManager", + "BaseRetrying", + "Retrying", + "Future", + "RetryCallState", + "AsyncRetrying", +] From 0b1cef0be60b2ab730cacf38d9d456787ad6d6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mu=C3=B1oz=20Ferrara?= Date: Mon, 30 Jan 2023 10:47:32 +0100 Subject: [PATCH 048/124] Fix async loop with retrying code block when result is available (#369) * Fix async loop when result is available * Add documentation and release notes * Better doc * Fix documentation * More concrete return type --------- Co-authored-by: Julien Danjou --- doc/source/index.rst | 16 ++++++++++++++++ ...-async-loop-with-result-f68e913ccb425aca.yaml | 4 ++++ tenacity/_asyncio.py | 4 ++-- tests/test_asyncio.py | 16 +++++++++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index bfcc6b93..47231b80 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -573,6 +573,22 @@ With async code you can use AsyncRetrying. except RetryError: pass +In both cases, you may want to set the result to the attempt so it's available +in retry strategies like ``retry_if_result``. This can be done accessing the +``retry_state`` property: + +.. testcode:: + + from tenacity import AsyncRetrying, retry_if_result + + async def function(): + async for attempt in AsyncRetrying(retry=retry_if_result(lambda x: x < 3)): + with attempt: + result = 1 # Some complex calculation, function call, etc. + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(result) + return result + Async and retry ~~~~~~~~~~~~~~~ diff --git a/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml b/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml new file mode 100644 index 00000000..73a58aa8 --- /dev/null +++ b/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Fix async loop with retrying code block when result is available. diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 374ef206..10d30f89 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -64,7 +64,7 @@ def __aiter__(self) -> "AsyncRetrying": self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) return self - async def __anext__(self) -> typing.Union[AttemptManager, typing.Any]: + async def __anext__(self) -> AttemptManager: while True: do = self.iter(retry_state=self._retry_state) if do is None: @@ -75,7 +75,7 @@ async def __anext__(self) -> typing.Union[AttemptManager, typing.Any]: self._retry_state.prepare_for_next_attempt() await self.sleep(do) else: - return do + raise StopAsyncIteration def wraps(self, fn: WrappedFn) -> WrappedFn: fn = super().wraps(fn) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index b370e29c..66948904 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -20,7 +20,7 @@ from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio -from tenacity import retry, stop_after_attempt +from tenacity import retry, retry_if_result, stop_after_attempt from tenacity.wait import wait_fixed from .test_tenacity import NoIOErrorAfterCount, current_time_ms @@ -156,6 +156,20 @@ async def test_sleeps(self): t = current_time_ms() - start self.assertLess(t, 1.1) + @asynctest + async def test_retry_with_result(self): + async def test(): + attempts = 0 + async for attempt in tasyncio.AsyncRetrying(retry=retry_if_result(lambda x: x < 3)): + with attempt: + attempts += 1 + attempt.retry_state.set_result(attempts) + return attempts + + result = await test() + + self.assertEqual(3, result) + if __name__ == "__main__": unittest.main() From 87f913d644fcba0670f750bb220c1213b81d0b59 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 10:50:03 +0100 Subject: [PATCH 049/124] ci: replace CircleCI deplay job by GitHub action (#381) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .circleci/config.yml | 38 ----------------------------------- .github/workflows/deploy.yaml | 33 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 38 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/deploy.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index ff36fb7e..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,38 +0,0 @@ -version: 2 - -jobs: - deploy: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - run: | - python -m venv venv - - run: | - venv/bin/pip install twine wheel - - run: - name: init .pypirc - command: | - echo -e "[pypi]" >> ~/.pypirc - echo -e "username = __token__" >> ~/.pypirc - echo -e "password = $PYPI_TOKEN" >> ~/.pypirc - - run: - name: create packages - command: | - venv/bin/python setup.py sdist bdist_wheel - - run: - name: upload to PyPI - command: | - venv/bin/twine upload dist/* - -workflows: - version: 2 - - test: - jobs: - - deploy: - filters: - tags: - only: /[0-9]+(\.[0-9]+)*/ - branches: - ignore: /.*/ diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 00000000..a81a46c0 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,33 @@ +name: Release deploy + +on: + push: + tags: + +jobs: + test: + timeout-minutes: 20 + runs-on: ubuntu-20.04 + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v3.3.0 + with: + fetch-depth: 0 + + - name: Setup Python 🔧 + uses: actions/setup-python@v4.5.0 + with: + python-version: 3.11 + + - name: Build 🔧 & Deploy 🚀 + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: | + pip install tox twine wheel$ + + echo -e "[pypi]" >> ~/.pypirc + echo -e "username = __token__" >> ~/.pypirc + echo -e "password = $PYPI_TOKEN" >> ~/.pypirc + + python setup.py sdist bdist_wheel + twine upload dist/* From 1007141288e4e22f9166b714553c63d88053ed43 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 13:52:58 +0100 Subject: [PATCH 050/124] ci(deploy): fix typo (#383) --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index a81a46c0..a310733b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -23,7 +23,7 @@ jobs: env: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} run: | - pip install tox twine wheel$ + pip install tox twine wheel echo -e "[pypi]" >> ~/.pypirc echo -e "username = __token__" >> ~/.pypirc From c0fc6911e6ca653d1dc49893307a8f761397f98e Mon Sep 17 00:00:00 2001 From: hirosassa Date: Sun, 23 Jan 2022 17:06:00 +0900 Subject: [PATCH 051/124] ci: add mypy This a rework of https://github.com/jd/tenacity/pull/339. The main differences with the PR above: * I set the ignore code for all `# type: ignore` * Instead of taking decision when RetryCallState has some attributes not set as expected, I raised a RuntimeError as this is unexpected. Deciding on such a situation would just hide bugs. --- .mergify.yml | 1 + pyproject.toml | 9 ++++++++ tenacity/__init__.py | 48 +++++++++++++++++++--------------------- tenacity/_asyncio.py | 19 +++++++++------- tenacity/after.py | 7 +++++- tenacity/before.py | 7 +++++- tenacity/before_sleep.py | 17 ++++++++++++-- tenacity/retry.py | 35 ++++++++++++++++++++++++++--- tenacity/stop.py | 2 ++ tenacity/tornadoweb.py | 6 ++--- tenacity/wait.py | 2 +- tests/test_after.py | 8 ++++--- tox.ini | 6 +++++ 13 files changed, 120 insertions(+), 47 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 3a351e96..f74465d1 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -9,6 +9,7 @@ queue_rules: - "check-success=test (3.11, py311)" - "check-success=test (3.11, black-ci)" - "check-success=test (3.11, pep8)" + - "check-success=test (3.11, mypy)" pull_request_rules: - name: warn on no changelog diff --git a/pyproject.toml b/pyproject.toml index 24c16bbc..41ffe726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,4 +13,13 @@ line-length = 120 safe = true target-version = ["py36", "py37", "py38", "py39", "py310", "py311"] +[tool.mypy] +strict = true +files = ["tenacity"] +show_error_codes = true + +[[tool.mypy.overrides]] +module = "tornado.*" +ignore_missing_imports = true + [tool.setuptools_scm] diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 78310e94..d110a78c 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -79,9 +79,9 @@ from .before_sleep import before_sleep_nothing # noqa try: - import tornado # type: ignore + import tornado except ImportError: - tornado = None # type: ignore + tornado = None if t.TYPE_CHECKING: import types @@ -90,20 +90,10 @@ from .stop import stop_base -WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable) +WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) _RetValT = t.TypeVar("_RetValT") -@t.overload -def retry(fn: WrappedFn) -> WrappedFn: - pass - - -@t.overload -def retry(*dargs: t.Any, **dkw: t.Any) -> t.Callable[[WrappedFn], WrappedFn]: # noqa - pass - - def retry(*dargs: t.Any, **dkw: t.Any) -> t.Union[WrappedFn, t.Callable[[WrappedFn], WrappedFn]]: # noqa """Wrap a function with a new `Retrying` object. @@ -214,7 +204,7 @@ def __exit__( exc_value: t.Optional[BaseException], traceback: t.Optional["types.TracebackType"], ) -> t.Optional[bool]: - if isinstance(exc_value, BaseException): + if exc_type is not None and exc_value is not None: self.retry_state.set_exception((exc_type, exc_value, traceback)) return True # Swallow exception. else: @@ -310,9 +300,9 @@ def statistics(self) -> t.Dict[str, t.Any]: statistics from each thread). """ try: - return self._local.statistics + return self._local.statistics # type: ignore[no-any-return] except AttributeError: - self._local.statistics = {} + self._local.statistics = t.cast(t.Dict[str, t.Any], {}) return self._local.statistics def wraps(self, f: WrappedFn) -> WrappedFn: @@ -328,10 +318,10 @@ def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return self.copy(*args, **kwargs).wraps(f) - wrapped_f.retry = self - wrapped_f.retry_with = retry_with + wrapped_f.retry = self # type: ignore[attr-defined] + wrapped_f.retry_with = retry_with # type: ignore[attr-defined] - return wrapped_f + return wrapped_f # type: ignore[return-value] def begin(self) -> None: self.statistics.clear() @@ -346,7 +336,7 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A self.before(retry_state) return DoAttempt() - is_explicit_retry = retry_state.outcome.failed and isinstance(retry_state.outcome.exception(), TryAgain) + is_explicit_retry = fut.failed and isinstance(fut.exception(), TryAgain) if not (is_explicit_retry or self.retry(retry_state=retry_state)): return fut.result() @@ -408,17 +398,23 @@ def __call__(self, fn: t.Callable[..., _RetValT], *args: t.Any, **kwargs: t.Any) try: result = fn(*args, **kwargs) except BaseException: # noqa: B902 - retry_state.set_exception(sys.exc_info()) + retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type] else: retry_state.set_result(result) elif isinstance(do, DoSleep): retry_state.prepare_for_next_attempt() self.sleep(do) else: - return do + return do # type: ignore[no-any-return] + + +if sys.version_info[1] >= 9: + FutureGenericT = futures.Future[t.Any] +else: + FutureGenericT = futures.Future -class Future(futures.Future): +class Future(FutureGenericT): """Encapsulates a (future or past) attempted call to a target function.""" def __init__(self, attempt_number: int) -> None: @@ -491,13 +487,15 @@ def set_result(self, val: t.Any) -> None: fut.set_result(val) self.outcome, self.outcome_timestamp = fut, ts - def set_exception(self, exc_info: t.Tuple[t.Type[BaseException], BaseException, "types.TracebackType"]) -> None: + def set_exception( + self, exc_info: t.Tuple[t.Type[BaseException], BaseException, "types.TracebackType| None"] + ) -> None: ts = time.monotonic() fut = Future(self.attempt_number) fut.set_exception(exc_info[1]) self.outcome, self.outcome_timestamp = fut, ts - def __repr__(self): + def __repr__(self) -> str: if self.outcome is None: result = "none yet" elif self.outcome.failed: diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 10d30f89..0b0f8765 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -26,16 +26,19 @@ from tenacity import DoSleep from tenacity import RetryCallState -WrappedFn = typing.TypeVar("WrappedFn", bound=typing.Callable) + +WrappedFn = typing.TypeVar("WrappedFn", bound=typing.Callable[..., typing.Any]) _RetValT = typing.TypeVar("_RetValT") class AsyncRetrying(BaseRetrying): - def __init__(self, sleep: typing.Callable[[float], typing.Awaitable] = sleep, **kwargs: typing.Any) -> None: + def __init__( + self, sleep: typing.Callable[[float], typing.Awaitable[typing.Any]] = sleep, **kwargs: typing.Any + ) -> None: super().__init__(**kwargs) self.sleep = sleep - async def __call__( # type: ignore # Change signature from supertype + async def __call__( # type: ignore[override] self, fn: typing.Callable[..., typing.Awaitable[_RetValT]], *args: typing.Any, @@ -50,14 +53,14 @@ async def __call__( # type: ignore # Change signature from supertype try: result = await fn(*args, **kwargs) except BaseException: # noqa: B902 - retry_state.set_exception(sys.exc_info()) + retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type] else: retry_state.set_result(result) elif isinstance(do, DoSleep): retry_state.prepare_for_next_attempt() await self.sleep(do) else: - return do + return do # type: ignore[no-any-return] def __aiter__(self) -> "AsyncRetrying": self.begin() @@ -86,7 +89,7 @@ async def async_wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: return await fn(*args, **kwargs) # Preserve attributes - async_wrapped.retry = fn.retry - async_wrapped.retry_with = fn.retry_with + async_wrapped.retry = fn.retry # type: ignore[attr-defined] + async_wrapped.retry_with = fn.retry_with # type: ignore[attr-defined] - return async_wrapped + return async_wrapped # type: ignore[return-value] diff --git a/tenacity/after.py b/tenacity/after.py index a38eae79..aa3cc9df 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -36,9 +36,14 @@ def after_log( """After call strategy that logs to some logger the finished attempt.""" def log_it(retry_state: "RetryCallState") -> None: + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) logger.log( log_level, - f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"Finished call to '{fn_name}' " f"after {sec_format % retry_state.seconds_since_start}(s), " f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) diff --git a/tenacity/before.py b/tenacity/before.py index 6a95406c..9284f7ae 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -32,9 +32,14 @@ def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["Re """Before call strategy that logs to some logger the attempt.""" def log_it(retry_state: "RetryCallState") -> None: + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) logger.log( log_level, - f"Starting call to '{_utils.get_callback_name(retry_state.fn)}', " + f"Starting call to '{fn_name}', " f"this is the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index 44b9f70e..279a21eb 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -36,6 +36,14 @@ def before_sleep_log( """Before call strategy that logs to some logger the attempt.""" def log_it(retry_state: "RetryCallState") -> None: + local_exc_info: BaseException | bool | None + + if retry_state.outcome is None: + raise RuntimeError("log_it() called before outcome was set") + + if retry_state.next_action is None: + raise RuntimeError("log_it() called before next_action was set") + if retry_state.outcome.failed: ex = retry_state.outcome.exception() verb, value = "raised", f"{ex.__class__.__name__}: {ex}" @@ -48,10 +56,15 @@ def log_it(retry_state: "RetryCallState") -> None: verb, value = "returned", retry_state.outcome.result() local_exc_info = False # exc_info does not apply when no exception + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) + logger.log( log_level, - f"Retrying {_utils.get_callback_name(retry_state.fn)} " - f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", + f"Retrying {fn_name} " f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", exc_info=local_exc_info, ) diff --git a/tenacity/retry.py b/tenacity/retry.py index 1305d3f0..73cb2684 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -63,8 +63,14 @@ def __init__(self, predicate: typing.Callable[[BaseException], bool]) -> None: self.predicate = predicate def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + if retry_state.outcome.failed: - return self.predicate(retry_state.outcome.exception()) + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return self.predicate(exception) else: return False @@ -111,10 +117,17 @@ def __init__( super().__init__(lambda e: not isinstance(e, exception_types)) def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + # always retry if no exception was raised if not retry_state.outcome.failed: return True - return self.predicate(retry_state.outcome.exception()) + + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return self.predicate(exception) class retry_if_exception_cause_type(retry_base): @@ -134,6 +147,9 @@ def __init__( self.exception_cause_types = exception_types def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__ called before outcome was set") + if retry_state.outcome.failed: exc = retry_state.outcome.exception() while exc is not None: @@ -151,6 +167,9 @@ def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None: self.predicate = predicate def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + if not retry_state.outcome.failed: return self.predicate(retry_state.outcome.result()) else: @@ -164,6 +183,9 @@ def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None: self.predicate = predicate def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + if not retry_state.outcome.failed: return not self.predicate(retry_state.outcome.result()) else: @@ -215,9 +237,16 @@ def __init__( self.predicate = lambda *args_, **kwargs_: not if_predicate(*args_, **kwargs_) def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + if not retry_state.outcome.failed: return True - return self.predicate(retry_state.outcome.exception()) + + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return self.predicate(exception) class retry_any(retry_base): diff --git a/tenacity/stop.py b/tenacity/stop.py index bb48c818..48b0a4ee 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -95,4 +95,6 @@ def __init__(self, max_delay: _utils.time_unit_type) -> None: self.max_delay = _utils.to_seconds(max_delay) def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.seconds_since_start is None: + raise RuntimeError("__call__() called but seconds_since_start is not set") return retry_state.seconds_since_start >= self.max_delay diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 9d7b3959..fabf13ae 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -33,8 +33,8 @@ def __init__(self, sleep: "typing.Callable[[float], Future[None]]" = gen.sleep, super().__init__(**kwargs) self.sleep = sleep - @gen.coroutine - def __call__( # type: ignore # Change signature from supertype + @gen.coroutine # type: ignore[misc] + def __call__( self, fn: "typing.Callable[..., typing.Union[typing.Generator[typing.Any, typing.Any, _RetValT], Future[_RetValT]]]", *args: typing.Any, @@ -49,7 +49,7 @@ def __call__( # type: ignore # Change signature from supertype try: result = yield fn(*args, **kwargs) except BaseException: # noqa: B902 - retry_state.set_exception(sys.exc_info()) + retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type] else: retry_state.set_result(result) elif isinstance(do, DoSleep): diff --git a/tenacity/wait.py b/tenacity/wait.py index 21d40cb1..0e4d78f1 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -36,7 +36,7 @@ def __add__(self, other: "wait_base") -> "wait_combine": def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_base"]: # make it possible to use multiple waits with the built-in sum function - if other == 0: + if other == 0: # type: ignore[comparison-overlap] return self return self.__add__(other) diff --git a/tests/test_after.py b/tests/test_after.py index d98e3095..6d7db45a 100644 --- a/tests/test_after.py +++ b/tests/test_after.py @@ -2,8 +2,8 @@ import random import unittest.mock -from tenacity import after_log from tenacity import _utils # noqa +from tenacity import after_log from . import test_tenacity @@ -24,9 +24,10 @@ def test_01_default(self): retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) fun = after_log(logger=logger, log_level=self.log_level) # use default sec_format fun(retry_state) + fn_name = "" if retry_state.fn is None else _utils.get_callback_name(retry_state.fn) log.assert_called_once_with( self.log_level, - f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"Finished call to '{fn_name}' " f"after {sec_format % retry_state.seconds_since_start}(s), " f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) @@ -42,9 +43,10 @@ def test_02_custom_sec_format(self): retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) fun = after_log(logger=logger, log_level=self.log_level, sec_format=sec_format) fun(retry_state) + fn_name = "" if retry_state.fn is None else _utils.get_callback_name(retry_state.fn) log.assert_called_once_with( self.log_level, - f"Finished call to '{_utils.get_callback_name(retry_state.fn)}' " + f"Finished call to '{fn_name}' " f"after {sec_format % retry_state.seconds_since_start}(s), " f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", ) diff --git a/tox.ini b/tox.ini index 5b7dd3f7..0e4fda13 100644 --- a/tox.ini +++ b/tox.ini @@ -31,6 +31,12 @@ deps = commands = black . +[testenv:mypy] +deps = + mypy +commands = + mypy tenacity + [testenv:black-ci] deps = black From f1aafdaf5e76c64979f8dc215213e5fe1b9b2994 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 16:43:49 +0100 Subject: [PATCH 052/124] ci: run mypy job --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bb94bd0a..d819c29d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,6 +34,8 @@ jobs: tox: pep8 - python: "3.11" tox: black-ci + - python: "3.11" + tox: mypy steps: - name: Checkout 🛎️ uses: actions/checkout@v3.3.0 From d9438b3a4080441904a2a2ea482a6d539f94cb5c Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 30 Jan 2023 16:49:14 +0100 Subject: [PATCH 053/124] fix(mypy): add missing typing (#384) Runtime callbacks for retry/stop/wait allow to pass a function or a class that inherit from retry_base/wait_base/stop_base. This fixes the typing when a function is used. Fixes #324 Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tenacity/__init__.py | 21 +++++++++++---------- tenacity/retry.py | 3 +++ tenacity/stop.py | 3 +++ tenacity/wait.py | 3 +++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index d110a78c..1f26ecdd 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -86,8 +86,9 @@ if t.TYPE_CHECKING: import types - from .wait import wait_base - from .stop import stop_base + from .retry import RetryBaseT + from .stop import StopBaseT + from .wait import WaitBaseT WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) @@ -217,9 +218,9 @@ class BaseRetrying(ABC): def __init__( self, sleep: t.Callable[[t.Union[int, float]], None] = sleep, - stop: "stop_base" = stop_never, - wait: "wait_base" = wait_none(), - retry: retry_base = retry_if_exception_type(), + stop: "StopBaseT" = stop_never, + wait: "WaitBaseT" = wait_none(), + retry: "RetryBaseT" = retry_if_exception_type(), before: t.Callable[["RetryCallState"], None] = before_nothing, after: t.Callable[["RetryCallState"], None] = after_nothing, before_sleep: t.Optional[t.Callable[["RetryCallState"], None]] = None, @@ -242,8 +243,8 @@ def __init__( def copy( self, sleep: t.Union[t.Callable[[t.Union[int, float]], None], object] = _unset, - stop: t.Union["stop_base", object] = _unset, - wait: t.Union["wait_base", object] = _unset, + stop: t.Union["StopBaseT", object] = _unset, + wait: t.Union["WaitBaseT", object] = _unset, retry: t.Union[retry_base, object] = _unset, before: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, after: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, @@ -337,14 +338,14 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A return DoAttempt() is_explicit_retry = fut.failed and isinstance(fut.exception(), TryAgain) - if not (is_explicit_retry or self.retry(retry_state=retry_state)): + if not (is_explicit_retry or self.retry(retry_state)): return fut.result() if self.after is not None: self.after(retry_state) self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start - if self.stop(retry_state=retry_state): + if self.stop(retry_state): if self.retry_error_callback: return self.retry_error_callback(retry_state) retry_exc = self.retry_error_cls(fut) @@ -353,7 +354,7 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A raise retry_exc from fut.exception() if self.wait: - sleep = self.wait(retry_state=retry_state) + sleep = self.wait(retry_state) else: sleep = 0.0 retry_state.next_action = RetryAction(sleep) diff --git a/tenacity/retry.py b/tenacity/retry.py index 73cb2684..765b6fe1 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -36,6 +36,9 @@ def __or__(self, other: "retry_base") -> "retry_any": return retry_any(self, other) +RetryBaseT = typing.Union[retry_base, typing.Callable[["RetryCallState"], bool]] + + class _retry_never(retry_base): """Retry strategy that never rejects any result.""" diff --git a/tenacity/stop.py b/tenacity/stop.py index 48b0a4ee..e6478606 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -38,6 +38,9 @@ def __or__(self, other: "stop_base") -> "stop_any": return stop_any(self, other) +StopBaseT = typing.Union[stop_base, typing.Callable[["RetryCallState"], bool]] + + class stop_any(stop_base): """Stop if any of the stop condition is valid.""" diff --git a/tenacity/wait.py b/tenacity/wait.py index 0e4d78f1..7a793b20 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -41,6 +41,9 @@ def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_bas return self.__add__(other) +WaitBaseT = typing.Union[wait_base, typing.Callable[["RetryCallState"], bool]] + + class wait_fixed(wait_base): """Wait strategy that waits a fixed amount of time between each retry.""" From 280201dca51894670b1c2866156aa5af7f530f9f Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 6 Feb 2023 10:19:42 +0100 Subject: [PATCH 054/124] Update index.rst --- doc/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index 47231b80..f012f464 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -6,7 +6,7 @@ Tenacity .. image:: https://circleci.com/gh/jd/tenacity.svg?style=svg :target: https://circleci.com/gh/jd/tenacity -.. image:: https://img.shields.io/endpoint.svg?url=https://dashboard.mergify.io/badges/jd/tenacity&style=flat +.. image:: https://img.shields.io/endpoint.svg?url=https://api.mergify.com/badges/jd/tenacity&style=flat :target: https://mergify.io :alt: Mergify Status From b93e4dca90cbadfc7db10c23253b4010b991b58f Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 6 Feb 2023 10:36:35 +0100 Subject: [PATCH 055/124] chore(pep8): update code for last black version (#388) --- tests/test_asyncio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 66948904..f6e9b0d8 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -88,7 +88,6 @@ def test_retry_attributes(self): @asynctest async def test_attempt_number_is_correct_for_interleaved_coroutines(self): - attempts = [] def after(retry_state): From 78c8d4bc8596af1143801076faa922f2f21c1bba Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Mon, 6 Feb 2023 11:29:32 +0100 Subject: [PATCH 056/124] fix: remove __iter__ from AsyncRetring (#387) This method doesn't make sense for AsyncRetrying. And it's even more vicious as: ``` for attempts in AsyncRetrying(): with attempts: await do_for_a_while() ``` will work, but sleep(XXX) coroutine will never be awaited and the call is retried instantly. This change removes the __iter__ method from AsyncRetrying. Co-authored-by: Julien Danjou --- releasenotes/notes/no-async-iter-6132a42e52348a75.yaml | 7 +++++++ tenacity/_asyncio.py | 3 +++ tests/test_asyncio.py | 10 ++++++++++ 3 files changed, 20 insertions(+) create mode 100644 releasenotes/notes/no-async-iter-6132a42e52348a75.yaml diff --git a/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml b/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml new file mode 100644 index 00000000..7a92c1b5 --- /dev/null +++ b/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `AsyncRetrying` was erroneously implementing `__iter__()`, making tenacity + retrying mechanism working but in a synchronous fashion and not waiting as + expected. This interface has been removed, `__aiter__()` should be used + instead. diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 0b0f8765..ab88d26b 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -62,6 +62,9 @@ async def __call__( # type: ignore[override] else: return do # type: ignore[no-any-return] + def __iter__(self) -> typing.Generator[AttemptManager, None, None]: + raise TypeError("AsyncRetrying object is not iterable") + def __aiter__(self) -> "AsyncRetrying": self.begin() self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index f6e9b0d8..66096beb 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -18,6 +18,8 @@ import unittest from functools import wraps +import pytest + from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio from tenacity import retry, retry_if_result, stop_after_attempt @@ -169,6 +171,14 @@ async def test(): self.assertEqual(3, result) + @asynctest + async def test_async_retying_iterator(self): + thing = NoIOErrorAfterCount(5) + with pytest.raises(TypeError): + for attempts in AsyncRetrying(): + with attempts: + await _async_function(thing) + if __name__ == "__main__": unittest.main() From b49eb370573626abd5ddb5dc03228c503079be59 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Thu, 9 Feb 2023 17:58:03 +0100 Subject: [PATCH 057/124] chore(typing): improve typing of WrappedFn (#390) This change improves the typing of WrappedFn. It makes explictly the two signatures of tenacity.retry() with overload. This avoids mypy thinking the return type is `` --- tenacity/__init__.py | 97 +++++++++++++++++++++++++++++--------------- tenacity/_asyncio.py | 24 +++++------ tox.ini | 2 +- 3 files changed, 76 insertions(+), 47 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 1f26ecdd..67312809 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -16,6 +16,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import functools import sys import threading @@ -91,37 +92,8 @@ from .wait import WaitBaseT +WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) -_RetValT = t.TypeVar("_RetValT") - - -def retry(*dargs: t.Any, **dkw: t.Any) -> t.Union[WrappedFn, t.Callable[[WrappedFn], WrappedFn]]: # noqa - """Wrap a function with a new `Retrying` object. - - :param dargs: positional arguments passed to Retrying object - :param dkw: keyword arguments passed to the Retrying object - """ - # support both @retry and @retry() as valid syntax - if len(dargs) == 1 and callable(dargs[0]): - return retry()(dargs[0]) - else: - - def wrap(f: WrappedFn) -> WrappedFn: - if isinstance(f, retry_base): - warnings.warn( - f"Got retry_base instance ({f.__class__.__name__}) as callable argument, " - f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" - ) - if iscoroutinefunction(f): - r: "BaseRetrying" = AsyncRetrying(*dargs, **dkw) - elif tornado and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f): - r = TornadoRetrying(*dargs, **dkw) - else: - r = Retrying(*dargs, **dkw) - - return r.wraps(f) - - return wrap class TryAgain(Exception): @@ -382,14 +354,24 @@ def __iter__(self) -> t.Generator[AttemptManager, None, None]: break @abstractmethod - def __call__(self, fn: t.Callable[..., _RetValT], *args: t.Any, **kwargs: t.Any) -> _RetValT: + def __call__( + self, + fn: t.Callable[..., WrappedFnReturnT], + *args: t.Any, + **kwargs: t.Any, + ) -> WrappedFnReturnT: pass class Retrying(BaseRetrying): """Retrying controller.""" - def __call__(self, fn: t.Callable[..., _RetValT], *args: t.Any, **kwargs: t.Any) -> _RetValT: + def __call__( + self, + fn: t.Callable[..., WrappedFnReturnT], + *args: t.Any, + **kwargs: t.Any, + ) -> WrappedFnReturnT: self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) @@ -510,6 +492,57 @@ def __repr__(self) -> str: return f"<{clsname} {id(self)}: attempt #{self.attempt_number}; slept for {slept}; last result: {result}>" +@t.overload +def retry(func: WrappedFn) -> WrappedFn: + ... + + +@t.overload +def retry( + sleep: t.Callable[[t.Union[int, float]], None] = sleep, + stop: "StopBaseT" = stop_never, + wait: "WaitBaseT" = wait_none(), + retry: "RetryBaseT" = retry_if_exception_type(), + before: t.Callable[["RetryCallState"], None] = before_nothing, + after: t.Callable[["RetryCallState"], None] = after_nothing, + before_sleep: t.Optional[t.Callable[["RetryCallState"], None]] = None, + reraise: bool = False, + retry_error_cls: t.Type["RetryError"] = RetryError, + retry_error_callback: t.Optional[t.Callable[["RetryCallState"], t.Any]] = None, +) -> t.Callable[[WrappedFn], WrappedFn]: + ... + + +def retry(*dargs: t.Any, **dkw: t.Any) -> t.Any: + """Wrap a function with a new `Retrying` object. + + :param dargs: positional arguments passed to Retrying object + :param dkw: keyword arguments passed to the Retrying object + """ + # support both @retry and @retry() as valid syntax + if len(dargs) == 1 and callable(dargs[0]): + return retry()(dargs[0]) + else: + + def wrap(f: WrappedFn) -> WrappedFn: + if isinstance(f, retry_base): + warnings.warn( + f"Got retry_base instance ({f.__class__.__name__}) as callable argument, " + f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" + ) + r: "BaseRetrying" + if iscoroutinefunction(f): + r = AsyncRetrying(*dargs, **dkw) + elif tornado and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f): + r = TornadoRetrying(*dargs, **dkw) + else: + r = Retrying(*dargs, **dkw) + + return r.wraps(f) + + return wrap + + from tenacity._asyncio import AsyncRetrying # noqa:E402,I100 if tornado: diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index ab88d26b..9e10c072 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -17,7 +17,7 @@ import functools import sys -import typing +import typing as t from asyncio import sleep from tenacity import AttemptManager @@ -26,24 +26,20 @@ from tenacity import DoSleep from tenacity import RetryCallState - -WrappedFn = typing.TypeVar("WrappedFn", bound=typing.Callable[..., typing.Any]) -_RetValT = typing.TypeVar("_RetValT") +WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") +WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) class AsyncRetrying(BaseRetrying): - def __init__( - self, sleep: typing.Callable[[float], typing.Awaitable[typing.Any]] = sleep, **kwargs: typing.Any - ) -> None: + sleep: t.Callable[[float], t.Awaitable[t.Any]] + + def __init__(self, sleep: t.Callable[[float], t.Awaitable[t.Any]] = sleep, **kwargs: t.Any) -> None: super().__init__(**kwargs) self.sleep = sleep async def __call__( # type: ignore[override] - self, - fn: typing.Callable[..., typing.Awaitable[_RetValT]], - *args: typing.Any, - **kwargs: typing.Any, - ) -> _RetValT: + self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any + ) -> WrappedFnReturnT: self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) @@ -62,7 +58,7 @@ async def __call__( # type: ignore[override] else: return do # type: ignore[no-any-return] - def __iter__(self) -> typing.Generator[AttemptManager, None, None]: + def __iter__(self) -> t.Generator[AttemptManager, None, None]: raise TypeError("AsyncRetrying object is not iterable") def __aiter__(self) -> "AsyncRetrying": @@ -88,7 +84,7 @@ def wraps(self, fn: WrappedFn) -> WrappedFn: # Ensure wrapper is recognized as a coroutine function. @functools.wraps(fn) - async def async_wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: return await fn(*args, **kwargs) # Preserve attributes diff --git a/tox.ini b/tox.ini index 0e4fda13..6a5c81c9 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,7 @@ commands = [testenv:mypy] deps = - mypy + mypy>=1.0.0 commands = mypy tenacity From 3cf1c9064af203999c94822adaf1b7eafb7b0bda Mon Sep 17 00:00:00 2001 From: hyunyoungjin <48899038+salmon131@users.noreply.github.com> Date: Thu, 23 Feb 2023 01:17:58 +0900 Subject: [PATCH 058/124] Add `retry_if_exception_cause_type` and `wait_exponential_jitter` to __all__ (#393) * add: omitted modules to __all__ * add release notes * Update releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml --------- Co-authored-by: Julien Danjou --- .../add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml | 3 +++ tenacity/__init__.py | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml diff --git a/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml b/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml new file mode 100644 index 00000000..cfcae1f6 --- /dev/null +++ b/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml @@ -0,0 +1,3 @@ +--- +other: + - Add `retry_if_exception_cause_type`and `wait_exponential_jitter` to __all__ of init.py \ No newline at end of file diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 67312809..1f90ccd0 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -556,6 +556,7 @@ def wrap(f: WrappedFn) -> WrappedFn: "retry_any", "retry_if_exception", "retry_if_exception_type", + "retry_if_exception_cause_type", "retry_if_not_exception_type", "retry_if_not_result", "retry_if_result", @@ -580,6 +581,7 @@ def wrap(f: WrappedFn) -> WrappedFn: "wait_random", "wait_random_exponential", "wait_full_jitter", + "wait_exponential_jitter", "before_log", "before_nothing", "after_log", From 548c5d490187af6f339cbffdd0add38aecc3ecb0 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Tue, 28 Feb 2023 17:30:09 +0400 Subject: [PATCH 059/124] better wait.WaitBaseT annotation (#392) * better wait.WaitBaseT annotation fixes https://github.com/jd/tenacity/issues/391 * add release notes * fix release note --------- Co-authored-by: dmitrii-sorokin-cndt --- releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml | 5 +++++ tenacity/wait.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml diff --git a/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml b/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml new file mode 100644 index 00000000..3b560c3b --- /dev/null +++ b/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Argument `wait` was improperly annotated, making mypy checks fail. + Now it's annotated as `typing.Union[wait_base, typing.Callable[["RetryCallState"], typing.Union[float, int]]]` diff --git a/tenacity/wait.py b/tenacity/wait.py index 7a793b20..e1e2fe48 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -41,7 +41,7 @@ def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_bas return self.__add__(other) -WaitBaseT = typing.Union[wait_base, typing.Callable[["RetryCallState"], bool]] +WaitBaseT = typing.Union[wait_base, typing.Callable[["RetryCallState"], typing.Union[float, int]]] class wait_fixed(wait_base): From 96938df70d6210ad59a0b79c0bb8e967463b0a03 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 20 Mar 2023 15:22:18 +0100 Subject: [PATCH 060/124] chore: remove support for Python 3.6 --- .github/workflows/ci.yaml | 2 -- .mergify.yml | 1 - pyproject.toml | 2 +- releasenotes/notes/remove-py36-876c0416cf279d15.yaml | 4 ++++ setup.cfg | 3 +-- tox.ini | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/remove-py36-876c0416cf279d15.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d819c29d..a46cd1ba 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,8 +18,6 @@ jobs: strategy: matrix: include: - - python: "3.6" - tox: py36 - python: "3.7" tox: py37 - python: "3.8" diff --git a/.mergify.yml b/.mergify.yml index f74465d1..4972b726 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,7 +1,6 @@ queue_rules: - name: default conditions: &CheckRuns - - "check-success=test (3.6, py36)" - "check-success=test (3.7, py37)" - "check-success=test (3.8, py38)" - "check-success=test (3.9, py39)" diff --git a/pyproject.toml b/pyproject.toml index 41ffe726..fc434e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ build-backend="setuptools.build_meta" [tool.black] line-length = 120 safe = true -target-version = ["py36", "py37", "py38", "py39", "py310", "py311"] +target-version = ["py37", "py38", "py39", "py310", "py311"] [tool.mypy] strict = true diff --git a/releasenotes/notes/remove-py36-876c0416cf279d15.yaml b/releasenotes/notes/remove-py36-876c0416cf279d15.yaml new file mode 100644 index 00000000..bfb1164d --- /dev/null +++ b/releasenotes/notes/remove-py36-876c0416cf279d15.yaml @@ -0,0 +1,4 @@ +--- +upgrade: + - | + Support for Python 3.6 has been removed. diff --git a/setup.cfg b/setup.cfg index b89917cb..ae14bf7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,6 @@ classifier = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -23,7 +22,7 @@ classifier = [options] install_requires = -python_requires = >=3.6 +python_requires = >=3.7 packages = tenacity [options.packages.find] diff --git a/tox.ini b/tox.ini index 6a5c81c9..0fa96fce 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{6,7,8,9,10,11}, pep8, pypy3 +envlist = py3{7,8,9,10,11}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -10,9 +10,9 @@ deps = pytest typeguard commands = - py3{6,7,8,9,10,11},pypy3: pytest {posargs} - py3{6,7,8,9,10,11},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{6,7,8,9,10,11},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{7,8,9,10,11},pypy3: pytest {posargs} + py3{7,8,9,10,11},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{7,8,9,10,11},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 433324956abb028f6d993195d31e4dd8308115c3 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Mon, 20 Mar 2023 22:32:03 +0800 Subject: [PATCH 061/124] Fix tests for typeguard 3.x (#394) typeguard 3.x introduced an incompatible change to `check_type()`'s signature. Co-authored-by: Julien Danjou --- .../Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml | 3 +++ tests/test_tenacity.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml diff --git a/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml b/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml new file mode 100644 index 00000000..9688c3d4 --- /dev/null +++ b/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml @@ -0,0 +1,3 @@ +--- +fixes: + - "Fixes test failures with typeguard 3.x" diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 82806a69..b646e23c 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1542,10 +1542,10 @@ def num_to_str(number): with_constructor_result = with_raw(1) # These raise TypeError exceptions if they fail - check_type("with_raw", with_raw, typing.Callable[[int], str]) - check_type("with_raw_result", with_raw_result, str) - check_type("with_constructor", with_constructor, typing.Callable[[int], str]) - check_type("with_constructor_result", with_constructor_result, str) + check_type(with_raw, typing.Callable[[int], str]) + check_type(with_raw_result, str) + check_type(with_constructor, typing.Callable[[int], str]) + check_type(with_constructor_result, str) @contextmanager From aa6f8f0a2428de696b237d1a86bc131c1cdb707a Mon Sep 17 00:00:00 2001 From: Kushal Gajurel Date: Wed, 12 Jul 2023 14:59:31 +0545 Subject: [PATCH 062/124] Added the link to documentation for a better experience (#409) * Added a link to the documentation, as code snippets are not being rendered properly * Added Changelog * Changed the branch name from master to main * changes added to changelog * Simplified the documentation LINK --------- Co-authored-by: Kushal Gajurel --- doc/source/index.rst | 4 +++- .../added_a_link_to_documentation-eefaf8f074b539f8.yaml | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index f012f464..ce586f9e 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -10,6 +10,8 @@ Tenacity :target: https://mergify.io :alt: Mergify Status +**Please refer to the** `tenacity documentation `_ **for a better experience.** + Tenacity is an Apache 2.0 licensed general-purpose retrying library, written in Python, to simplify the task of adding retry behavior to just about anything. It originates from `a fork of retrying @@ -622,7 +624,7 @@ Contribute #. Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug. #. Fork `the repository`_ on GitHub to start making your changes to the - **master** branch (or branch off of it). + **main** branch (or branch off of it). #. Write a test which shows that the bug was fixed or that the feature works as expected. #. Add a `changelog <#Changelogs>`_ diff --git a/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml b/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml new file mode 100644 index 00000000..381ddc4a --- /dev/null +++ b/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml @@ -0,0 +1,5 @@ +--- +other: + - | + Added a link to the documentation, as code snippets are not being rendered properly + Changed branch name to main in index.rst From 41ed2420cda8ab7650a39900451099f4730266c3 Mon Sep 17 00:00:00 2001 From: John Litborn <11260241+jakkdl@users.noreply.github.com> Date: Mon, 14 Aug 2023 15:21:18 +0200 Subject: [PATCH 063/124] Add support for async sleep functions in tenacity.retry annotation. Fixes https://github.com/jd/tenacity/issues/399 (#400) --- tenacity/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 1f90ccd0..ba8011be 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -499,7 +499,7 @@ def retry(func: WrappedFn) -> WrappedFn: @t.overload def retry( - sleep: t.Callable[[t.Union[int, float]], None] = sleep, + sleep: t.Callable[[t.Union[int, float]], t.Optional[t.Awaitable[None]]] = sleep, stop: "StopBaseT" = stop_never, wait: "WaitBaseT" = wait_none(), retry: "RetryBaseT" = retry_if_exception_type(), From 703fe3c41a65c07de93d530dbfe9e47f4ce4b8e6 Mon Sep 17 00:00:00 2001 From: John Litborn <11260241+jakkdl@users.noreply.github.com> Date: Mon, 21 Aug 2023 09:04:30 +0200 Subject: [PATCH 064/124] typecheck tests, although most all errors are ignored. (#401) --- pyproject.toml | 2 +- tests/test_after.py | 1 + tests/test_asyncio.py | 23 ++++++++++++++++++++--- tests/test_tenacity.py | 1 + tests/test_tornado.py | 3 ++- tox.ini | 5 +++-- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc434e9f..254d805f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ target-version = ["py37", "py38", "py39", "py310", "py311"] [tool.mypy] strict = true -files = ["tenacity"] +files = ["tenacity", "tests"] show_error_codes = true [[tool.mypy.overrides]] diff --git a/tests/test_after.py b/tests/test_after.py index 6d7db45a..32117033 100644 --- a/tests/test_after.py +++ b/tests/test_after.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="no-untyped-def,no-untyped-call" import logging import random import unittest.mock diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 66096beb..6fdcdefb 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="no-untyped-def,no-untyped-call" # coding: utf-8 # Copyright 2016 Étienne Bersac # @@ -99,8 +100,8 @@ def after(retry_state): thing2 = NoIOErrorAfterCount(3) await asyncio.gather( - _retryable_coroutine.retry_with(after=after)(thing1), - _retryable_coroutine.retry_with(after=after)(thing2), + _retryable_coroutine.retry_with(after=after)(thing1), # type: ignore[attr-defined] + _retryable_coroutine.retry_with(after=after)(thing2), # type: ignore[attr-defined] ) # There's no waiting on retry, only a wait in the coroutine, so the @@ -161,7 +162,12 @@ async def test_sleeps(self): async def test_retry_with_result(self): async def test(): attempts = 0 - async for attempt in tasyncio.AsyncRetrying(retry=retry_if_result(lambda x: x < 3)): + + # mypy doesn't have great lambda support + def lt_3(x: float) -> bool: + return x < 3 + + async for attempt in tasyncio.AsyncRetrying(retry=retry_if_result(lt_3)): with attempt: attempts += 1 attempt.retry_state.set_result(attempts) @@ -180,5 +186,16 @@ async def test_async_retying_iterator(self): await _async_function(thing) +# make sure mypy accepts passing an async sleep function +# https://github.com/jd/tenacity/issues/399 +async def my_async_sleep(x: float) -> None: + await asyncio.sleep(x) + + +@retry(sleep=my_async_sleep) +async def foo(): + pass + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b646e23c..203d2bae 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1,3 +1,4 @@ +# mypy: disable_error_code="no-untyped-def,no-untyped-call,attr-defined,arg-type,no-any-return,list-item,var-annotated,import,call-overload" # Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013 Ray Holder diff --git a/tests/test_tornado.py b/tests/test_tornado.py index ec5a3fd5..787e97e7 100644 --- a/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="no-untyped-def,no-untyped-call" # coding: utf-8 # Copyright 2017 Elisey Zanko # @@ -38,7 +39,7 @@ def _retryable_coroutine_with_2_attempts(thing): thing.go() -class TestTornado(testing.AsyncTestCase): +class TestTornado(testing.AsyncTestCase): # type: ignore[misc] @testing.gen_test def test_retry(self): assert gen.is_coroutine_function(_retryable_coroutine) diff --git a/tox.ini b/tox.ini index 0fa96fce..acb0d9ac 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,7 @@ deps = flake8 flake8-docstrings flake8-rst-docstrings flake8-logging-format -commands = flake8 +commands = flake8 {posargs} [testenv:black] deps = @@ -34,8 +34,9 @@ commands = [testenv:mypy] deps = mypy>=1.0.0 + pytest # for stubs commands = - mypy tenacity + mypy {posargs} [testenv:black-ci] deps = From a29f494197aec1f28fdb024599afd79f77c58c4b Mon Sep 17 00:00:00 2001 From: Carl George Date: Mon, 21 Aug 2023 03:06:50 -0400 Subject: [PATCH 065/124] Add a "test" extra (#357) Similar to the existing "doc" extra, specify a "test" extra with the relevant dependencies. Since tornado is needed for the tests, move it from doc to test. This will help distributions that package tenacity to gather the test dependencies without the doc dependencies. --- releasenotes/notes/add-test-extra-55e869261b03e56d.yaml | 3 +++ setup.cfg | 3 +++ tox.ini | 3 +-- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/add-test-extra-55e869261b03e56d.yaml diff --git a/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml b/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml new file mode 100644 index 00000000..968da1f3 --- /dev/null +++ b/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml @@ -0,0 +1,3 @@ +--- +other: + - Add a \"test\" extra diff --git a/setup.cfg b/setup.cfg index ae14bf7b..a6b5289d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,7 +35,10 @@ tenacity = py.typed doc = reno sphinx +test = + pytest tornado>=4.5 + typeguard [tool:pytest] filterwarnings = diff --git a/tox.ini b/tox.ini index acb0d9ac..1ec70515 100644 --- a/tox.ini +++ b/tox.ini @@ -6,9 +6,8 @@ skip_missing_interpreters = True usedevelop = True sitepackages = False deps = + .[test] .[doc] - pytest - typeguard commands = py3{7,8,9,10,11},pypy3: pytest {posargs} py3{7,8,9,10,11},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build From 310058274ed22a345e9c3917c97b4afd6363d5a5 Mon Sep 17 00:00:00 2001 From: Michael Noronha Date: Mon, 21 Aug 2023 00:09:39 -0700 Subject: [PATCH 066/124] Preserve function default and kwdefault through retry decorator (#406) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- ...ug-for-preserve-defaults-86682846dfa18005.yaml | 4 ++++ tenacity/__init__.py | 2 +- tenacity/_asyncio.py | 2 +- tests/test_asyncio.py | 15 +++++++++++++++ tests/test_tenacity.py | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml diff --git a/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml b/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml new file mode 100644 index 00000000..617953cf --- /dev/null +++ b/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Preserve __defaults__ and __kwdefaults__ through retry decorator diff --git a/tenacity/__init__.py b/tenacity/__init__.py index ba8011be..9a5fa41a 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -284,7 +284,7 @@ def wraps(self, f: WrappedFn) -> WrappedFn: :param f: A function to wraps for retrying. """ - @functools.wraps(f) + @functools.wraps(f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")) def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: return self(f, *args, **kw) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 9e10c072..d901cbd1 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -83,7 +83,7 @@ def wraps(self, fn: WrappedFn) -> WrappedFn: fn = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. - @functools.wraps(fn) + @functools.wraps(fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")) async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: return await fn(*args, **kwargs) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 6fdcdefb..078100f0 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -21,6 +21,7 @@ import pytest +import tenacity from tenacity import AsyncRetrying, RetryError from tenacity import _asyncio as tasyncio from tenacity import retry, retry_if_result, stop_after_attempt @@ -89,6 +90,20 @@ def test_retry_attributes(self): assert hasattr(_retryable_coroutine, "retry") assert hasattr(_retryable_coroutine, "retry_with") + def test_retry_preserves_argument_defaults(self): + async def function_with_defaults(a=1): + return a + + async def function_with_kwdefaults(*, a=1): + return a + + retrying = AsyncRetrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) + wrapped_defaults_function = retrying.wraps(function_with_defaults) + wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) + + self.assertEqual(function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__) + self.assertEqual(function_with_kwdefaults.__kwdefaults__, wrapped_kwdefaults_function.__kwdefaults__) + @asynctest async def test_attempt_number_is_correct_for_interleaved_coroutines(self): attempts = [] diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 203d2bae..966060ed 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1054,6 +1054,20 @@ def test_retry_if_exception_cause_type(self): except NameError: pass + def test_retry_preserves_argument_defaults(self): + def function_with_defaults(a=1): + return a + + def function_with_kwdefaults(*, a=1): + return a + + retrying = Retrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) + wrapped_defaults_function = retrying.wraps(function_with_defaults) + wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) + + self.assertEqual(function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__) + self.assertEqual(function_with_kwdefaults.__kwdefaults__, wrapped_kwdefaults_function.__kwdefaults__) + def test_defaults(self): self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5))) From 09ccacc51c6cbaf0cb3f7d12e0646c95ccc6e177 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 15 Sep 2023 22:38:34 +0300 Subject: [PATCH 067/124] Add support for Python 3.12 --- .github/workflows/ci.yaml | 10 +++++++--- .github/workflows/deploy.yaml | 4 ++-- .mergify.yml | 1 + pyproject.toml | 2 +- setup.cfg | 1 + tox.ini | 8 ++++---- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a46cd1ba..f062d744 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,6 +2,7 @@ name: Continuous Integration permissions: read-all on: + push: pull_request: branches: - main @@ -34,16 +35,19 @@ jobs: tox: black-ci - python: "3.11" tox: mypy + - python: "3.12" + tox: py312 steps: - name: Checkout 🛎️ - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4.0.0 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.7.0 with: - python-version: ${{ matrix.python }} + python-version: ${{ matrix.python }} + allow-prereleases: true - name: Build 🔧 & Test 🔍 run: | diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index a310733b..59ec0dd1 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout 🛎️ - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4.0.0 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.7.0 with: python-version: 3.11 diff --git a/.mergify.yml b/.mergify.yml index 4972b726..2fdedb9d 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -6,6 +6,7 @@ queue_rules: - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" + - "check-success=test (3.12, py312)" - "check-success=test (3.11, black-ci)" - "check-success=test (3.11, pep8)" - "check-success=test (3.11, mypy)" diff --git a/pyproject.toml b/pyproject.toml index 254d805f..60075331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ build-backend="setuptools.build_meta" [tool.black] line-length = 120 safe = true -target-version = ["py37", "py38", "py39", "py310", "py311"] +target-version = ["py37", "py38", "py39", "py310", "py311", "py312"] [tool.mypy] strict = true diff --git a/setup.cfg b/setup.cfg index a6b5289d..cd273cda 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifier = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Utilities [options] diff --git a/tox.ini b/tox.ini index 1ec70515..ce46835c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{7,8,9,10,11}, pep8, pypy3 +envlist = py3{7,8,9,10,11,12}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -9,9 +9,9 @@ deps = .[test] .[doc] commands = - py3{7,8,9,10,11},pypy3: pytest {posargs} - py3{7,8,9,10,11},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{7,8,9,10,11},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{7,8,9,10,11,12},pypy3: pytest {posargs} + py3{7,8,9,10,11,12},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{7,8,9,10,11,12},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 5c54fa0a8f58430d412cfca41c835a19bae28c55 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 15 Sep 2023 22:41:13 +0300 Subject: [PATCH 068/124] Only deploy for upstream --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 59ec0dd1..eb71854c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -6,6 +6,7 @@ on: jobs: test: + if: github.repository_owner == 'jd' timeout-minutes: 20 runs-on: ubuntu-20.04 steps: From 88807635e5502561c7767db2353d5ce1d63f67fa Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 15 Sep 2023 22:48:06 +0300 Subject: [PATCH 069/124] Drop support for EOL Python 3.7 --- .github/workflows/ci.yaml | 2 -- .mergify.yml | 1 - pyproject.toml | 2 +- setup.cfg | 3 +-- tox.ini | 8 ++++---- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f062d744..d06aeeca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,8 +19,6 @@ jobs: strategy: matrix: include: - - python: "3.7" - tox: py37 - python: "3.8" tox: py38 - python: "3.9" diff --git a/.mergify.yml b/.mergify.yml index 2fdedb9d..003f217b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,7 +1,6 @@ queue_rules: - name: default conditions: &CheckRuns - - "check-success=test (3.7, py37)" - "check-success=test (3.8, py38)" - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" diff --git a/pyproject.toml b/pyproject.toml index 60075331..a293a6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ build-backend="setuptools.build_meta" [tool.black] line-length = 120 safe = true -target-version = ["py37", "py38", "py39", "py310", "py311", "py312"] +target-version = ["py38", "py39", "py310", "py311", "py312"] [tool.mypy] strict = true diff --git a/setup.cfg b/setup.cfg index cd273cda..e6d921a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,6 @@ classifier = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -23,7 +22,7 @@ classifier = [options] install_requires = -python_requires = >=3.7 +python_requires = >=3.8 packages = tenacity [options.packages.find] diff --git a/tox.ini b/tox.ini index ce46835c..108c6e29 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{7,8,9,10,11,12}, pep8, pypy3 +envlist = py3{8,9,10,11,12}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -9,9 +9,9 @@ deps = .[test] .[doc] commands = - py3{7,8,9,10,11,12},pypy3: pytest {posargs} - py3{7,8,9,10,11,12},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{7,8,9,10,11,12},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{8,9,10,11,12},pypy3: pytest {posargs} + py3{8,9,10,11,12},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{8,9,10,11,12},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 1843b25f2d7211a5ea1e8597e527676ceec572e5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 15 Sep 2023 22:51:40 +0300 Subject: [PATCH 070/124] Upgrade Python syntax with pyupgrade --py38-plus --- tests/test_asyncio.py | 1 - tests/test_tenacity.py | 16 ++++++++-------- tests/test_tornado.py | 1 - 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 078100f0..542f540d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,5 +1,4 @@ # mypy: disable-error-code="no-untyped-def,no-untyped-call" -# coding: utf-8 # Copyright 2016 Étienne Bersac # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 966060ed..0e54bc1e 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -657,7 +657,7 @@ def go(self): """ if self.counter < self.count: self.counter += 1 - raise IOError("Hi there, I'm an IOError") + raise OSError("Hi there, I'm an IOError") return True @@ -699,7 +699,7 @@ def go(self): try: self.go2() except NameError as e: - raise IOError() from e + raise OSError() from e return True @@ -712,7 +712,7 @@ def __init__(self, count): self.count = count def go2(self): - raise IOError("Hi there, I'm an IOError") + raise OSError("Hi there, I'm an IOError") def go(self): """Raise a NameError with an IOError as cause until after count threshold has been crossed. @@ -723,7 +723,7 @@ def go(self): self.counter += 1 try: self.go2() - except IOError as e: + except OSError as e: raise NameError() from e return True @@ -764,7 +764,7 @@ def go(self): if self.counter < self.count: self.counter += 1 return True - raise IOError("Hi there, I'm an IOError") + raise OSError("Hi there, I'm an IOError") class CustomError(Exception): @@ -947,7 +947,7 @@ def test_with_stop_on_exception(self): try: _retryable_test_with_stop(NoIOErrorAfterCount(5)) self.fail("Expected IOError") - except IOError as re: + except OSError as re: self.assertTrue(isinstance(re, IOError)) print(re) @@ -976,7 +976,7 @@ def test_retry_except_exception_of_type(self): try: _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5)) self.fail("Expected IOError") - except IOError as err: + except OSError as err: self.assertTrue(isinstance(err, IOError)) print(err) @@ -1114,7 +1114,7 @@ def _retryable(): def test_retry_error_callback_should_be_preserved(self): def return_text(retry_state): - return "Calling %s keeps raising errors after %s attempts" % ( + return "Calling {} keeps raising errors after {} attempts".format( retry_state.fn.__name__, retry_state.attempt_number, ) diff --git a/tests/test_tornado.py b/tests/test_tornado.py index 787e97e7..24858cff 100644 --- a/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -1,5 +1,4 @@ # mypy: disable-error-code="no-untyped-def,no-untyped-call" -# coding: utf-8 # Copyright 2017 Elisey Zanko # # Licensed under the Apache License, Version 2.0 (the "License"); From f3d1909528b9ee95c79c556cc3380c248bffe49f Mon Sep 17 00:00:00 2001 From: Andy Freeland Date: Tue, 14 Nov 2023 21:37:42 +0900 Subject: [PATCH 071/124] Add `RetryCallState` to the API docs (#419) * Add `RetryCallState` to the API docs User code can access the `RetryCallState` so its members should be documented. * :noindex: `tenacity.RetryCallState` in the README * Move `RetryCallState` docs to the API docs where hopefully they render on RTD --- doc/source/api.rst | 3 +++ doc/source/index.rst | 50 +++++++------------------------------------- 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/doc/source/api.rst b/doc/source/api.rst index 1cf7dc75..7a80c4e7 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -17,6 +17,9 @@ Retry Main API .. autoclass:: tenacity.tornadoweb.TornadoRetrying :members: +.. autoclass:: tenacity.RetryCallState + :members: + After Functions --------------- diff --git a/doc/source/index.rst b/doc/source/index.rst index ce586f9e..804e6074 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -402,43 +402,7 @@ without raising an exception (or you can re-raise or do anything really) RetryCallState ~~~~~~~~~~~~~~ -``retry_state`` argument is an object of `RetryCallState` class: - -.. autoclass:: tenacity.RetryCallState - - Constant attributes: - - .. autoattribute:: start_time(float) - :annotation: - - .. autoattribute:: retry_object(BaseRetrying) - :annotation: - - .. autoattribute:: fn(callable) - :annotation: - - .. autoattribute:: args(tuple) - :annotation: - - .. autoattribute:: kwargs(dict) - :annotation: - - Variable attributes: - - .. autoattribute:: attempt_number(int) - :annotation: - - .. autoattribute:: outcome(tenacity.Future or None) - :annotation: - - .. autoattribute:: outcome_timestamp(float or None) - :annotation: - - .. autoattribute:: idle_for(float) - :annotation: - - .. autoattribute:: next_action(tenacity.RetryAction or None) - :annotation: +``retry_state`` argument is an object of :class:`~tenacity.RetryCallState` class. Other Custom Callbacks ~~~~~~~~~~~~~~~~~~~~~~ @@ -447,33 +411,33 @@ It's also possible to define custom callbacks for other keyword arguments. .. function:: my_stop(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation :return: whether or not retrying should stop :rtype: bool .. function:: my_wait(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation :return: number of seconds to wait before next retry :rtype: float .. function:: my_retry(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation :return: whether or not retrying should continue :rtype: bool .. function:: my_before(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation .. function:: my_after(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation .. function:: my_before_sleep(retry_state) - :param RetryState retry_state: info about current retry invocation + :param RetryCallState retry_state: info about current retry invocation Here's an example with a custom ``before_sleep`` function: From ebee81d31db13e95b3679bf0eebf19342dffdb9f Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sun, 3 Dec 2023 15:41:44 -0500 Subject: [PATCH 072/124] typing: update version compare to support pyright/pylance (#424) The current version comparison works with mypy but pyright doesn't handle it. Comparing against minor and major version allows it to work with pyright. related: https://github.com/microsoft/pyright/issues/6639 --- tenacity/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 9a5fa41a..b38898c3 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -391,7 +391,7 @@ def __call__( return do # type: ignore[no-any-return] -if sys.version_info[1] >= 9: +if sys.version_info >= (3, 9): FutureGenericT = futures.Future[t.Any] else: FutureGenericT = futures.Future From 99e7482e8b027f0521315e0f7830a0badae5c561 Mon Sep 17 00:00:00 2001 From: Chris Miller Date: Mon, 18 Dec 2023 03:20:45 -0500 Subject: [PATCH 073/124] Add ability to inspect upcoming sleep in `stop` funcs, and add `stop_before_delay` (#423) * Add upcoming_sleep to retry_state, and add stop_before_delay stop. * Add unit test to cover stop_before_delay. * Changelog. * Update docs for stop_before_delay. * More docs for the two stop_x_delay functions. * Add test to ensure it acts the same as stop_after_delay when upcoming sleep is 0. * Linter fixups. --------- Co-authored-by: Julien Danjou --- doc/source/index.rst | 10 +++++++ ...dd-stop-before-delay-a775f88ac872c923.yaml | 7 +++++ tenacity/__init__.py | 15 ++++++++--- tenacity/stop.py | 26 ++++++++++++++++++- tests/test_tenacity.py | 18 ++++++++++++- 5 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index 804e6074..bdf7ff21 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -124,6 +124,16 @@ 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. + +.. testcode:: + + @retry(stop=stop_before_delay(10)) + def stop_before_10_s(): + print("Stopping 1 attempt before 10 seconds") + raise Exception + You can combine several stop conditions by using the `|` operator: .. testcode:: diff --git a/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml b/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml new file mode 100644 index 00000000..85df4c3d --- /dev/null +++ b/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Added a new stop function: stop_before_delay, which will stop execution + if the next sleep time would cause overall delay to exceed the specified delay. + Useful for use cases where you have some upper bound on retry times that you must + not exceed, so returning before that timeout is preferable than returning after that timeout. \ No newline at end of file diff --git a/tenacity/__init__.py b/tenacity/__init__.py index b38898c3..bd60556b 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -50,6 +50,7 @@ # Import all built-in stop strategies for easier usage. from .stop import stop_after_attempt # noqa from .stop import stop_after_delay # noqa +from .stop import stop_before_delay # noqa from .stop import stop_all # noqa from .stop import stop_any # noqa from .stop import stop_never # noqa @@ -316,6 +317,13 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A if self.after is not None: self.after(retry_state) + if self.wait: + sleep = self.wait(retry_state) + else: + sleep = 0.0 + + retry_state.upcoming_sleep = sleep + self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start if self.stop(retry_state): if self.retry_error_callback: @@ -325,10 +333,6 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A raise retry_exc.reraise() raise retry_exc from fut.exception() - if self.wait: - sleep = self.wait(retry_state) - else: - sleep = 0.0 retry_state.next_action = RetryAction(sleep) retry_state.idle_for += sleep self.statistics["idle_for"] += sleep @@ -451,6 +455,8 @@ def __init__( self.idle_for: float = 0.0 #: Next action as decided by the retry manager self.next_action: t.Optional[RetryAction] = None + #: Next sleep time as decided by the retry manager. + self.upcoming_sleep: float = 0.0 @property def seconds_since_start(self) -> t.Optional[float]: @@ -568,6 +574,7 @@ def wrap(f: WrappedFn) -> WrappedFn: "sleep_using_event", "stop_after_attempt", "stop_after_delay", + "stop_before_delay", "stop_all", "stop_any", "stop_never", diff --git a/tenacity/stop.py b/tenacity/stop.py index e6478606..d7eb6b92 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -92,7 +92,14 @@ def __call__(self, retry_state: "RetryCallState") -> bool: class stop_after_delay(stop_base): - """Stop when the time from the first attempt >= limit.""" + """ + Stop when the time from the first attempt >= limit. + + Note: `max_delay` will be exceeded, so when used with a `wait`, the actual total delay will be greater + than `max_delay` by some of the final sleep period before `max_delay` is exceeded. + + If you need stricter timing with waits, consider `stop_before_delay` instead. + """ def __init__(self, max_delay: _utils.time_unit_type) -> None: self.max_delay = _utils.to_seconds(max_delay) @@ -101,3 +108,20 @@ def __call__(self, retry_state: "RetryCallState") -> bool: if retry_state.seconds_since_start is None: raise RuntimeError("__call__() called but seconds_since_start is not set") return retry_state.seconds_since_start >= self.max_delay + + +class stop_before_delay(stop_base): + """ + Stop right before the next attempt would take place after the time from the first attempt >= limit. + + Most useful when you are using with a `wait` function like wait_random_exponential, but need to make + sure that the max_delay is not exceeded. + """ + + def __init__(self, max_delay: _utils.time_unit_type) -> None: + self.max_delay = _utils.to_seconds(max_delay) + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.seconds_since_start is None: + raise RuntimeError("__call__() called but seconds_since_start is not set") + return retry_state.seconds_since_start + retry_state.upcoming_sleep >= self.max_delay diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 0e54bc1e..ac792010 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -51,7 +51,7 @@ def _set_delay_since_start(retry_state, delay): assert retry_state.seconds_since_start == delay -def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_result=None): +def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_result=None, upcoming_sleep=0): """Construct RetryCallState for given attempt number & delay. Only used in testing and thus is extra careful about timestamp arithmetics. @@ -70,6 +70,9 @@ def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_re retry_state.outcome = last_result else: retry_state.set_result(None) + + retry_state.upcoming_sleep = upcoming_sleep + _set_delay_since_start(retry_state, delay_since_first_attempt) return retry_state @@ -163,6 +166,19 @@ def test_stop_after_delay(self): self.assertTrue(r.stop(make_retry_state(2, 1))) self.assertTrue(r.stop(make_retry_state(2, 1.001))) + def test_stop_before_delay(self): + for delay in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(stop=tenacity.stop_before_delay(delay)) + self.assertFalse(r.stop(make_retry_state(2, 0.999, upcoming_sleep=0.0001))) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0.001))) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=1))) + + # It should act the same as stop_after_delay if upcoming sleep is 0 + self.assertFalse(r.stop(make_retry_state(2, 0.999, upcoming_sleep=0))) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0))) + self.assertTrue(r.stop(make_retry_state(2, 1.001, upcoming_sleep=0))) + def test_legacy_explicit_stop_type(self): Retrying(stop="stop_after_attempt") From 0b76e7c7b1b77f9033563ee9948aae425133b35d Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Sat, 3 Feb 2024 22:16:37 +0100 Subject: [PATCH 074/124] ci: replace black and flake8 by ruff --- .github/workflows/ci.yaml | 8 +- .mergify.yml | 4 +- pyproject.toml | 8 +- tenacity/__init__.py | 35 +++++-- tenacity/_asyncio.py | 8 +- tenacity/_utils.py | 4 +- tenacity/before.py | 4 +- tenacity/before_sleep.py | 3 +- tenacity/retry.py | 8 +- tenacity/stop.py | 5 +- tenacity/tornadoweb.py | 6 +- tenacity/wait.py | 12 ++- tests/test_after.py | 34 +++++-- tests/test_asyncio.py | 21 +++- tests/test_tenacity.py | 207 +++++++++++++++++++++++++++++--------- tox.ini | 32 +----- 16 files changed, 283 insertions(+), 116 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d06aeeca..4bd620f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,14 +27,12 @@ jobs: tox: py310 - python: "3.11" tox: py311 - - python: "3.11" + - python: "3.12" + tox: py312 + - python: "3.12" tox: pep8 - - python: "3.11" - tox: black-ci - python: "3.11" tox: mypy - - python: "3.12" - tox: py312 steps: - name: Checkout 🛎️ uses: actions/checkout@v4.0.0 diff --git a/.mergify.yml b/.mergify.yml index 003f217b..a9d99e98 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -6,9 +6,7 @@ queue_rules: - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" - "check-success=test (3.12, py312)" - - "check-success=test (3.11, black-ci)" - - "check-success=test (3.11, pep8)" - - "check-success=test (3.11, mypy)" + - "check-success=test (3.12, pep8)" pull_request_rules: - name: warn on no changelog diff --git a/pyproject.toml b/pyproject.toml index a293a6ab..f4bda948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,10 @@ requires = [ ] build-backend="setuptools.build_meta" -[tool.black] -line-length = 120 -safe = true -target-version = ["py38", "py39", "py310", "py311", "py312"] +[tool.ruff] +line-length = 88 +indent-width = 4 +target-version = "py38" [tool.mypy] strict = true diff --git a/tenacity/__init__.py b/tenacity/__init__.py index bd60556b..11e2faaa 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -125,7 +125,9 @@ class BaseAction: NAME: t.Optional[str] = None def __repr__(self) -> str: - state_str = ", ".join(f"{field}={getattr(self, field)!r}" for field in self.REPR_FIELDS) + state_str = ", ".join( + f"{field}={getattr(self, field)!r}" for field in self.REPR_FIELDS + ) return f"{self.__class__.__name__}({state_str})" def __str__(self) -> str: @@ -221,10 +223,14 @@ def copy( retry: t.Union[retry_base, object] = _unset, before: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, after: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, - before_sleep: t.Union[t.Optional[t.Callable[["RetryCallState"], None]], object] = _unset, + before_sleep: t.Union[ + t.Optional[t.Callable[["RetryCallState"], None]], object + ] = _unset, reraise: t.Union[bool, object] = _unset, retry_error_cls: t.Union[t.Type[RetryError], object] = _unset, - retry_error_callback: t.Union[t.Optional[t.Callable[["RetryCallState"], t.Any]], object] = _unset, + retry_error_callback: t.Union[ + t.Optional[t.Callable[["RetryCallState"], t.Any]], object + ] = _unset, ) -> "BaseRetrying": """Copy this object with some parameters changed if needed.""" return self.__class__( @@ -237,7 +243,9 @@ def copy( before_sleep=_first_set(before_sleep, self.before_sleep), reraise=_first_set(reraise, self.reraise), retry_error_cls=_first_set(retry_error_cls, self.retry_error_cls), - retry_error_callback=_first_set(retry_error_callback, self.retry_error_callback), + retry_error_callback=_first_set( + retry_error_callback, self.retry_error_callback + ), ) def __repr__(self) -> str: @@ -285,7 +293,9 @@ def wraps(self, f: WrappedFn) -> WrappedFn: :param f: A function to wraps for retrying. """ - @functools.wraps(f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")) + @functools.wraps( + f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__") + ) def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: return self(f, *args, **kw) @@ -414,7 +424,9 @@ def failed(self) -> bool: return self.exception() is not None @classmethod - def construct(cls, attempt_number: int, value: t.Any, has_exception: bool) -> "Future": + def construct( + cls, attempt_number: int, value: t.Any, has_exception: bool + ) -> "Future": """Construct a new Future object.""" fut = cls(attempt_number) if has_exception: @@ -477,7 +489,10 @@ def set_result(self, val: t.Any) -> None: self.outcome, self.outcome_timestamp = fut, ts def set_exception( - self, exc_info: t.Tuple[t.Type[BaseException], BaseException, "types.TracebackType| None"] + self, + exc_info: t.Tuple[ + t.Type[BaseException], BaseException, "types.TracebackType| None" + ], ) -> None: ts = time.monotonic() fut = Future(self.attempt_number) @@ -539,7 +554,11 @@ def wrap(f: WrappedFn) -> WrappedFn: r: "BaseRetrying" if iscoroutinefunction(f): r = AsyncRetrying(*dargs, **dkw) - elif tornado and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f): + elif ( + tornado + and hasattr(tornado.gen, "is_coroutine_function") + and tornado.gen.is_coroutine_function(f) + ): r = TornadoRetrying(*dargs, **dkw) else: r = Retrying(*dargs, **dkw) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index d901cbd1..16aec620 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -33,7 +33,9 @@ class AsyncRetrying(BaseRetrying): sleep: t.Callable[[float], t.Awaitable[t.Any]] - def __init__(self, sleep: t.Callable[[float], t.Awaitable[t.Any]] = sleep, **kwargs: t.Any) -> None: + def __init__( + self, sleep: t.Callable[[float], t.Awaitable[t.Any]] = sleep, **kwargs: t.Any + ) -> None: super().__init__(**kwargs) self.sleep = sleep @@ -83,7 +85,9 @@ def wraps(self, fn: WrappedFn) -> WrappedFn: fn = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. - @functools.wraps(fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")) + @functools.wraps( + fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__") + ) async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: return await fn(*args, **kwargs) diff --git a/tenacity/_utils.py b/tenacity/_utils.py index f14ff320..67ee0dea 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -73,4 +73,6 @@ def get_callback_name(cb: typing.Callable[..., typing.Any]) -> str: def to_seconds(time_unit: time_unit_type) -> float: - return float(time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit) + return float( + time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit + ) diff --git a/tenacity/before.py b/tenacity/before.py index 9284f7ae..366235af 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -28,7 +28,9 @@ def before_nothing(retry_state: "RetryCallState") -> None: """Before call strategy that does nothing.""" -def before_log(logger: "logging.Logger", log_level: int) -> typing.Callable[["RetryCallState"], None]: +def before_log( + logger: "logging.Logger", log_level: int +) -> typing.Callable[["RetryCallState"], None]: """Before call strategy that logs to some logger the attempt.""" def log_it(retry_state: "RetryCallState") -> None: diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index 279a21eb..d04edcf9 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -64,7 +64,8 @@ def log_it(retry_state: "RetryCallState") -> None: logger.log( log_level, - f"Retrying {fn_name} " f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", + f"Retrying {fn_name} " + f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", exc_info=local_exc_info, ) diff --git a/tenacity/retry.py b/tenacity/retry.py index 765b6fe1..c5e55a65 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -204,7 +204,9 @@ def __init__( match: typing.Optional[str] = None, ) -> None: if message and match: - raise TypeError(f"{self.__class__.__name__}() takes either 'message' or 'match', not both") + raise TypeError( + f"{self.__class__.__name__}() takes either 'message' or 'match', not both" + ) # set predicate if message: @@ -221,7 +223,9 @@ def match_fnc(exception: BaseException) -> bool: predicate = match_fnc else: - raise TypeError(f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'") + raise TypeError( + f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'" + ) super().__init__(predicate) diff --git a/tenacity/stop.py b/tenacity/stop.py index d7eb6b92..5cda59ab 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -124,4 +124,7 @@ def __init__(self, max_delay: _utils.time_unit_type) -> None: def __call__(self, retry_state: "RetryCallState") -> bool: if retry_state.seconds_since_start is None: raise RuntimeError("__call__() called but seconds_since_start is not set") - return retry_state.seconds_since_start + retry_state.upcoming_sleep >= self.max_delay + return ( + retry_state.seconds_since_start + retry_state.upcoming_sleep + >= self.max_delay + ) diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index fabf13ae..44323e40 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -29,7 +29,11 @@ class TornadoRetrying(BaseRetrying): - def __init__(self, sleep: "typing.Callable[[float], Future[None]]" = gen.sleep, **kwargs: typing.Any) -> None: + def __init__( + self, + sleep: "typing.Callable[[float], Future[None]]" = gen.sleep, + **kwargs: typing.Any, + ) -> None: super().__init__(**kwargs) self.sleep = sleep diff --git a/tenacity/wait.py b/tenacity/wait.py index e1e2fe48..3addbb9c 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -41,7 +41,9 @@ def __radd__(self, other: "wait_base") -> typing.Union["wait_combine", "wait_bas return self.__add__(other) -WaitBaseT = typing.Union[wait_base, typing.Callable[["RetryCallState"], typing.Union[float, int]]] +WaitBaseT = typing.Union[ + wait_base, typing.Callable[["RetryCallState"], typing.Union[float, int]] +] class wait_fixed(wait_base): @@ -64,12 +66,16 @@ def __init__(self) -> None: class wait_random(wait_base): """Wait strategy that waits a random amount of time between min/max.""" - def __init__(self, min: _utils.time_unit_type = 0, max: _utils.time_unit_type = 1) -> None: # noqa + def __init__( + self, min: _utils.time_unit_type = 0, max: _utils.time_unit_type = 1 + ) -> None: # noqa self.wait_random_min = _utils.to_seconds(min) self.wait_random_max = _utils.to_seconds(max) def __call__(self, retry_state: "RetryCallState") -> float: - return self.wait_random_min + (random.random() * (self.wait_random_max - self.wait_random_min)) + return self.wait_random_min + ( + random.random() * (self.wait_random_max - self.wait_random_min) + ) class wait_combine(wait_base): diff --git a/tests/test_after.py b/tests/test_after.py index 32117033..0cb4f716 100644 --- a/tests/test_after.py +++ b/tests/test_after.py @@ -11,7 +11,15 @@ class TestAfterLogFormat(unittest.TestCase): def setUp(self) -> None: - self.log_level = random.choice((logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL)) + self.log_level = random.choice( + ( + logging.DEBUG, + logging.INFO, + logging.WARNING, + logging.ERROR, + logging.CRITICAL, + ) + ) self.previous_attempt_number = random.randint(1, 512) def test_01_default(self): @@ -22,10 +30,18 @@ def test_01_default(self): sec_format = "%0.3f" delay_since_first_attempt = 0.1 - retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) - fun = after_log(logger=logger, log_level=self.log_level) # use default sec_format + retry_state = test_tenacity.make_retry_state( + self.previous_attempt_number, delay_since_first_attempt + ) + fun = after_log( + logger=logger, log_level=self.log_level + ) # use default sec_format fun(retry_state) - fn_name = "" if retry_state.fn is None else _utils.get_callback_name(retry_state.fn) + fn_name = ( + "" + if retry_state.fn is None + else _utils.get_callback_name(retry_state.fn) + ) log.assert_called_once_with( self.log_level, f"Finished call to '{fn_name}' " @@ -41,10 +57,16 @@ def test_02_custom_sec_format(self): sec_format = "%.1f" delay_since_first_attempt = 0.1 - retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, delay_since_first_attempt) + retry_state = test_tenacity.make_retry_state( + self.previous_attempt_number, delay_since_first_attempt + ) fun = after_log(logger=logger, log_level=self.log_level, sec_format=sec_format) fun(retry_state) - fn_name = "" if retry_state.fn is None else _utils.get_callback_name(retry_state.fn) + fn_name = ( + "" + if retry_state.fn is None + else _utils.get_callback_name(retry_state.fn) + ) log.assert_called_once_with( self.log_level, f"Finished call to '{fn_name}' " diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 542f540d..24cf6ed7 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -96,12 +96,19 @@ async def function_with_defaults(a=1): async def function_with_kwdefaults(*, a=1): return a - retrying = AsyncRetrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) + retrying = AsyncRetrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) wrapped_defaults_function = retrying.wraps(function_with_defaults) wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) - self.assertEqual(function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__) - self.assertEqual(function_with_kwdefaults.__kwdefaults__, wrapped_kwdefaults_function.__kwdefaults__) + self.assertEqual( + function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__ + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, + ) @asynctest async def test_attempt_number_is_correct_for_interleaved_coroutines(self): @@ -152,7 +159,9 @@ class CustomError(Exception): pass try: - async for attempt in tasyncio.AsyncRetrying(stop=stop_after_attempt(1), reraise=True): + async for attempt in tasyncio.AsyncRetrying( + stop=stop_after_attempt(1), reraise=True + ): with attempt: raise CustomError() except CustomError: @@ -164,7 +173,9 @@ class CustomError(Exception): async def test_sleeps(self): start = current_time_ms() try: - async for attempt in tasyncio.AsyncRetrying(stop=stop_after_attempt(1), wait=wait_fixed(1)): + async for attempt in tasyncio.AsyncRetrying( + stop=stop_after_attempt(1), wait=wait_fixed(1) + ): with attempt: raise Exception() except RetryError: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index ac792010..e158fa6a 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -51,12 +51,19 @@ def _set_delay_since_start(retry_state, delay): assert retry_state.seconds_since_start == delay -def make_retry_state(previous_attempt_number, delay_since_first_attempt, last_result=None, upcoming_sleep=0): +def make_retry_state( + previous_attempt_number, + delay_since_first_attempt, + last_result=None, + upcoming_sleep=0, +): """Construct RetryCallState for given attempt number & delay. Only used in testing and thus is extra careful about timestamp arithmetics. """ - required_parameter_unset = previous_attempt_number is _unset or delay_since_first_attempt is _unset + required_parameter_unset = ( + previous_attempt_number is _unset or delay_since_first_attempt is _unset + ) if required_parameter_unset: raise _make_unset_exception( "wait/stop", @@ -90,9 +97,15 @@ def test_callstate_repr(self): rs.idle_for = 1.1111111 assert repr(rs).endswith("attempt #1; slept for 1.11; last result: none yet>") rs = make_retry_state(2, 5) - assert repr(rs).endswith("attempt #2; slept for 0.0; last result: returned None>") - rs = make_retry_state(0, 0, last_result=tenacity.Future.construct(1, ValueError("aaa"), True)) - assert repr(rs).endswith("attempt #0; slept for 0.0; last result: failed (ValueError aaa)>") + assert repr(rs).endswith( + "attempt #2; slept for 0.0; last result: returned None>" + ) + rs = make_retry_state( + 0, 0, last_result=tenacity.Future.construct(1, ValueError("aaa"), True) + ) + assert repr(rs).endswith( + "attempt #0; slept for 0.0; last result: failed (ValueError aaa)>" + ) class TestStopConditions(unittest.TestCase): @@ -101,7 +114,9 @@ def test_never_stop(self): self.assertFalse(r.stop(make_retry_state(3, 6546))) def test_stop_any(self): - stop = tenacity.stop_any(tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)) + stop = tenacity.stop_any( + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) @@ -114,7 +129,9 @@ def s(*args): self.assertTrue(s(4, 1.8)) def test_stop_all(self): - stop = tenacity.stop_all(tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)) + stop = tenacity.stop_all( + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) @@ -170,7 +187,9 @@ def test_stop_before_delay(self): for delay in (1, datetime.timedelta(seconds=1)): with self.subTest(): r = Retrying(stop=tenacity.stop_before_delay(delay)) - self.assertFalse(r.stop(make_retry_state(2, 0.999, upcoming_sleep=0.0001))) + self.assertFalse( + r.stop(make_retry_state(2, 0.999, upcoming_sleep=0.0001)) + ) self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0.001))) self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=1))) @@ -205,15 +224,23 @@ def test_fixed_sleep(self): self.assertEqual(1, r.wait(make_retry_state(12, 6546))) def test_incrementing_sleep(self): - for start, increment in ((500, 100), (datetime.timedelta(seconds=500), datetime.timedelta(seconds=100))): + for start, increment in ( + (500, 100), + (datetime.timedelta(seconds=500), datetime.timedelta(seconds=100)), + ): with self.subTest(): - r = Retrying(wait=tenacity.wait_incrementing(start=start, increment=increment)) + r = Retrying( + wait=tenacity.wait_incrementing(start=start, increment=increment) + ) self.assertEqual(500, r.wait(make_retry_state(1, 6546))) self.assertEqual(600, r.wait(make_retry_state(2, 6546))) self.assertEqual(700, r.wait(make_retry_state(3, 6546))) def test_random_sleep(self): - for min_, max_ in ((1, 20), (datetime.timedelta(seconds=1), datetime.timedelta(seconds=20))): + for min_, max_ in ( + (1, 20), + (datetime.timedelta(seconds=1), datetime.timedelta(seconds=20)), + ): with self.subTest(): r = Retrying(wait=tenacity.wait_random(min=min_, max=max_)) times = set() @@ -300,7 +327,10 @@ def test_exponential_with_min_wait_and_multiplier(self): self.assertEqual(r.wait(make_retry_state(20, 0)), 1048576) def test_exponential_with_min_wait_andmax__wait(self): - for min_, max_ in ((10, 100), (datetime.timedelta(seconds=10), datetime.timedelta(seconds=100))): + for min_, max_ in ( + (10, 100), + (datetime.timedelta(seconds=10), datetime.timedelta(seconds=100)), + ): with self.subTest(): r = Retrying(wait=tenacity.wait_exponential(min=min_, max=max_)) self.assertEqual(r.wait(make_retry_state(1, 0)), 10) @@ -327,7 +357,11 @@ def wait_func(retry_state): self.assertEqual(r.wait(make_retry_state(10, 100)), 1000) def test_wait_combine(self): - r = Retrying(wait=tenacity.wait_combine(tenacity.wait_random(0, 3), tenacity.wait_fixed(5))) + r = Retrying( + wait=tenacity.wait_combine( + tenacity.wait_random(0, 3), tenacity.wait_fixed(5) + ) + ) # Test it a few time since it's random for i in range(1000): w = r.wait(make_retry_state(1, 5)) @@ -343,7 +377,11 @@ def test_wait_double_sum(self): self.assertGreaterEqual(w, 5) def test_wait_triple_sum(self): - r = Retrying(wait=tenacity.wait_fixed(1) + tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) + r = Retrying( + wait=tenacity.wait_fixed(1) + + tenacity.wait_random(0, 3) + + tenacity.wait_fixed(5) + ) # Test it a few time since it's random for i in range(1000): w = r.wait(make_retry_state(1, 5)) @@ -495,7 +533,10 @@ def waitfunc(retry_state): retrying = Retrying( wait=waitfunc, - retry=(tenacity.retry_if_exception_type() | tenacity.retry_if_result(lambda result: result == 123)), + retry=( + tenacity.retry_if_exception_type() + | tenacity.retry_if_result(lambda result: result == 123) + ), ) def returnval(): @@ -579,7 +620,9 @@ def r(fut): self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_and(self): - retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result(lambda x: isinstance(x, int)) + retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result( + lambda x: isinstance(x, int) + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) @@ -591,7 +634,9 @@ def r(fut): self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_or(self): - retry = tenacity.retry_if_result(lambda x: x == "foo") | tenacity.retry_if_result(lambda x: isinstance(x, int)) + retry = tenacity.retry_if_result( + lambda x: x == "foo" + ) | tenacity.retry_if_result(lambda x: isinstance(x, int)) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) @@ -609,7 +654,9 @@ def _raise_try_again(self): def test_retry_try_again(self): self._attempts = 0 - Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)(self._raise_try_again) + Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)( + self._raise_try_again + ) self.assertEqual(3, self._attempts) def test_retry_try_again_forever(self): @@ -867,7 +914,9 @@ def _retryable_test_if_not_exception_type_io(thing): return thing.go() -@retry(stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError)) +@retry( + stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError) +) def _retryable_test_with_exception_type_io_attempt_limit(thing): return thing.go() @@ -892,28 +941,46 @@ def _retryable_test_with_unless_exception_type_no_input(thing): @retry( stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_if_exception_message(message=NoCustomErrorAfterCount.derived_message), + retry=tenacity.retry_if_exception_message( + message=NoCustomErrorAfterCount.derived_message + ), ) def _retryable_test_if_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message(message=NoCustomErrorAfterCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NoCustomErrorAfterCount.derived_message + ) +) def _retryable_test_if_not_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_exception_message(match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message(match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_not_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_not_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message(message=NameErrorUntilCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NameErrorUntilCount.derived_message + ) +) def _retryable_test_not_exception_message_delay(thing): return thing.go() @@ -977,7 +1044,9 @@ def test_retry_if_exception_of_type(self): self.assertTrue(isinstance(n, NameError)) print(n) - self.assertTrue(_retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5))) + self.assertTrue( + _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5)) + ) try: _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5)) @@ -987,7 +1056,9 @@ def test_retry_if_exception_of_type(self): print(n) def test_retry_except_exception_of_type(self): - self.assertTrue(_retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5))) + self.assertTrue( + _retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5)) + ) try: _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5)) @@ -998,7 +1069,9 @@ def test_retry_except_exception_of_type(self): def test_retry_until_exception_of_type_attempt_number(self): try: - self.assertTrue(_retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))) + self.assertTrue( + _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) + ) except NameError as e: s = _retryable_test_with_unless_exception_type_name.retry.statistics self.assertTrue(s["attempt_number"] == 6) @@ -1009,7 +1082,11 @@ def test_retry_until_exception_of_type_attempt_number(self): def test_retry_until_exception_of_type_no_type(self): try: # no input should catch all subclasses of Exception - self.assertTrue(_retryable_test_with_unless_exception_type_no_input(NameErrorUntilCount(5))) + self.assertTrue( + _retryable_test_with_unless_exception_type_no_input( + NameErrorUntilCount(5) + ) + ) except NameError as e: s = _retryable_test_with_unless_exception_type_no_input.retry.statistics self.assertTrue(s["attempt_number"] == 6) @@ -1020,7 +1097,9 @@ def test_retry_until_exception_of_type_no_type(self): def test_retry_until_exception_of_type_wrong_exception(self): try: # two iterations with IOError, one that returns True - _retryable_test_with_unless_exception_type_name_attempt_limit(IOErrorUntilCount(2)) + _retryable_test_with_unless_exception_type_name_attempt_limit( + IOErrorUntilCount(2) + ) self.fail("Expected RetryError") except RetryError as e: self.assertTrue(isinstance(e, RetryError)) @@ -1028,21 +1107,29 @@ def test_retry_until_exception_of_type_wrong_exception(self): def test_retry_if_exception_message(self): try: - self.assertTrue(_retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))) + self.assertTrue( + _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3)) + ) except CustomError: print(_retryable_test_if_exception_message_message.retry.statistics) self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message(NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: s = _retryable_test_if_not_exception_message_message.retry.statistics self.assertTrue(s["attempt_number"] == 1) def test_retry_if_not_exception_message_delay(self): try: - self.assertTrue(_retryable_test_not_exception_message_delay(NameErrorUntilCount(3))) + self.assertTrue( + _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) + ) except NameError: s = _retryable_test_not_exception_message_delay.retry.statistics print(s["attempt_number"]) @@ -1050,19 +1137,27 @@ def test_retry_if_not_exception_message_delay(self): def test_retry_if_exception_message_match(self): try: - self.assertTrue(_retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3))) + self.assertTrue( + _retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3)) + ) except CustomError: self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message_match(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message(NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: s = _retryable_test_if_not_exception_message_message.retry.statistics self.assertTrue(s["attempt_number"] == 1) def test_retry_if_exception_cause_type(self): - self.assertTrue(_retryable_test_with_exception_cause_type(NoNameErrorCauseAfterCount(5))) + self.assertTrue( + _retryable_test_with_exception_cause_type(NoNameErrorCauseAfterCount(5)) + ) try: _retryable_test_with_exception_cause_type(NoIOErrorCauseAfterCount(5)) @@ -1077,12 +1172,19 @@ def function_with_defaults(a=1): def function_with_kwdefaults(*, a=1): return a - retrying = Retrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) wrapped_defaults_function = retrying.wraps(function_with_defaults) wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) - self.assertEqual(function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__) - self.assertEqual(function_with_kwdefaults.__kwdefaults__, wrapped_kwdefaults_function.__kwdefaults__) + self.assertEqual( + function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__ + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, + ) def test_defaults(self): self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) @@ -1101,7 +1203,9 @@ class Hello: def __call__(self): return "Hello" - retrying = Retrying(wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") @@ -1109,13 +1213,17 @@ def __call__(self): class TestRetryWith: def test_redefine_wait(self): start = current_time_ms() - result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))(NoneReturnUntilAfterCount(5)) + result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( + NoneReturnUntilAfterCount(5) + ) t = current_time_ms() - start assert t >= 500 assert result is True def test_redefine_stop(self): - result = _retryable_test_with_stop.retry_with(stop=tenacity.stop_after_attempt(5))(NoneReturnUntilAfterCount(4)) + result = _retryable_test_with_stop.retry_with( + stop=tenacity.stop_after_attempt(5) + )(NoneReturnUntilAfterCount(4)) assert result is True def test_retry_error_cls_should_be_preserved(self): @@ -1220,7 +1328,10 @@ def _before_sleep_log_raises(self, get_call_fn): finally: logger.removeHandler(handler) - etalon_re = r"^Retrying .* in 0\.01 seconds as it raised " r"(IO|OS)Error: Hi there, I'm an IOError\.$" + etalon_re = ( + r"^Retrying .* in 0\.01 seconds as it raised " + r"(IO|OS)Error: Hi there, I'm an IOError\.$" + ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format self.assertRegex(fmt(handler.records[0]), etalon_re) @@ -1237,7 +1348,9 @@ def test_before_sleep_log_raises_with_exc_info(self): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, logging.INFO, exc_info=True) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=True + ) retrying = Retrying( wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3), @@ -1267,7 +1380,9 @@ def test_before_sleep_log_returns(self, exc_info=False): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, logging.INFO, exc_info=exc_info) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=exc_info + ) _retry = tenacity.retry_if_result(lambda result: result is None) retrying = Retrying( wait=tenacity.wait_fixed(0.01), @@ -1550,7 +1665,9 @@ def test_retry_error_is_pickleable(self): class TestRetryTyping(unittest.TestCase): - @pytest.mark.skipif(sys.version_info < (3, 0), reason="typeguard not supported for python 2") + @pytest.mark.skipif( + sys.version_info < (3, 0), reason="typeguard not supported for python 2" + ) def test_retry_type_annotations(self): """The decorator should maintain types of decorated functions.""" # Just in case this is run with unit-test, return early for py2 diff --git a/tox.ini b/tox.ini index 108c6e29..13e5a1dc 100644 --- a/tox.ini +++ b/tox.ini @@ -15,20 +15,10 @@ commands = [testenv:pep8] basepython = python3 -deps = flake8 - flake8-import-order - flake8-blind-except - flake8-builtins - flake8-docstrings - flake8-rst-docstrings - flake8-logging-format -commands = flake8 {posargs} - -[testenv:black] -deps = - black +deps = ruff commands = - black . + ruff check . {posargs} + ruff format --check . {posargs} [testenv:mypy] deps = @@ -37,21 +27,7 @@ deps = commands = mypy {posargs} -[testenv:black-ci] -deps = - black - {[testenv:black]deps} -commands = - black --check --diff . - [testenv:reno] basepython = python3 deps = reno -commands = reno {posargs} - -[flake8] -exclude = .tox,.eggs -show-source = true -ignore = D100,D101,D102,D103,D104,D105,D107,G200,G201,W503,W504,E501 -enable-extensions=G -max-line-length = 120 +commands = reno {posargs} \ No newline at end of file From 17aefd96cbc90fd5201f61562640709a185e8796 Mon Sep 17 00:00:00 2001 From: hasier Date: Tue, 6 Feb 2024 11:10:37 +0000 Subject: [PATCH 075/124] Incrementally build iter actions list (#434) * Incrementally build iter actions list * Add TypedDict for iter_state * Format with ruff * Make IterState a dataclass * Fix typing * Conditionally add slots to dataclass --------- Co-authored-by: Julien Danjou --- tenacity/__init__.py | 126 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 11e2faaa..dc8dfbcc 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -15,8 +15,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - - +import dataclasses import functools import sys import threading @@ -97,6 +96,29 @@ WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) +dataclass_kwargs = {} +if sys.version_info >= (3, 10): + dataclass_kwargs.update({"slots": True}) + + +@dataclasses.dataclass(**dataclass_kwargs) +class IterState: + actions: t.List[t.Callable[["RetryCallState"], t.Any]] = dataclasses.field( + default_factory=list + ) + retry_run_result: bool = False + delay_since_first_attempt: int = 0 + stop_run_result: bool = False + is_explicit_retry: bool = False + + def reset(self) -> None: + self.actions = [] + self.retry_run_result = False + self.delay_since_first_attempt = 0 + self.stop_run_result = False + self.is_explicit_retry = False + + class TryAgain(Exception): """Always retry the executed function when raised.""" @@ -287,6 +309,14 @@ def statistics(self) -> t.Dict[str, t.Any]: self._local.statistics = t.cast(t.Dict[str, t.Any], {}) return self._local.statistics + @property + def iter_state(self) -> IterState: + try: + return self._local.iter_state # type: ignore[no-any-return] + except AttributeError: + self._local.iter_state = IterState() + return self._local.iter_state + def wraps(self, f: WrappedFn) -> WrappedFn: """Wrap a function for retrying. @@ -313,20 +343,13 @@ def begin(self) -> None: self.statistics["attempt_number"] = 1 self.statistics["idle_for"] = 0 - def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa - fut = retry_state.outcome - if fut is None: - if self.before is not None: - self.before(retry_state) - return DoAttempt() - - is_explicit_retry = fut.failed and isinstance(fut.exception(), TryAgain) - if not (is_explicit_retry or self.retry(retry_state)): - return fut.result() + def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None: + self.iter_state.actions.append(fn) - if self.after is not None: - self.after(retry_state) + def _run_retry(self, retry_state: "RetryCallState") -> None: + self.iter_state.retry_run_result = self.retry(retry_state) + def _run_wait(self, retry_state: "RetryCallState") -> None: if self.wait: sleep = self.wait(retry_state) else: @@ -334,24 +357,75 @@ def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.A retry_state.upcoming_sleep = sleep + def _run_stop(self, retry_state: "RetryCallState") -> None: self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start - if self.stop(retry_state): + self.iter_state.stop_run_result = self.stop(retry_state) + + def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa + self._begin_iter(retry_state) + result = None + for action in self.iter_state.actions: + result = action(retry_state) + return result + + def _begin_iter(self, retry_state: "RetryCallState") -> None: # noqa + self.iter_state.reset() + + fut = retry_state.outcome + if fut is None: + if self.before is not None: + self._add_action_func(self.before) + self._add_action_func(lambda rs: DoAttempt()) + return + + self.iter_state.is_explicit_retry = fut.failed and isinstance( + fut.exception(), TryAgain + ) + if not self.iter_state.is_explicit_retry: + self._add_action_func(self._run_retry) + self._add_action_func(self._post_retry_check_actions) + + def _post_retry_check_actions(self, retry_state: "RetryCallState") -> None: + if not (self.iter_state.is_explicit_retry or self.iter_state.retry_run_result): + self._add_action_func(lambda rs: rs.outcome.result()) + return + + if self.after is not None: + self._add_action_func(self.after) + + self._add_action_func(self._run_wait) + self._add_action_func(self._run_stop) + self._add_action_func(self._post_stop_check_actions) + + def _post_stop_check_actions(self, retry_state: "RetryCallState") -> None: + if self.iter_state.stop_run_result: if self.retry_error_callback: - return self.retry_error_callback(retry_state) - retry_exc = self.retry_error_cls(fut) - if self.reraise: - raise retry_exc.reraise() - raise retry_exc from fut.exception() + self._add_action_func(self.retry_error_callback) + return + + def exc_check(rs: "RetryCallState") -> None: + fut = t.cast(Future, rs.outcome) + retry_exc = self.retry_error_cls(fut) + if self.reraise: + raise retry_exc.reraise() + raise retry_exc from fut.exception() + + self._add_action_func(exc_check) + return + + def next_action(rs: "RetryCallState") -> None: + sleep = rs.upcoming_sleep + rs.next_action = RetryAction(sleep) + rs.idle_for += sleep + self.statistics["idle_for"] += sleep + self.statistics["attempt_number"] += 1 - retry_state.next_action = RetryAction(sleep) - retry_state.idle_for += sleep - self.statistics["idle_for"] += sleep - self.statistics["attempt_number"] += 1 + self._add_action_func(next_action) if self.before_sleep is not None: - self.before_sleep(retry_state) + self._add_action_func(self.before_sleep) - return DoSleep(sleep) + self._add_action_func(lambda rs: DoSleep(rs.upcoming_sleep)) def __iter__(self) -> t.Generator[AttemptManager, None, None]: self.begin() From 2f624ba31fa89fa4dd3e65b375bc780066839a4f Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 9 Feb 2024 04:21:50 -0600 Subject: [PATCH 076/124] Add a Dependabot config to autoupdate GitHub action versions (#439) --- .github/dependabot.yml | 10 ++++++++++ ...dependabot-for-github-actions-4d2464f3c0928463.yaml | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b22df070 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + groups: + github-actions: + patterns: + - '*' diff --git a/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml b/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml new file mode 100644 index 00000000..278df6ca --- /dev/null +++ b/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml @@ -0,0 +1,5 @@ +--- +other: + - | + Add a Dependabot configuration submit PRs monthly (as needed) + to keep GitHub action versions updated. From 9eb3868112d4d4a3d3bae37ae75fc49736eb2cfa Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 9 Feb 2024 11:29:21 +0100 Subject: [PATCH 077/124] ci: run deploy wf on release (#442) --- .github/workflows/deploy.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index eb71854c..db4ee53f 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,14 +1,14 @@ name: Release deploy on: - push: - tags: + release: + types: + - published jobs: test: - if: github.repository_owner == 'jd' timeout-minutes: 20 - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout 🛎️ uses: actions/checkout@v4.0.0 From 50065df82629e3e59e9c344abb5fc471a58bb7a7 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 9 Feb 2024 11:32:55 +0100 Subject: [PATCH 078/124] ci: simplify Mergify configuration and automerge dependabot (#443) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .mergify.yml | 43 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index a9d99e98..daa56f2c 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,6 +1,7 @@ queue_rules: - name: default - conditions: &CheckRuns + merge_method: squash + queue_conditions: &CheckRuns - "check-success=test (3.8, py38)" - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" @@ -20,42 +21,28 @@ pull_request_rules: ⚠️ No release notes detected. Please make sure to use [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add a changelog entry. + - name: automatic merge without changelog conditions: - - and: *CheckRuns - - "#approved-reviews-by>=1" - - label=no-changelog + - or: + - author=jd + - author=dependabot + - "#approved-reviews-by>=1" + - or: + - label=no-changelog + - author=dependabot actions: queue: - name: default - method: squash + - name: automatic merge with changelog conditions: - - and: *CheckRuns - - "#approved-reviews-by>=1" - - files~=^releasenotes/notes/ - actions: - queue: - name: default - method: squash - - name: automatic merge for jd without changelog - conditions: - - author=jd - - and: *CheckRuns - - label=no-changelog - actions: - queue: - name: default - method: squash - - name: automatic merge for jd with changelog - conditions: - - author=jd - - and: *CheckRuns + - or: + - author=jd + - "#approved-reviews-by>=1" - files~=^releasenotes/notes/ actions: queue: - name: default - method: squash + - name: dismiss reviews conditions: [] actions: From 9ed3e964839d588eb696e9b8e68cd9e96751bdd1 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 9 Feb 2024 11:40:19 +0100 Subject: [PATCH 079/124] ci(mergify): add missing [bot] for dependabot (#446) --- .mergify.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index daa56f2c..8180bed8 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -26,11 +26,11 @@ pull_request_rules: conditions: - or: - author=jd - - author=dependabot + - author=dependabot[bot] - "#approved-reviews-by>=1" - or: - label=no-changelog - - author=dependabot + - author=dependabot[bot] actions: queue: From cf58675e9c5d8e002272e8e5bbdbd6ac14f69920 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Feb 2024 11:00:22 +0000 Subject: [PATCH 080/124] chore(deps): bump the github-actions group with 2 updates (#441) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4.0.0 to 4.1.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.0.0...v4.1.1) Updates `actions/setup-python` from 4.7.0 to 5.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.7.0...v5.0.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/deploy.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4bd620f6..a0b311f7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,12 +35,12 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.1 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index db4ee53f..15c0dfed 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.1 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v5.0.0 with: python-version: 3.11 From 65a19f9be58fb9d1333f2501879cda2be7249ce2 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 9 Feb 2024 12:03:11 +0100 Subject: [PATCH 081/124] ci(deploy): rename job (#444) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 15c0dfed..05fb50a7 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -6,7 +6,7 @@ on: - published jobs: - test: + publish: timeout-minutes: 20 runs-on: ubuntu-latest steps: From b7e488379a5de01b54fc77bd598bdccecf199e47 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 9 Feb 2024 12:07:45 +0100 Subject: [PATCH 082/124] ci(test): remove not necessary push (#445) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a0b311f7..fffad557 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,6 @@ name: Continuous Integration permissions: read-all on: - push: pull_request: branches: - main From bfa2c800ea17d5cbfacf51003627ada4a7c3691f Mon Sep 17 00:00:00 2001 From: hasier Date: Sat, 2 Mar 2024 07:30:37 +0000 Subject: [PATCH 083/124] Support async actions (#437) * Support async actions * Fixes after main rebase * Test is_coroutine_callable --- tenacity/_asyncio.py | 46 ++++++++++++++++++++++++++++++++++++++++++-- tenacity/_utils.py | 13 ++++++++++++- tests/test_utils.py | 41 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/test_utils.py diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 16aec620..27c26642 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -25,6 +25,7 @@ from tenacity import DoAttempt from tenacity import DoSleep from tenacity import RetryCallState +from tenacity import _utils WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) @@ -46,7 +47,7 @@ async def __call__( # type: ignore[override] retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) while True: - do = self.iter(retry_state=retry_state) + do = await self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): try: result = await fn(*args, **kwargs) @@ -60,6 +61,47 @@ async def __call__( # type: ignore[override] else: return do # type: ignore[no-any-return] + @classmethod + def _wrap_action_func(cls, fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + if _utils.is_coroutine_callable(fn): + return fn + + async def inner(*args: t.Any, **kwargs: t.Any) -> t.Any: + return fn(*args, **kwargs) + + return inner + + def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None: + self.iter_state.actions.append(self._wrap_action_func(fn)) + + async def _run_retry(self, retry_state: "RetryCallState") -> None: # type: ignore[override] + self.iter_state.retry_run_result = await self._wrap_action_func(self.retry)( + retry_state + ) + + async def _run_wait(self, retry_state: "RetryCallState") -> None: # type: ignore[override] + if self.wait: + sleep = await self._wrap_action_func(self.wait)(retry_state) + else: + sleep = 0.0 + + retry_state.upcoming_sleep = sleep + + async def _run_stop(self, retry_state: "RetryCallState") -> None: # type: ignore[override] + self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start + self.iter_state.stop_run_result = await self._wrap_action_func(self.stop)( + retry_state + ) + + async def iter( + self, retry_state: "RetryCallState" + ) -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa: A003 + self._begin_iter(retry_state) + result = None + for action in self.iter_state.actions: + result = await action(retry_state) + return result + def __iter__(self) -> t.Generator[AttemptManager, None, None]: raise TypeError("AsyncRetrying object is not iterable") @@ -70,7 +112,7 @@ def __aiter__(self) -> "AsyncRetrying": async def __anext__(self) -> AttemptManager: while True: - do = self.iter(retry_state=self._retry_state) + do = await self.iter(retry_state=self._retry_state) if do is None: raise StopAsyncIteration elif isinstance(do, DoAttempt): diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 67ee0dea..4e34115e 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -13,7 +13,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import functools +import inspect import sys import typing from datetime import timedelta @@ -76,3 +77,13 @@ def to_seconds(time_unit: time_unit_type) -> float: return float( time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit ) + + +def is_coroutine_callable(call: typing.Callable[..., typing.Any]) -> bool: + if inspect.isclass(call): + return False + if inspect.iscoroutinefunction(call): + return True + partial_call = isinstance(call, functools.partial) and call.func + dunder_call = partial_call or getattr(call, "__call__", None) + return inspect.iscoroutinefunction(dunder_call) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..ec7b3ee5 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +import functools + +from tenacity import _utils + + +def test_is_coroutine_callable() -> None: + async def async_func() -> None: + pass + + def sync_func() -> None: + pass + + class AsyncClass: + async def __call__(self) -> None: + pass + + class SyncClass: + def __call__(self) -> None: + pass + + lambda_fn = lambda: None # noqa: E731 + + partial_async_func = functools.partial(async_func) + partial_sync_func = functools.partial(sync_func) + partial_async_class = functools.partial(AsyncClass().__call__) + partial_sync_class = functools.partial(SyncClass().__call__) + partial_lambda_fn = functools.partial(lambda_fn) + + assert _utils.is_coroutine_callable(async_func) is True + assert _utils.is_coroutine_callable(sync_func) is False + assert _utils.is_coroutine_callable(AsyncClass) is False + assert _utils.is_coroutine_callable(AsyncClass()) is True + assert _utils.is_coroutine_callable(SyncClass) is False + assert _utils.is_coroutine_callable(SyncClass()) is False + assert _utils.is_coroutine_callable(lambda_fn) is False + + assert _utils.is_coroutine_callable(partial_async_func) is True + assert _utils.is_coroutine_callable(partial_sync_func) is False + assert _utils.is_coroutine_callable(partial_async_class) is True + assert _utils.is_coroutine_callable(partial_sync_class) is False + assert _utils.is_coroutine_callable(partial_lambda_fn) is False From 06c34a281699491efc6c6edd1c334d0f77c02e3c Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Sat, 2 Mar 2024 08:36:49 +0100 Subject: [PATCH 084/124] chore: update to latest ruff (#448) --- tenacity/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index dc8dfbcc..bcee3f59 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -588,8 +588,7 @@ def __repr__(self) -> str: @t.overload -def retry(func: WrappedFn) -> WrappedFn: - ... +def retry(func: WrappedFn) -> WrappedFn: ... @t.overload @@ -604,8 +603,7 @@ def retry( reraise: bool = False, retry_error_cls: t.Type["RetryError"] = RetryError, retry_error_callback: t.Optional[t.Callable[["RetryCallState"], t.Any]] = None, -) -> t.Callable[[WrappedFn], WrappedFn]: - ... +) -> t.Callable[[WrappedFn], WrappedFn]: ... def retry(*dargs: t.Any, **dkw: t.Any) -> t.Any: From c5d2d8bfeede5d36254335a52c13519ce8cf5fad Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Sat, 2 Mar 2024 08:40:30 +0100 Subject: [PATCH 085/124] ci: refactor Mergify rules (#447) --- .mergify.yml | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 8180bed8..35e475d0 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,7 +1,15 @@ queue_rules: - name: default merge_method: squash - queue_conditions: &CheckRuns + queue_conditions: + - or: + - author = jd + - "#approved-reviews-by >= 1" + - author = dependabot[bot] + - or: + - files ~= ^releasenotes/notes/ + - label = no-changelog + - author = dependabot[bot] - "check-success=test (3.8, py38)" - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" @@ -22,24 +30,8 @@ pull_request_rules: [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add a changelog entry. - - name: automatic merge without changelog - conditions: - - or: - - author=jd - - author=dependabot[bot] - - "#approved-reviews-by>=1" - - or: - - label=no-changelog - - author=dependabot[bot] - actions: - queue: - - - name: automatic merge with changelog - conditions: - - or: - - author=jd - - "#approved-reviews-by>=1" - - files~=^releasenotes/notes/ + - name: automatic queue + conditions: [] actions: queue: From cb15300d9b4358d9bdb6cfd33d433368700b3abe Mon Sep 17 00:00:00 2001 From: Richard Si Date: Thu, 14 Mar 2024 06:25:38 -0400 Subject: [PATCH 086/124] Lazy import asyncio.sleep as it's expensive (#450) On my system, importing tenacity (_without tornado_) takes 35ms, and asyncio is singlehandedly responsible for 15ms. Some users do not ever use AsyncRetrying (or asyncio in their project generally) and it would be a shame for them to incur a unnecessary import penalty. Full disclaimer: I pursued this change primarily to reduce pip's startup time where asyncio was a nontrivial portion of the import timeline. --- tenacity/_asyncio.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py index 27c26642..b06303f4 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/_asyncio.py @@ -18,7 +18,6 @@ import functools import sys import typing as t -from asyncio import sleep from tenacity import AttemptManager from tenacity import BaseRetrying @@ -31,11 +30,20 @@ WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) +def asyncio_sleep(duration: float) -> t.Awaitable[None]: + # Lazy import asyncio as it's expensive (responsible for 25-50% of total import overhead). + import asyncio + + return asyncio.sleep(duration) + + class AsyncRetrying(BaseRetrying): sleep: t.Callable[[float], t.Awaitable[t.Any]] def __init__( - self, sleep: t.Callable[[float], t.Awaitable[t.Any]] = sleep, **kwargs: t.Any + self, + sleep: t.Callable[[float], t.Awaitable[t.Any]] = asyncio_sleep, + **kwargs: t.Any, ) -> None: super().__init__(**kwargs) self.sleep = sleep From 21137e79ea6d59907b3b8d236c275c5190a612fe Mon Sep 17 00:00:00 2001 From: hasier Date: Wed, 12 Jun 2024 13:20:22 +0100 Subject: [PATCH 087/124] Add async strategies (#451) * Add async strategies * Fix init typing * Reuse is_coroutine_callable * Keep only async predicate overrides and DRY implementations * Ensure async and/or versions called when necessary * Run ruff format * Copy over strategies as async * Add release note --- .../add-async-actions-b249c527d99723bb.yaml | 5 + tenacity/__init__.py | 28 ++- tenacity/_utils.py | 12 ++ tenacity/{_asyncio.py => asyncio/__init__.py} | 86 ++++++--- tenacity/asyncio/retry.py | 125 +++++++++++++ tenacity/retry.py | 10 +- tests/test_asyncio.py | 165 +++++++++++++++++- 7 files changed, 396 insertions(+), 35 deletions(-) create mode 100644 releasenotes/notes/add-async-actions-b249c527d99723bb.yaml rename tenacity/{_asyncio.py => asyncio/__init__.py} (64%) create mode 100644 tenacity/asyncio/retry.py diff --git a/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml b/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml new file mode 100644 index 00000000..096a24f2 --- /dev/null +++ b/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Added the ability to use async functions for retries. This way, you can now use + asyncio coroutines for retry strategy predicates. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index bcee3f59..7de36d43 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -24,7 +24,8 @@ import warnings from abc import ABC, abstractmethod from concurrent import futures -from inspect import iscoroutinefunction + +from . import _utils # Import all built-in retry strategies for easier usage. from .retry import retry_base # noqa @@ -87,6 +88,7 @@ if t.TYPE_CHECKING: import types + from . import asyncio as tasyncio from .retry import RetryBaseT from .stop import StopBaseT from .wait import WaitBaseT @@ -593,16 +595,24 @@ def retry(func: WrappedFn) -> WrappedFn: ... @t.overload def retry( - sleep: t.Callable[[t.Union[int, float]], t.Optional[t.Awaitable[None]]] = sleep, + sleep: t.Callable[[t.Union[int, float]], t.Union[None, t.Awaitable[None]]] = sleep, stop: "StopBaseT" = stop_never, wait: "WaitBaseT" = wait_none(), - retry: "RetryBaseT" = retry_if_exception_type(), - before: t.Callable[["RetryCallState"], None] = before_nothing, - after: t.Callable[["RetryCallState"], None] = after_nothing, - before_sleep: t.Optional[t.Callable[["RetryCallState"], None]] = None, + retry: "t.Union[RetryBaseT, tasyncio.retry.RetryBaseT]" = retry_if_exception_type(), + before: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = before_nothing, + after: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = after_nothing, + before_sleep: t.Optional[ + t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] + ] = None, reraise: bool = False, retry_error_cls: t.Type["RetryError"] = RetryError, - retry_error_callback: t.Optional[t.Callable[["RetryCallState"], t.Any]] = None, + retry_error_callback: t.Optional[ + t.Callable[["RetryCallState"], t.Union[t.Any, t.Awaitable[t.Any]]] + ] = None, ) -> t.Callable[[WrappedFn], WrappedFn]: ... @@ -624,7 +634,7 @@ def wrap(f: WrappedFn) -> WrappedFn: f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" ) r: "BaseRetrying" - if iscoroutinefunction(f): + if _utils.is_coroutine_callable(f): r = AsyncRetrying(*dargs, **dkw) elif ( tornado @@ -640,7 +650,7 @@ def wrap(f: WrappedFn) -> WrappedFn: return wrap -from tenacity._asyncio import AsyncRetrying # noqa:E402,I100 +from tenacity.asyncio import AsyncRetrying # noqa:E402,I100 if tornado: from tenacity.tornadoweb import TornadoRetrying diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 4e34115e..f11a0888 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -87,3 +87,15 @@ def is_coroutine_callable(call: typing.Callable[..., typing.Any]) -> bool: partial_call = isinstance(call, functools.partial) and call.func dunder_call = partial_call or getattr(call, "__call__", None) return inspect.iscoroutinefunction(dunder_call) + + +def wrap_to_async_func( + call: typing.Callable[..., typing.Any], +) -> typing.Callable[..., typing.Awaitable[typing.Any]]: + if is_coroutine_callable(call): + return call + + async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + return call(*args, **kwargs) + + return inner diff --git a/tenacity/_asyncio.py b/tenacity/asyncio/__init__.py similarity index 64% rename from tenacity/_asyncio.py rename to tenacity/asyncio/__init__.py index b06303f4..3ec0088b 100644 --- a/tenacity/_asyncio.py +++ b/tenacity/asyncio/__init__.py @@ -19,13 +19,29 @@ import sys import typing as t +import tenacity from tenacity import AttemptManager from tenacity import BaseRetrying from tenacity import DoAttempt from tenacity import DoSleep from tenacity import RetryCallState +from tenacity import RetryError +from tenacity import after_nothing +from tenacity import before_nothing from tenacity import _utils +# Import all built-in retry strategies for easier usage. +from .retry import RetryBaseT +from .retry import retry_all # noqa +from .retry import retry_any # noqa +from .retry import retry_if_exception # noqa +from .retry import retry_if_result # noqa +from ..retry import RetryBaseT as SyncRetryBaseT + +if t.TYPE_CHECKING: + from tenacity.stop import StopBaseT + from tenacity.wait import WaitBaseT + WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) @@ -38,15 +54,41 @@ def asyncio_sleep(duration: float) -> t.Awaitable[None]: class AsyncRetrying(BaseRetrying): - sleep: t.Callable[[float], t.Awaitable[t.Any]] - def __init__( self, - sleep: t.Callable[[float], t.Awaitable[t.Any]] = asyncio_sleep, - **kwargs: t.Any, + sleep: t.Callable[ + [t.Union[int, float]], t.Union[None, t.Awaitable[None]] + ] = asyncio_sleep, + stop: "StopBaseT" = tenacity.stop.stop_never, + wait: "WaitBaseT" = tenacity.wait.wait_none(), + retry: "t.Union[SyncRetryBaseT, RetryBaseT]" = tenacity.retry_if_exception_type(), + before: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = before_nothing, + after: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = after_nothing, + before_sleep: t.Optional[ + t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] + ] = None, + reraise: bool = False, + retry_error_cls: t.Type["RetryError"] = RetryError, + retry_error_callback: t.Optional[ + t.Callable[["RetryCallState"], t.Union[t.Any, t.Awaitable[t.Any]]] + ] = None, ) -> None: - super().__init__(**kwargs) - self.sleep = sleep + super().__init__( + sleep=sleep, # type: ignore[arg-type] + stop=stop, + wait=wait, + retry=retry, # type: ignore[arg-type] + before=before, # type: ignore[arg-type] + after=after, # type: ignore[arg-type] + before_sleep=before_sleep, # type: ignore[arg-type] + reraise=reraise, + retry_error_cls=retry_error_cls, + retry_error_callback=retry_error_callback, + ) async def __call__( # type: ignore[override] self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any @@ -65,31 +107,21 @@ async def __call__( # type: ignore[override] retry_state.set_result(result) elif isinstance(do, DoSleep): retry_state.prepare_for_next_attempt() - await self.sleep(do) + await self.sleep(do) # type: ignore[misc] else: return do # type: ignore[no-any-return] - @classmethod - def _wrap_action_func(cls, fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: - if _utils.is_coroutine_callable(fn): - return fn - - async def inner(*args: t.Any, **kwargs: t.Any) -> t.Any: - return fn(*args, **kwargs) - - return inner - def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None: - self.iter_state.actions.append(self._wrap_action_func(fn)) + self.iter_state.actions.append(_utils.wrap_to_async_func(fn)) async def _run_retry(self, retry_state: "RetryCallState") -> None: # type: ignore[override] - self.iter_state.retry_run_result = await self._wrap_action_func(self.retry)( + self.iter_state.retry_run_result = await _utils.wrap_to_async_func(self.retry)( retry_state ) async def _run_wait(self, retry_state: "RetryCallState") -> None: # type: ignore[override] if self.wait: - sleep = await self._wrap_action_func(self.wait)(retry_state) + sleep = await _utils.wrap_to_async_func(self.wait)(retry_state) else: sleep = 0.0 @@ -97,7 +129,7 @@ async def _run_wait(self, retry_state: "RetryCallState") -> None: # type: ignor async def _run_stop(self, retry_state: "RetryCallState") -> None: # type: ignore[override] self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start - self.iter_state.stop_run_result = await self._wrap_action_func(self.stop)( + self.iter_state.stop_run_result = await _utils.wrap_to_async_func(self.stop)( retry_state ) @@ -127,7 +159,7 @@ async def __anext__(self) -> AttemptManager: return AttemptManager(retry_state=self._retry_state) elif isinstance(do, DoSleep): self._retry_state.prepare_for_next_attempt() - await self.sleep(do) + await self.sleep(do) # type: ignore[misc] else: raise StopAsyncIteration @@ -146,3 +178,13 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: async_wrapped.retry_with = fn.retry_with # type: ignore[attr-defined] return async_wrapped # type: ignore[return-value] + + +__all__ = [ + "retry_all", + "retry_any", + "retry_if_exception", + "retry_if_result", + "WrappedFn", + "AsyncRetrying", +] diff --git a/tenacity/asyncio/retry.py b/tenacity/asyncio/retry.py new file mode 100644 index 00000000..94b8b154 --- /dev/null +++ b/tenacity/asyncio/retry.py @@ -0,0 +1,125 @@ +# Copyright 2016–2021 Julien Danjou +# Copyright 2016 Joshua Harlow +# Copyright 2013-2014 Ray Holder +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import abc +import typing + +from tenacity import _utils +from tenacity import retry_base + +if typing.TYPE_CHECKING: + from tenacity import RetryCallState + + +class async_retry_base(retry_base): + """Abstract base class for async retry strategies.""" + + @abc.abstractmethod + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + pass + + def __and__( # type: ignore[override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_all": + return retry_all(self, other) + + def __rand__( # type: ignore[misc,override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_all": + return retry_all(other, self) + + def __or__( # type: ignore[override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_any": + return retry_any(self, other) + + def __ror__( # type: ignore[misc,override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_any": + return retry_any(other, self) + + +RetryBaseT = typing.Union[ + async_retry_base, typing.Callable[["RetryCallState"], typing.Awaitable[bool]] +] + + +class retry_if_exception(async_retry_base): + """Retry strategy that retries if an exception verifies a predicate.""" + + def __init__( + self, predicate: typing.Callable[[BaseException], typing.Awaitable[bool]] + ) -> None: + self.predicate = predicate + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + + if retry_state.outcome.failed: + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return await self.predicate(exception) + else: + return False + + +class retry_if_result(async_retry_base): + """Retries if the result verifies a predicate.""" + + def __init__( + self, predicate: typing.Callable[[typing.Any], typing.Awaitable[bool]] + ) -> None: + self.predicate = predicate + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + + if not retry_state.outcome.failed: + return await self.predicate(retry_state.outcome.result()) + else: + return False + + +class retry_any(async_retry_base): + """Retries if any of the retries condition is valid.""" + + def __init__(self, *retries: typing.Union[retry_base, async_retry_base]) -> None: + self.retries = retries + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + result = False + for r in self.retries: + result = result or await _utils.wrap_to_async_func(r)(retry_state) + if result: + break + return result + + +class retry_all(async_retry_base): + """Retries if all the retries condition are valid.""" + + def __init__(self, *retries: typing.Union[retry_base, async_retry_base]) -> None: + self.retries = retries + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + result = True + for r in self.retries: + result = result and await _utils.wrap_to_async_func(r)(retry_state) + if not result: + break + return result diff --git a/tenacity/retry.py b/tenacity/retry.py index c5e55a65..9211631b 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -30,10 +30,16 @@ def __call__(self, retry_state: "RetryCallState") -> bool: pass def __and__(self, other: "retry_base") -> "retry_all": - return retry_all(self, other) + return other.__rand__(self) + + def __rand__(self, other: "retry_base") -> "retry_all": + return retry_all(other, self) def __or__(self, other: "retry_base") -> "retry_any": - return retry_any(self, other) + return other.__ror__(self) + + def __ror__(self, other: "retry_base") -> "retry_any": + return retry_any(other, self) RetryBaseT = typing.Union[retry_base, typing.Callable[["RetryCallState"], bool]] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 24cf6ed7..48f62869 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -22,8 +22,8 @@ import tenacity from tenacity import AsyncRetrying, RetryError -from tenacity import _asyncio as tasyncio -from tenacity import retry, retry_if_result, stop_after_attempt +from tenacity import asyncio as tasyncio +from tenacity import retry, retry_if_exception, retry_if_result, stop_after_attempt from tenacity.wait import wait_fixed from .test_tenacity import NoIOErrorAfterCount, current_time_ms @@ -202,6 +202,167 @@ def lt_3(x: float) -> bool: self.assertEqual(3, result) + @asynctest + async def test_retry_with_async_result(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + async for attempt in tasyncio.AsyncRetrying( + retry=tasyncio.retry_if_result(lt_3) + ): + with attempt: + attempts += 1 + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_exc(self): + async def test(): + attempts = 0 + + class CustomException(Exception): + pass + + async def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + async for attempt in tasyncio.AsyncRetrying( + retry=tasyncio.retry_if_exception(is_exc) + ): + with attempt: + attempts += 1 + if attempts < 3: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_result_or(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + class CustomException(Exception): + pass + + def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + retry_strategy = tasyncio.retry_if_result(lt_3) | retry_if_exception(is_exc) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + if 2 < attempts < 4: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(4, result) + + @asynctest + async def test_retry_with_async_result_ror(self): + async def test(): + attempts = 0 + + def lt_3(x: float) -> bool: + return x < 3 + + class CustomException(Exception): + pass + + async def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + retry_strategy = retry_if_result(lt_3) | tasyncio.retry_if_exception(is_exc) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + if 2 < attempts < 4: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(4, result) + + @asynctest + async def test_retry_with_async_result_and(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + def gt_0(x: float) -> bool: + return x > 0 + + retry_strategy = tasyncio.retry_if_result(lt_3) & retry_if_result(gt_0) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_result_rand(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + def gt_0(x: float) -> bool: + return x > 0 + + retry_strategy = retry_if_result(gt_0) & tasyncio.retry_if_result(lt_3) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + @asynctest async def test_async_retying_iterator(self): thing = NoIOErrorAfterCount(5) From 5b00c1581a25d9777259f81562e9fc16f21c827e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:23:03 +0000 Subject: [PATCH 088/124] chore(deps): bump the github-actions group across 1 directory with 2 updates (#466) Bumps the github-actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4.1.1 to 4.1.6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.1.1...v4.1.6) Updates `actions/setup-python` from 5.0.0 to 5.1.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.0.0...v5.1.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/deploy.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fffad557..d4648a4e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,12 +34,12 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.6 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 05fb50a7..80352027 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.6 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: python-version: 3.11 From 952189b4e33c02b5cd3fb0eb82dd318087f06d66 Mon Sep 17 00:00:00 2001 From: Martin Beckert Date: Thu, 13 Jun 2024 10:46:08 +0200 Subject: [PATCH 089/124] Update index.rst: Remove * (#465) Co-authored-by: Julien Danjou --- doc/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/index.rst b/doc/source/index.rst index bdf7ff21..3f0764a8 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -79,7 +79,7 @@ Examples Basic Retry ~~~~~~~~~~~ -.. testsetup:: * +.. testsetup:: import logging # From ade0567151ea6c9494fbca94c777ab729922e527 Mon Sep 17 00:00:00 2001 From: John Litborn <11260241+jakkdl@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:33:40 +0200 Subject: [PATCH 090/124] Support Trio out-of-the-box, take 2 (#463) * Support Trio out-of-the-box This PR makes `@retry` just work when running under Trio. * Add a no-trio test environment * Switch to only testing trio in one environment * bump releasenote so it is later in history->reno puts it in the correct place in the changelog * fix mypy & pep8 checks * Update doc/source/index.rst fix example Co-authored-by: Julien Danjou * Update tests/test_tornado.py * Update tests/test_tornado.py * make _portably_async_sleep a sync function that returns an async function --------- Co-authored-by: Nathaniel J. Smith Co-authored-by: Julien Danjou --- .github/workflows/ci.yaml | 2 +- doc/source/index.rst | 18 +++++++++----- .../trio-support-retry-22bd544800cd1f36.yaml | 6 +++++ tenacity/asyncio/__init__.py | 17 ++++++++++--- tests/test_asyncio.py | 24 ++++++++++++++++++- tox.ini | 6 +++-- 6 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d4648a4e..b0e3c02d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: - python: "3.11" tox: py311 - python: "3.12" - tox: py312 + tox: py312,py312-trio - python: "3.12" tox: pep8 - python: "3.11" diff --git a/doc/source/index.rst b/doc/source/index.rst index 3f0764a8..65dd208b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -568,28 +568,34 @@ in retry strategies like ``retry_if_result``. This can be done accessing the Async and retry ~~~~~~~~~~~~~~~ -Finally, ``retry`` works also on asyncio and Tornado (>= 4.5) coroutines. +Finally, ``retry`` works also on asyncio, Trio, and Tornado (>= 4.5) coroutines. Sleeps are done asynchronously too. .. code-block:: python @retry - async def my_async_function(loop): + async def my_asyncio_function(loop): await loop.getaddrinfo('8.8.8.8', 53) +.. code-block:: python + + @retry + async def my_async_trio_function(): + await trio.socket.getaddrinfo('8.8.8.8', 53) + .. code-block:: python @retry @tornado.gen.coroutine - def my_async_function(http_client, url): + def my_async_tornado_function(http_client, url): yield http_client.fetch(url) -You can even use alternative event loops such as `curio` or `Trio` by passing the correct sleep function: +You can even use alternative event loops such as `curio` by passing the correct sleep function: .. code-block:: python - @retry(sleep=trio.sleep) - async def my_async_function(loop): + @retry(sleep=curio.sleep) + async def my_async_curio_function(): await asks.get('https://example.org') Contribute diff --git a/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml b/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml new file mode 100644 index 00000000..b8e0c149 --- /dev/null +++ b/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + If you're using `Trio `__, then + ``@retry`` now works automatically. It's no longer necessary to + pass ``sleep=trio.sleep``. diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 3ec0088b..6d63ebcf 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -46,11 +46,22 @@ WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) -def asyncio_sleep(duration: float) -> t.Awaitable[None]: +def _portable_async_sleep(seconds: float) -> t.Awaitable[None]: + # If trio is already imported, then importing it is cheap. + # If trio isn't already imported, then it's definitely not running, so we + # can skip further checks. + if "trio" in sys.modules: + # If trio is available, then sniffio is too + import trio + import sniffio + + if sniffio.current_async_library() == "trio": + return trio.sleep(seconds) + # Otherwise, assume asyncio # Lazy import asyncio as it's expensive (responsible for 25-50% of total import overhead). import asyncio - return asyncio.sleep(duration) + return asyncio.sleep(seconds) class AsyncRetrying(BaseRetrying): @@ -58,7 +69,7 @@ def __init__( self, sleep: t.Callable[ [t.Union[int, float]], t.Union[None, t.Awaitable[None]] - ] = asyncio_sleep, + ] = _portable_async_sleep, stop: "StopBaseT" = tenacity.stop.stop_never, wait: "WaitBaseT" = tenacity.wait.wait_none(), retry: "t.Union[SyncRetryBaseT, RetryBaseT]" = tenacity.retry_if_exception_type(), diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 48f62869..87165299 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -18,6 +18,13 @@ import unittest from functools import wraps +try: + import trio +except ImportError: + have_trio = False +else: + have_trio = True + import pytest import tenacity @@ -55,7 +62,7 @@ async def _retryable_coroutine_with_2_attempts(thing): thing.go() -class TestAsync(unittest.TestCase): +class TestAsyncio(unittest.TestCase): @asynctest async def test_retry(self): thing = NoIOErrorAfterCount(5) @@ -138,6 +145,21 @@ def after(retry_state): assert list(attempt_nos2) == [1, 2, 3] +@unittest.skipIf(not have_trio, "trio not installed") +class TestTrio(unittest.TestCase): + def test_trio_basic(self): + thing = NoIOErrorAfterCount(5) + + @retry + async def trio_function(): + await trio.sleep(0.00001) + return thing.go() + + trio.run(trio_function) + + assert thing.counter == thing.count + + class TestContextManager(unittest.TestCase): @asynctest async def test_do_max_attempts(self): diff --git a/tox.ini b/tox.ini index 13e5a1dc..14f8ae00 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{8,9,10,11,12}, pep8, pypy3 +envlist = py3{8,9,10,11,12,12-trio}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -8,6 +8,7 @@ sitepackages = False deps = .[test] .[doc] + trio: trio commands = py3{8,9,10,11,12},pypy3: pytest {posargs} py3{8,9,10,11,12},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build @@ -24,10 +25,11 @@ commands = deps = mypy>=1.0.0 pytest # for stubs + trio commands = mypy {posargs} [testenv:reno] basepython = python3 deps = reno -commands = reno {posargs} \ No newline at end of file +commands = reno {posargs} From 702014bc224240c1bb92bdaab250bda178fd938f Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 17 Jun 2024 09:39:37 +0200 Subject: [PATCH 091/124] ci: add support for trio in Mergify automerge (#470) Change-Id: Idc6ec012cceae67ceb11914763350b34addcce5e --- .mergify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mergify.yml b/.mergify.yml index 35e475d0..7eeace46 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -14,7 +14,7 @@ queue_rules: - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" - - "check-success=test (3.12, py312)" + - "check-success=test (3.12, py312,py312-trio)" - "check-success=test (3.12, pep8)" pull_request_rules: From ee6a8f7a76654d712fd44604d3b6d4f9af6b2249 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 17 Jun 2024 14:00:41 +0200 Subject: [PATCH 092/124] Include `tenacity.asyncio` subpackage in release dist (#474) * Include tenacity.asyncio subpackage in release dist * Add changelog entry --- releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml | 3 +++ setup.cfg | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml diff --git a/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml b/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml new file mode 100644 index 00000000..b8bcc9af --- /dev/null +++ b/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml @@ -0,0 +1,3 @@ +--- +fixes: + - Fix setuptools config to include tenacity.asyncio package in release distributions. diff --git a/setup.cfg b/setup.cfg index e6d921a8..ccb824a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,9 +23,10 @@ classifier = [options] install_requires = python_requires = >=3.8 -packages = tenacity +packages = find: [options.packages.find] +include = tenacity* exclude = tests [options.package_data] From a15fa645326ef776dbef81ee13e3833cd14bdced Mon Sep 17 00:00:00 2001 From: hasier Date: Mon, 24 Jun 2024 15:58:20 +0100 Subject: [PATCH 093/124] fix: Avoid overwriting local contexts with retry decorator (#479) * Avoid overwriting local contexts with retry decorator * Add reno release note --- ...al-context-overwrite-94190ba06a481631.yaml | 4 + tenacity/__init__.py | 10 +- tenacity/asyncio/__init__.py | 13 +- tests/test_issue_478.py | 118 ++++++++++++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml create mode 100644 tests/test_issue_478.py diff --git a/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml b/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml new file mode 100644 index 00000000..ff2ba7ee --- /dev/null +++ b/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Avoid overwriting local contexts when applying the retry decorator. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 7de36d43..06251eda 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -329,13 +329,19 @@ def wraps(self, f: WrappedFn) -> WrappedFn: f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__") ) def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: - return self(f, *args, **kw) + # Always create a copy to prevent overwriting the local contexts when + # calling the same wrapped functions multiple times in the same stack + copy = self.copy() + wrapped_f.statistics = copy.statistics # type: ignore[attr-defined] + return copy(f, *args, **kw) def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return self.copy(*args, **kwargs).wraps(f) - wrapped_f.retry = self # type: ignore[attr-defined] + # Preserve attributes + wrapped_f.retry = wrapped_f # type: ignore[attr-defined] wrapped_f.retry_with = retry_with # type: ignore[attr-defined] + wrapped_f.statistics = {} # type: ignore[attr-defined] return wrapped_f # type: ignore[return-value] diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 6d63ebcf..38b76c7e 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -175,18 +175,23 @@ async def __anext__(self) -> AttemptManager: raise StopAsyncIteration def wraps(self, fn: WrappedFn) -> WrappedFn: - fn = super().wraps(fn) + wrapped = super().wraps(fn) # Ensure wrapper is recognized as a coroutine function. @functools.wraps( fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__") ) async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: - return await fn(*args, **kwargs) + # Always create a copy to prevent overwriting the local contexts when + # calling the same wrapped functions multiple times in the same stack + copy = self.copy() + async_wrapped.statistics = copy.statistics # type: ignore[attr-defined] + return await copy(fn, *args, **kwargs) # Preserve attributes - async_wrapped.retry = fn.retry # type: ignore[attr-defined] - async_wrapped.retry_with = fn.retry_with # type: ignore[attr-defined] + async_wrapped.retry = async_wrapped # type: ignore[attr-defined] + async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined] + async_wrapped.statistics = {} # type: ignore[attr-defined] return async_wrapped # type: ignore[return-value] diff --git a/tests/test_issue_478.py b/tests/test_issue_478.py new file mode 100644 index 00000000..7489ad7c --- /dev/null +++ b/tests/test_issue_478.py @@ -0,0 +1,118 @@ +import asyncio +import typing +import unittest + +from functools import wraps + +from tenacity import RetryCallState, retry + + +def asynctest( + callable_: typing.Callable[..., typing.Any], +) -> typing.Callable[..., typing.Any]: + @wraps(callable_) + def wrapper(*a: typing.Any, **kw: typing.Any) -> typing.Any: + loop = asyncio.get_event_loop() + return loop.run_until_complete(callable_(*a, **kw)) + + return wrapper + + +MAX_RETRY_FIX_ATTEMPTS = 2 + + +class TestIssue478(unittest.TestCase): + def test_issue(self) -> None: + results = [] + + def do_retry(retry_state: RetryCallState) -> bool: + outcome = retry_state.outcome + assert outcome + ex = outcome.exception() + _subject_: str = retry_state.args[0] + + if _subject_ == "Fix": # no retry on fix failure + return False + + if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS: + return False + + if ex: + do_fix_work() + return True + + return False + + @retry(reraise=True, retry=do_retry) + def _do_work(subject: str) -> None: + if subject == "Error": + results.append(f"{subject} is not working") + raise Exception(f"{subject} is not working") + results.append(f"{subject} is working") + + def do_any_work(subject: str) -> None: + _do_work(subject) + + def do_fix_work() -> None: + _do_work("Fix") + + try: + do_any_work("Error") + except Exception as exc: + assert str(exc) == "Error is not working" + else: + assert False, "No exception caught" + + assert results == [ + "Error is not working", + "Fix is working", + "Error is not working", + ] + + @asynctest + async def test_async(self) -> None: + results = [] + + async def do_retry(retry_state: RetryCallState) -> bool: + outcome = retry_state.outcome + assert outcome + ex = outcome.exception() + _subject_: str = retry_state.args[0] + + if _subject_ == "Fix": # no retry on fix failure + return False + + if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS: + return False + + if ex: + await do_fix_work() + return True + + return False + + @retry(reraise=True, retry=do_retry) + async def _do_work(subject: str) -> None: + if subject == "Error": + results.append(f"{subject} is not working") + raise Exception(f"{subject} is not working") + results.append(f"{subject} is working") + + async def do_any_work(subject: str) -> None: + await _do_work(subject) + + async def do_fix_work() -> None: + await _do_work("Fix") + + try: + await do_any_work("Error") + except Exception as exc: + assert str(exc) == "Error is not working" + else: + assert False, "No exception caught" + + assert results == [ + "Error is not working", + "Fix is working", + "Error is not working", + ] From 33cd0e1d4ac529f2f7853eba66b8127232f1ec63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:57:46 +0000 Subject: [PATCH 094/124] chore(deps): bump actions/checkout in the github-actions group (#483) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4.1.6 to 4.1.7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.1.6...v4.1.7) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b0e3c02d..9dfa7340 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.6 + uses: actions/checkout@v4.1.7 with: fetch-depth: 0 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 80352027..688cabe4 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.6 + uses: actions/checkout@v4.1.7 with: fetch-depth: 0 From 31fe2d0cf2505bffd0cf1ffda8c7e30450ce709f Mon Sep 17 00:00:00 2001 From: hasier Date: Fri, 5 Jul 2024 08:22:20 +0100 Subject: [PATCH 095/124] 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 --- doc/source/index.rst | 36 +++++++++-- ...y-wrapper-attributes-f7a3a45b8e90f257.yaml | 6 ++ tenacity/__init__.py | 2 +- tenacity/asyncio/__init__.py | 2 +- tests/test_asyncio.py | 61 ++++++++++++++++++- tests/test_tenacity.py | 58 +++++++++++++++--- 6 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index 65dd208b..928ddd99 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 00000000..967cd29e --- /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 06251eda..02057a07 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -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] diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 38b76c7e..a9260914 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -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] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 87165299..0b74476b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -17,6 +17,7 @@ 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 @@ 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: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index e158fa6a..ecc0312c 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -25,6 +25,7 @@ from contextlib import contextmanager from copy import copy from fractions import Fraction +from unittest import mock import pytest @@ -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: @@ -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: @@ -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): @@ -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): @@ -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) @@ -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): @@ -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): @@ -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): From a662bbb487cd6d34541824589f8e8c7a1f7791bb Mon Sep 17 00:00:00 2001 From: YuXuan Tay Date: Mon, 29 Jul 2024 20:10:10 +0800 Subject: [PATCH 096/124] Respects `min` argument for `wait_random_exponential` (#425) * Respects `min` argument for wait_random_exponential * Update test_tenacity.py * Update test_tenacity.py * Update test_tenacity.py --- ...wait-random-exponential-min-2a4b7eed9f002436.yaml | 4 ++++ tenacity/wait.py | 2 +- tests/test_tenacity.py | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml diff --git a/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml b/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml new file mode 100644 index 00000000..34efd1c2 --- /dev/null +++ b/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Respects `min` arg for `wait_random_exponential` diff --git a/tenacity/wait.py b/tenacity/wait.py index 3addbb9c..dc3c8505 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -197,7 +197,7 @@ class wait_random_exponential(wait_exponential): def __call__(self, retry_state: "RetryCallState") -> float: high = super().__call__(retry_state=retry_state) - return random.uniform(0, high) + return random.uniform(self.min, high) class wait_exponential_jitter(wait_base): diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index ecc0312c..b76fec2c 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -472,9 +472,17 @@ def test_wait_random_exponential(self): self._assert_inclusive_range(fn(make_retry_state(8, 0)), 0, 60.0) self._assert_inclusive_range(fn(make_retry_state(9, 0)), 0, 60.0) - fn = tenacity.wait_random_exponential(10, 5) + # max wait + max_wait = 5 + fn = tenacity.wait_random_exponential(10, max_wait) for _ in range(1000): - self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0.00, 5.00) + self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0.00, max_wait) + + # min wait + min_wait = 5 + fn = tenacity.wait_random_exponential(min=min_wait) + for _ in range(1000): + self._assert_inclusive_range(fn(make_retry_state(1, 0)), min_wait, 5) # Default arguments exist fn = tenacity.wait_random_exponential() From e2482a6f90c432992d2f094774131531d5983b75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 19:32:50 +0000 Subject: [PATCH 097/124] chore(deps): bump actions/setup-python in the github-actions group (#488) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.1.0 to 5.1.1 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.1.0...v5.1.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9dfa7340..b79fa453 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.1.0 + uses: actions/setup-python@v5.1.1 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 688cabe4..671a5507 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.1.0 + uses: actions/setup-python@v5.1.1 with: python-version: 3.11 From 7fbaf4d519041d9054ad6dca46b172686a2faf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?LY=EF=BC=88=E9=80=80=E7=BD=91/offline=EF=BC=89?= <51789698+Young-Lord@users.noreply.github.com> Date: Mon, 26 Aug 2024 17:59:50 +0800 Subject: [PATCH 098/124] docs: unify the name `before sleep strategy` in docstring and comments (#491) --- tenacity/__init__.py | 2 +- tenacity/before_sleep.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 02057a07..72eba04f 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -76,7 +76,7 @@ from .after import after_log # noqa from .after import after_nothing # noqa -# Import all built-in after strategies for easier usage. +# Import all built-in before sleep strategies for easier usage. from .before_sleep import before_sleep_log # noqa from .before_sleep import before_sleep_nothing # noqa diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index d04edcf9..153edb7a 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -25,7 +25,7 @@ def before_sleep_nothing(retry_state: "RetryCallState") -> None: - """Before call strategy that does nothing.""" + """Before sleep strategy that does nothing.""" def before_sleep_log( @@ -33,7 +33,7 @@ def before_sleep_log( log_level: int, exc_info: bool = False, ) -> typing.Callable[["RetryCallState"], None]: - """Before call strategy that logs to some logger the attempt.""" + """Before sleep strategy that logs to some logger the attempt.""" def log_it(retry_state: "RetryCallState") -> None: local_exc_info: BaseException | bool | None From 60358bb9340c9a73026e94e0c6de4a892b14eecd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Sep 2024 19:53:28 +0000 Subject: [PATCH 099/124] chore(deps): bump actions/setup-python in the github-actions group (#493) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.1.1 to 5.2.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.1.1...v5.2.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b79fa453..163380e0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 671a5507..f3612e59 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: 3.11 From 11af5c1397b65f126cde67922da97a1f37aa0af4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 19:51:31 +0000 Subject: [PATCH 100/124] chore(deps): bump actions/checkout in the github-actions group (#500) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4.1.7 to 4.2.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.1.7...v4.2.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 163380e0..2b9b4f92 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: fetch-depth: 0 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index f3612e59..558aaf0d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: fetch-depth: 0 From 0d40e76f7d06d631fb127e1ec58c8bd776e70d49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 19:48:24 +0000 Subject: [PATCH 101/124] chore(deps): bump the github-actions group with 2 updates (#502) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4.2.0 to 4.2.2 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.2.0...v4.2.2) Updates `actions/setup-python` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/deploy.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b9b4f92..4a779233 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,12 +34,12 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.2 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.2.0 + uses: actions/setup-python@v5.3.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 558aaf0d..beb51368 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.2 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.2.0 + uses: actions/setup-python@v5.3.0 with: python-version: 3.11 From 72db2740cab8248d2d9b7b9a0716cb1ea9867051 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Feb 2025 19:19:08 +0000 Subject: [PATCH 102/124] chore(deps): bump actions/setup-python in the github-actions group (#513) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.3.0 to 5.4.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4a779233..3b1757b6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index beb51368..fb00284f 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: 3.11 From 320335902409ed2e09f21cb83431de7ee7a0c2a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 18 Feb 2025 02:26:58 -0600 Subject: [PATCH 103/124] Test with Python 3.13 (#480) --- .github/workflows/ci.yaml | 4 +++- .mergify.yml | 3 ++- setup.cfg | 1 + tox.ini | 9 +++++---- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b1757b6..7a5722a9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,9 +27,11 @@ jobs: - python: "3.11" tox: py311 - python: "3.12" - tox: py312,py312-trio + tox: py312 - python: "3.12" tox: pep8 + - python: "3.13" + tox: py313,py313-trio - python: "3.11" tox: mypy steps: diff --git a/.mergify.yml b/.mergify.yml index 7eeace46..bdf9ca54 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -14,7 +14,8 @@ queue_rules: - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" - - "check-success=test (3.12, py312,py312-trio)" + - "check-success=test (3.12, py312)" + - "check-success=test (3.13, py313,py313-trio)" - "check-success=test (3.12, pep8)" pull_request_rules: diff --git a/setup.cfg b/setup.cfg index ccb824a2..be14f694 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifier = Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Topic :: Utilities [options] diff --git a/tox.ini b/tox.ini index 14f8ae00..fb76d04b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ [tox] -envlist = py3{8,9,10,11,12,12-trio}, pep8, pypy3 +# we only test trio on latest python version +envlist = py3{8,9,10,11,12,13,13-trio}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -10,9 +11,9 @@ deps = .[doc] trio: trio commands = - py3{8,9,10,11,12},pypy3: pytest {posargs} - py3{8,9,10,11,12},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{8,9,10,11,12},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{8,9,10,11,12,13},pypy3: pytest {posargs} + py3{8,9,10,11,12,13},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{8,9,10,11,12,13},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 3e2c18175944c1896a1065809db15378d545cdce Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 18 Feb 2025 09:30:03 +0100 Subject: [PATCH 104/124] ci: remove Python 3.8 support (#515) Change-Id: Idfd1cffe0f9adad4c2d293636b099060194a7c8c --- .github/workflows/ci.yaml | 2 -- .mergify.yml | 1 - pyproject.toml | 2 +- setup.cfg | 3 +-- tox.ini | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a5722a9..c0195d38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,8 +18,6 @@ jobs: strategy: matrix: include: - - python: "3.8" - tox: py38 - python: "3.9" tox: py39 - python: "3.10" diff --git a/.mergify.yml b/.mergify.yml index bdf9ca54..22cbc6f9 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -10,7 +10,6 @@ queue_rules: - files ~= ^releasenotes/notes/ - label = no-changelog - author = dependabot[bot] - - "check-success=test (3.8, py38)" - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" diff --git a/pyproject.toml b/pyproject.toml index f4bda948..2ff52b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ build-backend="setuptools.build_meta" [tool.ruff] line-length = 88 indent-width = 4 -target-version = "py38" +target-version = "py39" [tool.mypy] strict = true diff --git a/setup.cfg b/setup.cfg index be14f694..647a7b3c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,6 @@ classifier = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 @@ -23,7 +22,7 @@ classifier = [options] install_requires = -python_requires = >=3.8 +python_requires = >=3.9 packages = find: [options.packages.find] diff --git a/tox.ini b/tox.ini index fb76d04b..cbf5151b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] # we only test trio on latest python version -envlist = py3{8,9,10,11,12,13,13-trio}, pep8, pypy3 +envlist = py3{9,10,11,12,13,13-trio}, pep8, pypy3 skip_missing_interpreters = True [testenv] From 212c47c05fec89c3aca8c4fec0b426c9f33036e8 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 18 Feb 2025 09:32:05 +0100 Subject: [PATCH 105/124] ci: update ubuntu image (#516) Change-Id: Ibe1df683f0a433a982ab1967514ff86381692a17 Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0195d38..0d0e011b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ concurrency: jobs: test: timeout-minutes: 20 - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: include: From bfbf17314612b8546a650c4b56d6c6438e6857df Mon Sep 17 00:00:00 2001 From: Doctor <50728601+ThirVondukr@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:36:46 +0300 Subject: [PATCH 106/124] fix: return "Self" from "BaseRetrying.copy" (#518) --- tenacity/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 72eba04f..e274c215 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -88,6 +88,8 @@ if t.TYPE_CHECKING: import types + from typing_extensions import Self + from . import asyncio as tasyncio from .retry import RetryBaseT from .stop import StopBaseT @@ -255,7 +257,7 @@ def copy( retry_error_callback: t.Union[ t.Optional[t.Callable[["RetryCallState"], t.Any]], object ] = _unset, - ) -> "BaseRetrying": + ) -> "Self": """Copy this object with some parameters changed if needed.""" return self.__class__( sleep=_first_set(sleep, self.sleep), From f9a879c531ff4be938309aae6c69f46fc5b732d8 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 25 Mar 2025 13:45:40 +0100 Subject: [PATCH 107/124] ci: upload on PyPI using trusted publishing (#520) Change-Id: Ie927da8fbe09caab1fae7fc368e3a9383591bdf7 --- .github/workflows/deploy.yaml | 34 ---------------------------------- .github/workflows/release.yml | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 34 deletions(-) delete mode 100644 .github/workflows/deploy.yaml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index fb00284f..00000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: Release deploy - -on: - release: - types: - - published - -jobs: - publish: - timeout-minutes: 20 - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v4.2.2 - with: - fetch-depth: 0 - - - name: Setup Python 🔧 - uses: actions/setup-python@v5.4.0 - with: - python-version: 3.11 - - - name: Build 🔧 & Deploy 🚀 - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - pip install tox twine wheel - - echo -e "[pypi]" >> ~/.pypirc - echo -e "username = __token__" >> ~/.pypirc - echo -e "password = $PYPI_TOKEN" >> ~/.pypirc - - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..13760228 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +catname: upload release to PyPI +on: + release: + types: + - published + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v5.4.0 + with: + python-version: 3.13 + + - name: Install build + run: | + pip install setuptools-scm wheel + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From b4dfa3fe88707f42561d11dea4bca06c45fb5523 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 19:54:51 +0000 Subject: [PATCH 108/124] chore(deps): bump actions/setup-python in the github-actions group (#522) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.4.0 to 5.5.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.4.0...v5.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d0e011b..9f869d48 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13760228..86977238 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-python@v5.4.0 + - uses: actions/setup-python@v5.5.0 with: python-version: 3.13 From a44271f3d7d917d81e432ce7f85d448b437b4e41 Mon Sep 17 00:00:00 2001 From: Robert Schweizer Date: Wed, 2 Apr 2025 10:07:40 +0200 Subject: [PATCH 109/124] fix: Add re.Pattern to allowed match types (#497) Suggested in https://github.com/jd/tenacity/issues/436#issuecomment-1933445424 --- .../notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml | 4 ++++ tenacity/retry.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml diff --git a/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml b/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml new file mode 100644 index 00000000..c71ffd98 --- /dev/null +++ b/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Added `re.Pattern` to allowed match types. diff --git a/tenacity/retry.py b/tenacity/retry.py index 9211631b..9f099ec0 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -207,7 +207,7 @@ class retry_if_exception_message(retry_if_exception): def __init__( self, message: typing.Optional[str] = None, - match: typing.Optional[str] = None, + match: typing.Union[None, str, typing.Pattern[str]] = None, ) -> None: if message and match: raise TypeError( @@ -242,7 +242,7 @@ class retry_if_not_exception_message(retry_if_exception_message): def __init__( self, message: typing.Optional[str] = None, - match: typing.Optional[str] = None, + match: typing.Union[None, str, typing.Pattern[str]] = None, ) -> None: super().__init__(message, match) # invert predicate From 2b173a1039009773dbf5d377f95cc8aabe83bf58 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 2 Apr 2025 10:20:14 +0200 Subject: [PATCH 110/124] ci: fix typo Change-Id: I611c088f3aeb7924c2f230ec206af792f85a7c1d --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86977238..dd625589 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -catname: upload release to PyPI +name: upload release to PyPI on: release: types: From 62787c34bb052d28d814bc07e5c3caed22cd73a2 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 2 Apr 2025 10:23:25 +0200 Subject: [PATCH 111/124] ci: fix build Change-Id: If07b01c417e119d5756e7d0ac7cb6e401d00dbd6 --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd625589..fd23502c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,5 +26,9 @@ jobs: run: | pip install setuptools-scm wheel + - name: Build + run: | + python setup.py sdist bdist_wheel + - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From 012dc0d77e527c6940d90300dcbe7ba8a3830ba4 Mon Sep 17 00:00:00 2001 From: Alex Guinman Date: Thu, 3 Apr 2025 02:20:33 +1000 Subject: [PATCH 112/124] Apply formatting to num seconds in before_sleep_log (#489) * Apply formatting to num seconds * Update default format to be consistent --- tenacity/after.py | 2 +- tenacity/before_sleep.py | 3 ++- tests/test_after.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tenacity/after.py b/tenacity/after.py index aa3cc9df..1735b0b6 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -31,7 +31,7 @@ def after_nothing(retry_state: "RetryCallState") -> None: def after_log( logger: "logging.Logger", log_level: int, - sec_format: str = "%0.3f", + sec_format: str = "%.3g", ) -> typing.Callable[["RetryCallState"], None]: """After call strategy that logs to some logger the finished attempt.""" diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index 153edb7a..d71ea8e1 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -32,6 +32,7 @@ def before_sleep_log( logger: "logging.Logger", log_level: int, exc_info: bool = False, + sec_format: str = "%.3g", ) -> typing.Callable[["RetryCallState"], None]: """Before sleep strategy that logs to some logger the attempt.""" @@ -65,7 +66,7 @@ def log_it(retry_state: "RetryCallState") -> None: logger.log( log_level, f"Retrying {fn_name} " - f"in {retry_state.next_action.sleep} seconds as it {verb} {value}.", + f"in {sec_format % retry_state.next_action.sleep} seconds as it {verb} {value}.", exc_info=local_exc_info, ) diff --git a/tests/test_after.py b/tests/test_after.py index 0cb4f716..cec8d909 100644 --- a/tests/test_after.py +++ b/tests/test_after.py @@ -27,7 +27,7 @@ def test_01_default(self): log = unittest.mock.MagicMock(spec="logging.Logger.log") logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) - sec_format = "%0.3f" + sec_format = "%.3g" delay_since_first_attempt = 0.1 retry_state = test_tenacity.make_retry_state( From 077aaa84eaa91051d6a779cd1edb68727d7daebb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 19:16:34 +0000 Subject: [PATCH 113/124] chore(deps): bump actions/setup-python in the github-actions group (#524) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.5.0 to 5.6.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.5.0...v5.6.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9f869d48..4fdd62b1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd23502c..1e94465e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-python@v5.5.0 + - uses: actions/setup-python@v5.6.0 with: python-version: 3.13 From eed7d785e667df145c0e3eeddff59af64e4e860d Mon Sep 17 00:00:00 2001 From: Sandro Bonazzola Date: Fri, 27 Jun 2025 10:18:58 +0200 Subject: [PATCH 114/124] Support Python 3.14 (#528) Signed-off-by: Sandro Bonazzola --- .github/workflows/ci.yaml | 10 ++++++---- .github/workflows/release.yml | 2 +- .mergify.yml | 5 +++-- .../notes/support-py3.14-14928188cab53b99.yaml | 3 +++ setup.cfg | 1 + tenacity/__init__.py | 12 ++++-------- tests/test_asyncio.py | 3 +-- tests/test_issue_478.py | 3 +-- tox.ini | 8 ++++---- 9 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 releasenotes/notes/support-py3.14-14928188cab53b99.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4fdd62b1..3ecc1505 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,11 +26,13 @@ jobs: tox: py311 - python: "3.12" tox: py312 - - python: "3.12" - tox: pep8 - python: "3.13" - tox: py313,py313-trio - - python: "3.11" + tox: py313 + - python: "3.14" + tox: py314,py314-trio + - python: "3.14" + tox: pep8 + - python: "3.14" tox: mypy steps: - name: Checkout 🛎️ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e94465e..63e0b7c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-python@v5.6.0 with: - python-version: 3.13 + python-version: 3.14 - name: Install build run: | diff --git a/.mergify.yml b/.mergify.yml index 22cbc6f9..142c4f47 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -14,8 +14,9 @@ queue_rules: - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" - "check-success=test (3.12, py312)" - - "check-success=test (3.13, py313,py313-trio)" - - "check-success=test (3.12, pep8)" + - "check-success=test (3.13, py313)" + - "check-success=test (3.14, py314,py314-trio)" + - "check-success=test (3.14, pep8)" pull_request_rules: - name: warn on no changelog diff --git a/releasenotes/notes/support-py3.14-14928188cab53b99.yaml b/releasenotes/notes/support-py3.14-14928188cab53b99.yaml new file mode 100644 index 00000000..3eec60a3 --- /dev/null +++ b/releasenotes/notes/support-py3.14-14928188cab53b99.yaml @@ -0,0 +1,3 @@ +--- +features: + - Python 3.14 support has been added. diff --git a/setup.cfg b/setup.cfg index 647a7b3c..b4aafb33 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifier = Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Topic :: Utilities [options] diff --git a/tenacity/__init__.py b/tenacity/__init__.py index e274c215..e93793cc 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -307,19 +307,15 @@ def statistics(self) -> t.Dict[str, t.Any]: future we may provide a way to aggregate the various statistics from each thread). """ - try: - return self._local.statistics # type: ignore[no-any-return] - except AttributeError: + if not hasattr(self._local, "statistics"): self._local.statistics = t.cast(t.Dict[str, t.Any], {}) - return self._local.statistics + return self._local.statistics # type: ignore[no-any-return] @property def iter_state(self) -> IterState: - try: - return self._local.iter_state # type: ignore[no-any-return] - except AttributeError: + if not hasattr(self._local, "iter_state"): self._local.iter_state = IterState() - return self._local.iter_state + return self._local.iter_state # type: ignore[no-any-return] def wraps(self, f: WrappedFn) -> WrappedFn: """Wrap a function for retrying. diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 0b74476b..f6793f0b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -40,8 +40,7 @@ def asynctest(callable_): @wraps(callable_) def wrapper(*a, **kw): - loop = asyncio.get_event_loop() - return loop.run_until_complete(callable_(*a, **kw)) + return asyncio.run(callable_(*a, **kw)) return wrapper diff --git a/tests/test_issue_478.py b/tests/test_issue_478.py index 7489ad7c..83182ac4 100644 --- a/tests/test_issue_478.py +++ b/tests/test_issue_478.py @@ -12,8 +12,7 @@ def asynctest( ) -> typing.Callable[..., typing.Any]: @wraps(callable_) def wrapper(*a: typing.Any, **kw: typing.Any) -> typing.Any: - loop = asyncio.get_event_loop() - return loop.run_until_complete(callable_(*a, **kw)) + return asyncio.run(callable_(*a, **kw)) return wrapper diff --git a/tox.ini b/tox.ini index cbf5151b..e3641348 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] # we only test trio on latest python version -envlist = py3{9,10,11,12,13,13-trio}, pep8, pypy3 +envlist = py3{9,10,11,12,13,14,14-trio}, pep8, pypy3 skip_missing_interpreters = True [testenv] @@ -11,9 +11,9 @@ deps = .[doc] trio: trio commands = - py3{8,9,10,11,12,13},pypy3: pytest {posargs} - py3{8,9,10,11,12,13},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{8,9,10,11,12,13},pypy3: sphinx-build -a -E -W -b html doc/source doc/build + py3{8,9,10,11,12,13,14},pypy3: pytest {posargs} + py3{8,9,10,11,12,13,14},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{8,9,10,11,12,13,14},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 From 22399207ada81342d0a816dade9d71f61221183e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 08:38:53 +0000 Subject: [PATCH 115/124] chore(deps): bump actions/checkout in the github-actions group (#535) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4.2.2 to 5.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ecc1505..324b4446 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63e0b7c3..f4e71849 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 with: fetch-depth: 0 fetch-tags: true From b12af2e23bcf7909e96f7b4e4aa0793737e2b0f1 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 3 Sep 2025 10:53:26 +0200 Subject: [PATCH 116/124] ci(mergify): enable autoqueue and fix conditions (#536) --- .mergify.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 142c4f47..feae5686 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,6 +1,7 @@ queue_rules: - name: default merge_method: squash + autoqueue: true queue_conditions: - or: - author = jd @@ -22,7 +23,8 @@ pull_request_rules: - name: warn on no changelog conditions: - -files~=^releasenotes/notes/ - - label!=no-changelog + - label != no-changelog + - author != dependabot[bot] - -closed actions: comment: @@ -31,11 +33,6 @@ pull_request_rules: [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add a changelog entry. - - name: automatic queue - conditions: [] - actions: - queue: - - name: dismiss reviews conditions: [] actions: From e8d5f3b91b4da34e799c9aeff29e3ee3631047c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:07:13 +0000 Subject: [PATCH 117/124] chore(deps): bump actions/setup-python in the github-actions group (#538) Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5.6.0 to 6.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.6.0...v6.0.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 324b4446..07ebdc7b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4e71849..53ff536b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-python@v5.6.0 + - uses: actions/setup-python@v6.0.0 with: python-version: 3.14 From d6e57dd1bd6eb1971bbe57169f98cd9c4f7aae6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20P=C3=89ROUX?= Date: Sat, 11 Oct 2025 10:48:59 +0200 Subject: [PATCH 118/124] Typing: Accept non-standard logger in helpers logging something (#540) * Accept non-standard logger in helpers logging something * Update changelog * Fix formatting --- .../notes/logging-protocol-a4cf0f786f21e4ee.yaml | 5 +++++ tenacity/_utils.py | 12 ++++++++++++ tenacity/after.py | 4 +--- tenacity/before.py | 4 +--- tenacity/before_sleep.py | 4 +--- 5 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml diff --git a/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml b/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml new file mode 100644 index 00000000..ad8206df --- /dev/null +++ b/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml @@ -0,0 +1,5 @@ +--- +other: + - | + Accept non-standard logger in helpers logging something (eg: structlog, loguru...) + diff --git a/tenacity/_utils.py b/tenacity/_utils.py index f11a0888..be148719 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -25,6 +25,18 @@ MAX_WAIT = sys.maxsize / 2 +class LoggerProtocol(typing.Protocol): + """ + Protocol used by utils expecting a logger (eg: before_log). + + Compatible with logging, structlog, loguru, etc... + """ + + def log( + self, level: int, msg: str, /, *args: typing.Any, **kwargs: typing.Any + ) -> typing.Any: ... + + def find_ordinal(pos_num: int) -> str: # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers if pos_num == 0: diff --git a/tenacity/after.py b/tenacity/after.py index 1735b0b6..a4b89224 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -19,8 +19,6 @@ from tenacity import _utils if typing.TYPE_CHECKING: - import logging - from tenacity import RetryCallState @@ -29,7 +27,7 @@ def after_nothing(retry_state: "RetryCallState") -> None: def after_log( - logger: "logging.Logger", + logger: _utils.LoggerProtocol, log_level: int, sec_format: str = "%.3g", ) -> typing.Callable[["RetryCallState"], None]: diff --git a/tenacity/before.py b/tenacity/before.py index 366235af..5d9aa24e 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -19,8 +19,6 @@ from tenacity import _utils if typing.TYPE_CHECKING: - import logging - from tenacity import RetryCallState @@ -29,7 +27,7 @@ def before_nothing(retry_state: "RetryCallState") -> None: def before_log( - logger: "logging.Logger", log_level: int + logger: _utils.LoggerProtocol, log_level: int ) -> typing.Callable[["RetryCallState"], None]: """Before call strategy that logs to some logger the attempt.""" diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py index d71ea8e1..a822cb86 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -19,8 +19,6 @@ from tenacity import _utils if typing.TYPE_CHECKING: - import logging - from tenacity import RetryCallState @@ -29,7 +27,7 @@ def before_sleep_nothing(retry_state: "RetryCallState") -> None: def before_sleep_log( - logger: "logging.Logger", + logger: _utils.LoggerProtocol, log_level: int, exc_info: bool = False, sec_format: str = "%.3g", From 815c34fec2c8d64fe2bc8a6bdd79bfb41f1938c6 Mon Sep 17 00:00:00 2001 From: Davide Cunial Date: Wed, 5 Nov 2025 07:58:22 +0100 Subject: [PATCH 119/124] feat(wait): add `wait_exception` strategy (#541) Add `wait_exception` which calls a user-defined predicate with the caught exception to determine the wait time. Raises `RuntimeError` if the outcome has no exception. --- tenacity/__init__.py | 2 ++ tenacity/wait.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_tenacity.py | 18 ++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index e93793cc..b0b47946 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -59,6 +59,7 @@ # Import all built-in wait strategies for easier usage. from .wait import wait_chain # noqa from .wait import wait_combine # noqa +from .wait import wait_exception # noqa from .wait import wait_exponential # noqa from .wait import wait_fixed # noqa from .wait import wait_incrementing # noqa @@ -686,6 +687,7 @@ def wrap(f: WrappedFn) -> WrappedFn: "stop_when_event_set", "wait_chain", "wait_combine", + "wait_exception", "wait_exponential", "wait_fixed", "wait_incrementing", diff --git a/tenacity/wait.py b/tenacity/wait.py index dc3c8505..abfde775 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -113,6 +113,45 @@ def __call__(self, retry_state: "RetryCallState") -> float: return wait_func(retry_state=retry_state) +class wait_exception(wait_base): + """Wait strategy that waits the amount of time returned by the predicate. + + The predicate is passed the exception object. Based on the exception, the + user can decide how much time to wait before retrying. + + For example:: + + def http_error(exception: BaseException) -> float: + if ( + isinstance(exception, requests.HTTPError) + and exception.response.status_code == requests.codes.too_many_requests + ): + return float(exception.response.headers.get("Retry-After", "1")) + return 60.0 + + + @retry( + stop=stop_after_attempt(3), + wait=wait_exception(http_error), + ) + def http_get_request(url: str) -> None: + response = requests.get(url) + response.raise_for_status() + """ + + def __init__(self, predicate: typing.Callable[[BaseException], float]) -> None: + self.predicate = predicate + + def __call__(self, retry_state: "RetryCallState") -> float: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return self.predicate(exception) + + class wait_incrementing(wait_base): """Wait an incremental amount of time after each attempt. diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b76fec2c..5e5dddfc 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -369,6 +369,24 @@ def test_wait_combine(self): self.assertLess(w, 8) self.assertGreaterEqual(w, 5) + def test_wait_exception(self): + def predicate(exc): + if isinstance(exc, ValueError): + return 3.5 + return 10.0 + + r = Retrying(wait=tenacity.wait_exception(predicate)) + + fut1 = tenacity.Future.construct(1, ValueError(), True) + self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut1)), 3.5) + + fut2 = tenacity.Future.construct(1, KeyError(), True) + self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut2)), 10.0) + + fut3 = tenacity.Future.construct(1, None, False) + with self.assertRaises(RuntimeError): + r.wait(make_retry_state(1, 0, last_result=fut3)) + def test_wait_double_sum(self): r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) # Test it a few time since it's random From 0f55245b8da5c4cc8385c2f692164a6ff52cd88e Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Nov 2025 08:06:08 +0100 Subject: [PATCH 120/124] ci: remove reno requirements (#542) We're going to leverage GitHub release directly. Change-Id: Ia6be88bf875b69d8ae6aec2287cd3795c81adcd6 --- .mergify.yml | 17 ----------------- doc/source/index.rst | 14 -------------- 2 files changed, 31 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index feae5686..48d990dd 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -7,10 +7,6 @@ queue_rules: - author = jd - "#approved-reviews-by >= 1" - author = dependabot[bot] - - or: - - files ~= ^releasenotes/notes/ - - label = no-changelog - - author = dependabot[bot] - "check-success=test (3.9, py39)" - "check-success=test (3.10, py310)" - "check-success=test (3.11, py311)" @@ -20,19 +16,6 @@ queue_rules: - "check-success=test (3.14, pep8)" pull_request_rules: - - name: warn on no changelog - conditions: - - -files~=^releasenotes/notes/ - - label != no-changelog - - author != dependabot[bot] - - -closed - actions: - comment: - message: > - ⚠️ No release notes detected. Please make sure to use - [reno](https://docs.openstack.org/reno/latest/user/usage.html) to add - a changelog entry. - - name: dismiss reviews conditions: [] actions: diff --git a/doc/source/index.rst b/doc/source/index.rst index 928ddd99..6a5b66bb 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -637,17 +637,3 @@ Contribute #. Make the docs better (or more detailed, or more easier to read, or ...) .. _`the repository`: https://github.com/jd/tenacity - -Changelogs -~~~~~~~~~~ - -`reno`_ is used for managing changelogs. Take a look at their usage docs. - -The doc generation will automatically compile the changelogs. You just need to add them. - -.. code-block:: sh - - # Opens a template file in an editor - tox -e reno -- new some-slug-for-my-change --edit - -.. _`reno`: https://docs.openstack.org/reno/latest/user/usage.html From e792bbaf0cab3685c8000899cb9f61e04d6f3e23 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 2 Dec 2025 11:09:22 +0100 Subject: [PATCH 121/124] ci: fix mypy (#546) mypy changed the type of error reported Change-Id: Ice231e0e04dfafc5f421e6d3b416c1504bcef77d --- tenacity/tornadoweb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 44323e40..a0aa6491 100644 --- a/tenacity/tornadoweb.py +++ b/tenacity/tornadoweb.py @@ -37,7 +37,7 @@ def __init__( super().__init__(**kwargs) self.sleep = sleep - @gen.coroutine # type: ignore[misc] + @gen.coroutine # type: ignore[untyped-decorator] def __call__( self, fn: "typing.Callable[..., typing.Union[typing.Generator[typing.Any, typing.Any, _RetValT], Future[_RetValT]]]", From c35a4b341ef5e553c670290fbd8835b59d5f08a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:23:04 +0000 Subject: [PATCH 122/124] chore(deps): bump the github-actions group with 2 updates (#545) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 5.0.0 to 6.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5.0.0...v6.0.0) Updates `actions/setup-python` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6.0.0...v6.1.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07ebdc7b..1c775838 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,12 +36,12 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.0 with: fetch-depth: 0 - name: Setup Python 🔧 - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ matrix.python }} allow-prereleases: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53ff536b..e6f607df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@v6.0.0 with: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-python@v6.0.0 + - uses: actions/setup-python@v6.1.0 with: python-version: 3.14 From ef12c9ed1df4dec3748b1fa9304527245b041d2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:04:28 +0000 Subject: [PATCH 123/124] chore(deps): bump actions/checkout in the github-actions group (#547) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6.0.0...v6.0.1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1c775838..0c35a1fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ jobs: tox: mypy steps: - name: Checkout 🛎️ - uses: actions/checkout@v6.0.0 + uses: actions/checkout@v6.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6f607df..206c183f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v6.0.0 + - uses: actions/checkout@v6.0.1 with: fetch-depth: 0 fetch-tags: true From 21ae7d0cc27069defd111e8ec81407f6d14089f6 Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:26:04 +0530 Subject: [PATCH 124/124] docs: fix syntax error in wait_chain docstring example (#548) Add missing closing bracket in the wait_chain docstring example code. Also fix the multi-line string in the print statement. Closes #250 --- tenacity/wait.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tenacity/wait.py b/tenacity/wait.py index abfde775..1cc17ea2 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -98,10 +98,10 @@ class wait_chain(wait_base): @retry(wait=wait_chain(*[wait_fixed(1) for i in range(3)] + [wait_fixed(2) for j in range(5)] + - [wait_fixed(5) for k in range(4))) + [wait_fixed(5) for k in range(4)])) def wait_chained(): - print("Wait 1s for 3 attempts, 2s for 5 attempts and 5s - thereafter.") + print("Wait 1s for 3 attempts, 2s for 5 attempts and 5s " + "thereafter.") """ def __init__(self, *strategies: wait_base) -> None: