diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a48c80ad..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,98 +0,0 @@ -version: 2 - -jobs: - pep8: - docker: - - image: circleci/python:3.8 - steps: - - checkout - - run: - 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 - 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 - deploy: - docker: - - image: circleci/python:3.8 - 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: - - pep8 - - py27 - - py35 - - py36 - - py37 - - py38 - - deploy: - filters: - tags: - only: /[0-9]+(\.[0-9]+)*/ - branches: - ignore: /.*/ 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/.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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..03d34198 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,45 @@ +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-24.04 + strategy: + matrix: + include: + - python: "3.10" + task: check + - python: "3.11" + task: check + - python: "3.12" + task: check + - python: "3.13" + task: check + - python: "3.14" + task: check + - python: "3.14" + task: lint + - python: "3.14" + task: mypy + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Setup uv 🔧 + uses: astral-sh/setup-uv@v5 + + - name: Build 🔧 & Test 🔍 + run: uv run --python ${{ matrix.python }} poe ${{ matrix.task }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..55a6903c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: upload release to PyPI +on: + release: + types: + - published + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-24.04 + environment: release + permissions: + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: astral-sh/setup-uv@v5 + + - name: Build + run: uv build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 933f759e..3c966caa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,11 @@ dist *.pyc *.egg-info build -.tox/ +.venv/ +uv.lock AUTHORS ChangeLog -.eggs/ doc/_build +tenacity/_version.py /.pytest_cache diff --git a/.mergify.yml b/.mergify.yml index c3c6be04..0135b7c9 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,32 +1,21 @@ +queue_rules: + - name: default + merge_method: squash + autoqueue: true + queue_conditions: + - or: + - author = jd + - "#approved-reviews-by >= 1" + - author = dependabot[bot] + - "check-success=test (3.10, check)" + - "check-success=test (3.11, check)" + - "check-success=test (3.12, check)" + - "check-success=test (3.13, check)" + - "check-success=test (3.14, check)" + - "check-success=test (3.14, lint)" + - "check-success=test (3.14, mypy)" + pull_request_rules: - - name: automatic merge - 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" - - "#approved-reviews-by>=1" - - label!=work-in-progress - actions: - merge: - strict: "smart" - method: squash - - name: automatic merge for jd - 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" - - label!=work-in-progress - actions: - merge: - strict: "smart" - method: squash - name: dismiss reviews conditions: [] actions: diff --git a/.readthedocs.yml b/.readthedocs.yml index b75e2b97..1ab90f14 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,4 +1,8 @@ version: 2 +build: + os: ubuntu-24.04 + tools: + python: "3.13" python: install: - method: pip 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/conf.py b/doc/source/conf.py index 23856bc5..a0de6991 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 @@ -16,27 +15,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import sys +from importlib.metadata import version as pkg_version -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('../..')) +release = pkg_version("tenacity") +version = ".".join(release.split(".")[:2]) extensions = [ - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', - 'reno.sphinxext', + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "reno.sphinxext", ] - -# -- Options for sphinx.ext.doctest ----------------------------------------- - -# doctest_default_flags = -cwd = os.path.abspath(os.path.dirname(__file__)) -tenacity_path = os.path.join(cwd, os.pardir, os.pardir) -doctest_path = [tenacity_path] -# doctest_global_setup = -# doctest_global_cleanup = -# doctest_test_doctest_blocks = diff --git a/doc/source/index.rst b/doc/source/index.rst index af3c5bba..ba21fd93 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,18 +1,20 @@ Tenacity ======== .. image:: https://img.shields.io/pypi/v/tenacity.svg - :target: https://pypi.python.org/pypi/tenacity + :target: https://pypi.org/project/tenacity -.. image:: https://circleci.com/gh/jd/tenacity.svg?style=svg - :target: https://circleci.com/gh/jd/tenacity +.. image:: https://img.shields.io/pypi/pyversions/tenacity.svg + :target: https://pypi.org/project/tenacity -.. image:: https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg - :target: https://saythanks.io/to/jd +.. image:: https://github.com/jd/tenacity/actions/workflows/ci.yaml/badge.svg?branch=main + :target: https://github.com/jd/tenacity/actions/workflows/ci.yaml -.. 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 +**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 @@ -80,7 +82,7 @@ Examples Basic Retry ~~~~~~~~~~~ -.. testsetup:: * +.. testsetup:: import logging # @@ -98,7 +100,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 @@ -125,6 +127,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:: @@ -208,11 +220,19 @@ 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") 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:: @@ -225,6 +245,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:: @@ -254,8 +289,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:: @@ -278,6 +317,7 @@ by using the before callback function: .. testcode:: import logging + import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) @@ -292,6 +332,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) @@ -308,6 +349,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) @@ -323,7 +365,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:: @@ -336,7 +378,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: @@ -370,52 +412,39 @@ 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 ~~~~~~~~~~~~~~ -``retry_state`` argument is an object of `RetryCallState` class: - -.. autoclass:: tenacity.RetryCallState - - Constant attributes: - - .. autoinstanceattribute:: start_time(float) - :annotation: +``retry_state`` argument is an object of :class:`~tenacity.RetryCallState` class. +Its most useful attributes are: - .. autoinstanceattribute:: retry_object(BaseRetrying) - :annotation: +* ``attempt_number`` — number of the current attempt (starts at 1) +* ``outcome`` — a :class:`concurrent.futures.Future` holding the last result or exception +* ``seconds_since_start`` — total elapsed seconds from the first attempt to the last outcome (``None`` if no outcome yet) +* ``idle_for`` — cumulative seconds spent sleeping between attempts +* ``start_time`` — :func:`time.monotonic` timestamp of the first attempt - .. autoinstanceattribute:: fn(callable) - :annotation: +For example, to log the total elapsed time after all retries: - .. autoinstanceattribute:: args(tuple) - :annotation: - - .. autoinstanceattribute:: kwargs(dict) - :annotation: +.. testcode:: - Variable attributes: + import logging - .. autoinstanceattribute:: attempt_number(int) - :annotation: + logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - .. autoinstanceattribute:: outcome(tenacity.Future or None) - :annotation: + logger = logging.getLogger(__name__) - .. autoinstanceattribute:: outcome_timestamp(float or None) - :annotation: + def log_elapsed(retry_state): + logger.info('Finished after %.3fs', retry_state.seconds_since_start) - .. autoinstanceattribute:: idle_for(float) - :annotation: + @retry(stop=stop_after_attempt(3), after=log_elapsed) + def raise_my_exception(): + raise MyException("Fail") - .. autoinstanceattribute:: next_action(tenacity.RetryAction or None) - :annotation: + try: + raise_my_exception() + except RetryError: + pass Other Custom Callbacks ~~~~~~~~~~~~~~~~~~~~~~ @@ -424,33 +453,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: @@ -480,92 +509,52 @@ 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 +Common Patterns +~~~~~~~~~~~~~~~ - .. function:: my_deprecated_before(func, trial_number) +**Running setup code between retries** (e.g. reconnecting): - *deprecated* +.. testcode:: - :param callable func: function whose outcome is to be retried - :param int trial_number: the number of current attempt + def reconnect(retry_state): + print("Reconnecting before next attempt...") - .. function:: my_deprecated_after(func, trial_number, trial_time_taken) + @retry(stop=stop_after_attempt(3), before_sleep=reconnect) + def send_data(): + raise MyException("connection lost") - *deprecated* + try: + send_data() + except RetryError: + pass - :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 +.. testoutput:: + :hide: - .. function:: my_deprecated_before_sleep(func, sleep, last_result) + ... - *deprecated* +The ``before_sleep`` callback runs after a failed attempt and before sleeping, +making it ideal for re-establishing connections, refreshing tokens, or any +other setup that needs to happen before the next attempt. - :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 +**Accessing the attempt number inside the function** using the iterator API: .. testcode:: - import logging - - logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) + from tenacity import Retrying - logger = logging.getLogger(__name__) + for attempt in Retrying(stop=stop_after_attempt(3)): + with attempt: + print(f"Attempt {attempt.retry_state.attempt_number}") + if attempt.retry_state.attempt_number < 3: + raise MyException("not yet") - 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) +.. testoutput:: + :hide: - @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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -584,7 +573,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: @@ -603,6 +592,64 @@ 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: + + ... + +Disabling Retries +~~~~~~~~~~~~~~~~~ + +You can disable retrying entirely by passing ``enabled=False``. When disabled, +the decorated function is called directly without any retry logic. This is +useful during development or testing when you want fast feedback on failures: + +.. testcode:: + + import os + + @retry( + enabled=os.getenv("ENABLE_RETRIES", "1") != "0", + stop=stop_after_attempt(5), + wait=wait_fixed(1), + ) + def call_api(): + pass # your code here + + call_api() + +You can also use ``retry_with`` to disable retries on a per-call basis: + +.. testcode:: + + @retry(stop=stop_after_attempt(5)) + def call_api(): + pass # your code here + + # In tests: + call_api.retry_with(enabled=False)() + Retrying code block ~~~~~~~~~~~~~~~~~~~ @@ -638,32 +685,84 @@ 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 ~~~~~~~~~~~~~~~ -Finally, ``retry`` works also on asyncio and Tornado (>= 4.5) coroutines. +Finally, ``retry`` works also on asyncio, Trio, and Tornado coroutines. Sleeps are done asynchronously too. .. code-block:: python @retry - async def my_async_function(loop): - await loop.getaddrinfo('8.8.8.8', 53) + async def my_asyncio_function(): + await asyncio.getaddrinfo('8.8.8.8', 53) .. code-block:: python @retry - @tornado.gen.coroutine - def my_async_function(http_client, url): - yield http_client.fetch(url) + async def my_async_trio_function(): + await trio.socket.getaddrinfo('8.8.8.8', 53) -You can even use alternative event loops such as `curio` or `Trio` by passing the correct sleep function: +.. code-block:: python + + @retry + async def my_async_tornado_function(http_client, url): + await http_client.fetch(url) + +You can use alternative event loops by passing the correct sleep function: .. code-block:: python @retry(sleep=trio.sleep) - async def my_async_function(loop): - await asks.get('https://example.org') + async def my_async_trio_function_with_sleep(): + ... + +Generators +~~~~~~~~~~ + +``retry`` does not support generator or async generator functions. Decorating a +generator with ``@retry`` will not retry on exceptions raised during iteration +— the decorator wraps the function call itself, which for generators simply +returns a generator object without executing any of the body. + +Also note that generators passed *as arguments* to a retried function will be +exhausted after the first attempt and will not be rewound automatically on +retry. If you need to pass a generator as an argument, consider passing a +factory function instead: + +.. code-block:: python + + # Bad: generator will be exhausted after the first attempt + @retry + def process(items): + for item in items: + do_work(item) + + process(my_generator()) # retries will see an empty generator + + # Good: pass a factory so a fresh generator is created on each attempt + @retry + def process(items_factory): + for item in items_factory(): + do_work(item) + + process(my_generator) # each retry gets a fresh generator Contribute ---------- @@ -671,24 +770,17 @@ 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>`_ #. 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. +Running the test suite locally:: -The doc generation will automatically compile the changelogs. You just need to add them. + uv run poe check # run tests + build docs + uv run poe lint # run ruff linter + uv run poe mypy # run type checker + uv run poe all # run everything -.. 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 +.. _`the repository`: https://github.com/jd/tenacity diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..447c639e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,127 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "tenacity/_version.py" + +[project] +name = "tenacity" +dynamic = ["version"] +description = "Retry code until it succeeds" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Julien Danjou", email = "julien@danjou.info" }, +] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Utilities", +] + +[project.urls] +Homepage = "https://github.com/jd/tenacity" +Documentation = "https://tenacity.readthedocs.io" +Source = "https://github.com/jd/tenacity" +Issues = "https://github.com/jd/tenacity/issues" +Changelog = "https://tenacity.readthedocs.io/en/latest/changelog.html" + +[project.optional-dependencies] +doc = [ + "reno", + "sphinx", +] +test = [ + "pytest", + "tornado>=6.0", + "typeguard", +] + +[dependency-groups] +dev = [ + "poethepoet", + "pytest", + "tornado>=6.0", + "typeguard", + "ruff", + "mypy", + "sphinx", + "reno", + "trio", +] + +[tool.poe.tasks] +test = "pytest" +docs-doctest = "sphinx-build -a -E -W -b doctest doc/source doc/build" +docs-html = "sphinx-build -a -E -W -b html doc/source doc/build" +docs = ["docs-doctest", "docs-html"] +check = ["test", "docs"] +mypy = "mypy" +reno = "reno" + +[tool.poe.tasks.fmt] +sequence = [ + { cmd = "ruff check --fix ." }, + { cmd = "ruff format ." }, +] + +[tool.poe.tasks.lint] +sequence = [ + { cmd = "ruff check ." }, + { cmd = "ruff format --check ." }, +] + +[tool.poe.tasks.all] +sequence = [ + { ref = "lint" }, + { ref = "mypy" }, + { ref = "check" }, +] + +[tool.pytest.ini_options] +filterwarnings = [ + "once::DeprecationWarning", +] + +[tool.ruff] +line-length = 88 +indent-width = 4 +target-version = "py310" +exclude = ["tenacity/_version.py"] + +[tool.ruff.lint] +select = ["ASYNC", "B", "C4", "DTZ", "E", "EXE", "F", "FLY", "FURB", "I", "ICN", "ISC", "LOG", "PERF", "PGH", "PIE", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "SLOT", "T10", "TC", "UP", "W"] +ignore = [ + "B008", # function calls in default arguments (intentional API design) + "B905", # zip() without strict= (not needed in existing code) + "E501", # line too long (formatter handles what it can) + "PYI036", # false positive on string-quoted __exit__ annotations + "RUF003", # ambiguous unicode characters in comments (copyright names) + "RUF005", # iterable unpacking vs concatenation (less readable for tuple +) + "RUF012", # mutable class default (test constant, never mutated) + "SIM108", # ternary instead of if-else (less readable in context) +] + +[tool.mypy] +strict = true +files = ["tenacity", "tests"] +show_error_codes = true +exclude = ["tenacity/_version\\.py"] + +[[tool.mypy.overrides]] +module = "tornado.*" +ignore_missing_imports = true + 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/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/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/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/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/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/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/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/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/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 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/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/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml new file mode 100644 index 00000000..0ffe819f --- /dev/null +++ b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Passing an async ``sleep`` callable (e.g. ``trio.sleep``) to ``@retry`` + now correctly uses ``AsyncRetrying``, even when the decorated function is + synchronous. Previously, the async sleep would silently not be awaited, + resulting in no delay between retries. 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 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. 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/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/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml b/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml new file mode 100644 index 00000000..ac2a8cfd --- /dev/null +++ b/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - | + Python 3.9 has reached end-of-life and is no longer supported. + The minimum supported version is now Python 3.10. 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/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/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/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml b/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml new file mode 100644 index 00000000..c5127669 --- /dev/null +++ b/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml @@ -0,0 +1,9 @@ +--- +fixes: + - | + The ``@retry`` decorator's type overloads for the ``sleep=`` parameter + (e.g. ``sleep=trio.sleep``) have been improved. Previously, the + async-sleep overload used ``R | Awaitable[R]`` as the return type + bound, which was ambiguous: for ``async def f() -> T``, pyright could + infer ``R = Coroutine[Any, Any, T]`` instead of ``R = T``, producing + false-positive type errors in downstream code. 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/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/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/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/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/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/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/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/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/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/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/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/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/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. 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/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/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/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/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/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/setup.cfg b/setup.cfg deleted file mode 100644 index 5cc3cb18..00000000 --- a/setup.cfg +++ /dev/null @@ -1,46 +0,0 @@ -[metadata] -name = tenacity -license = Apache 2.0 -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 -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.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Topic :: Utilities - -[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' -packages = tenacity - -[options.package_data] -tenacity = py.typed - -[options.extras_require] -doc = - reno - sphinx - tornado>=4.5 - -[wheel] -universal = 1 - -[tool:pytest] -filterwarnings = - # Show any DeprecationWarnings once - once::DeprecationWarning diff --git a/setup.py b/setup.py deleted file mode 100644 index 66163685..00000000 --- a/setup.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -# -# 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 setuptools - -setuptools.setup( - setup_requires=['setuptools_scm'], - use_scm_version=True, -) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 336f4f65..282e6dae 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 @@ -16,118 +15,113 @@ # 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. - -try: - from inspect import iscoroutinefunction -except ImportError: - iscoroutinefunction = None - -try: - import tornado -except ImportError: - tornado = None - +import dataclasses +import functools import sys import threading +import time import typing as t import warnings -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod from concurrent import futures +from . import _utils -import six +# Import all built-in after strategies for easier usage. +from .after import after_log, after_nothing -from tenacity import _utils -from tenacity import compat as _compat +# Import all built-in before strategies for easier usage. +from .before import before_log, before_nothing -# Import all built-in retry strategies for easier usage. -from .retry import retry_all # noqa -from .retry import retry_always # noqa -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_result # noqa -from .retry import retry_if_result # noqa -from .retry import retry_never # noqa -from .retry import retry_unless_exception_type # noqa -from .retry import retry_if_exception_message # noqa -from .retry import retry_if_not_exception_message # noqa +# Import all built-in before sleep strategies for easier usage. +from .before_sleep import before_sleep_log, before_sleep_nothing # Import all nap strategies for easier usage. -from .nap import sleep # noqa -from .nap import sleep_using_event # noqa +from .nap import sleep, sleep_using_event + +# Import all built-in retry strategies for easier usage. +from .retry import ( + retry_all, + retry_always, + retry_any, + retry_base, + retry_if_exception, + retry_if_exception_cause_type, + retry_if_exception_message, + retry_if_exception_type, + retry_if_not_exception_message, + retry_if_not_exception_type, + retry_if_not_result, + retry_if_result, + retry_never, + retry_unless_exception_type, +) # 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_all # noqa -from .stop import stop_any # noqa -from .stop import stop_never # noqa -from .stop import stop_when_event_set # noqa +from .stop import ( + stop_after_attempt, + stop_after_delay, + stop_all, + stop_any, + stop_before_delay, + stop_never, + stop_when_event_set, +) # 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_exponential # noqa -from .wait import wait_fixed # noqa -from .wait import wait_incrementing # noqa -from .wait import wait_none # noqa -from .wait import wait_random # noqa -from .wait import wait_random_exponential # noqa -from .wait import wait_random_exponential as wait_full_jitter # noqa - -# Import all built-in before strategies for easier usage. -from .before import before_log # noqa -from .before import before_nothing # noqa +from .wait import ( + wait_chain, + wait_combine, + wait_exception, + wait_exponential, + wait_exponential_jitter, + wait_fixed, + wait_incrementing, + wait_none, + wait_random, + wait_random_exponential, +) +from .wait import wait_random_exponential as wait_full_jitter -# Import all built-in after strategies for easier usage. -from .after import after_log # noqa -from .after import after_nothing # noqa +try: + import tornado +except ImportError: + tornado = None # type: ignore[assignment] -# Import all built-in after strategies for easier usage. -from .before_sleep import before_sleep_log # noqa -from .before_sleep import before_sleep_nothing # noqa +if t.TYPE_CHECKING: + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + import types -WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable) + from . import asyncio as tasyncio + from .retry import RetryBaseT + from .stop import StopBaseT + from .wait import WaitBaseT -@t.overload -def retry(fn): - # type: (WrappedFn) -> WrappedFn - """Type signature for @retry as a raw decorator.""" - pass +WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") +WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) +P = t.ParamSpec("P") +R = t.TypeVar("R") -@t.overload -def retry(*dargs, **dkw): # noqa - # type: (...) -> t.Callable[[WrappedFn], WrappedFn] - """Type signature for the @retry() decorator constructor.""" - pass +@dataclasses.dataclass(slots=True) +class IterState: + actions: list[t.Callable[["RetryCallState"], t.Any]] = dataclasses.field( + default_factory=list + ) + retry_run_result: bool = False + stop_run_result: bool = False + is_explicit_retry: bool = False - -def retry(*dargs, **dkw): # 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): - 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): - r = TornadoRetrying(*dargs, **dkw) - else: - r = Retrying(*dargs, **dkw) - - return r.wraps(f) - - return wrap + def reset(self) -> None: + self.actions = [] + self.retry_run_result = False + self.stop_run_result = False + self.is_explicit_retry = False class TryAgain(Exception): @@ -137,7 +131,7 @@ class TryAgain(Exception): NO_RESULT = object() -class DoAttempt(object): +class DoAttempt: pass @@ -145,158 +139,183 @@ class DoSleep(float): pass -class BaseAction(object): +class BaseAction: """Base class for representing actions to take by retry object. 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 """ - REPR_FIELDS = () - NAME = None + REPR_FIELDS: t.Sequence[str] = () + NAME: str | None = 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) + def __repr__(self) -> 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): + def __str__(self) -> str: return repr(self) class RetryAction(BaseAction): - REPR_FIELDS = ('sleep',) - NAME = 'retry' + 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: 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): - return "{0}[{1}]".format(self.__class__.__name__, self.last_attempt) + def __str__(self) -> str: + return f"{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): - if isinstance(exc_value, BaseException): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: "types.TracebackType | None", + ) -> bool | None: + 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: - # We don't have the result, actually. - self.retry_state.set_result(None) - - -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): + # We don't have the result, actually. + self.retry_state.set_result(None) + return None + + async def __aenter__(self) -> None: + pass + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: "types.TracebackType | None", + ) -> bool | None: + return self.__exit__(exc_type, exc_value, traceback) + + +class BaseRetrying(ABC): + def __init__( + self, + sleep: t.Callable[[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.Callable[["RetryCallState"], None] | None = None, + reraise: bool = False, + retry_error_cls: type[RetryError] = RetryError, + retry_error_callback: t.Callable[["RetryCallState"], t.Any] | None = None, + name: str | None = None, + enabled: bool = True, + ): 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 - - # 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): + self.retry_error_callback = retry_error_callback + self._name = name + self.enabled = enabled + + def copy( + self, + sleep: t.Callable[[int | float], None] | object = _unset, + stop: "StopBaseT | object" = _unset, + wait: "WaitBaseT | object" = _unset, + retry: retry_base | object = _unset, + before: t.Callable[["RetryCallState"], None] | object = _unset, + after: t.Callable[["RetryCallState"], None] | object = _unset, + before_sleep: t.Callable[["RetryCallState"], None] | None | object = _unset, + reraise: bool | object = _unset, + retry_error_cls: type[RetryError] | object = _unset, + retry_error_callback: t.Callable[["RetryCallState"], t.Any] + | None + | object = _unset, + name: str | None | object = _unset, + enabled: bool | object = _unset, + ) -> "Self": """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 + ), + name=_first_set(name, self._name), + enabled=_first_set(enabled, self.enabled), ) - def __repr__(self): - attrs = dict( - _utils.visible_attrs(self, attrs={'me': id(self)}), - __class__=self.__class__.__name__, + def __getstate__(self) -> dict[str, t.Any]: + # Exclude threading.local which cannot be pickled + return {k: v for k, v in self.__dict__.items() if k != "_local"} + + def __setstate__(self, state: dict[str, t.Any]) -> None: + self.__dict__.update(state) + self._local = threading.local() + + def __str__(self) -> str: + return self._name if self._name is not None else "" + + def __repr__(self) -> str: + return ( + 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}, " + f"name={self._name!r})>" ) - 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): + def statistics(self) -> dict[str, t.Any]: """Return a dictionary of runtime statistics. This dictionary will be empty when the controller has never been @@ -304,8 +323,8 @@ def statistics(self): may not) have useful and/or informational keys and values when running is underway and/or completed. - .. warning:: The keys in this dictionary **should** be some what - stable (not changing), but there existence **may** + .. warning:: The keys in this dictionary **should** be somewhat + stable (not changing), but their existence **may** change between major releases as new statistics are gathered or removed so before accessing keys ensure that they actually exist and handle when they do not. @@ -313,81 +332,142 @@ def statistics(self): .. note:: The values in this dictionary are local to the thread running call (so if multiple threads share the same retrying object - either directly or indirectly) they will each have - there own view of statistics they have collected (in the + their own view of statistics they have collected (in the future we may provide a way to aggregate the various statistics from each thread). """ - try: - return self._local.statistics - except AttributeError: - self._local.statistics = {} - return self._local.statistics + if not hasattr(self._local, "statistics"): + self._local.statistics = t.cast("dict[str, t.Any]", {}) + return self._local.statistics # type: ignore[no-any-return] - def wraps(self, f): + @property + def iter_state(self) -> IterState: + if not hasattr(self._local, "iter_state"): + self._local.iter_state = IterState() + return self._local.iter_state # type: ignore[no-any-return] + + def wraps(self, f: t.Callable[P, R]) -> "_RetryDecorated[P, R]": """Wrap a function for retrying. - :param f: A function to wraps for retrying. + :param f: A function to wrap for retrying. """ - @_utils.wraps(f) - def wrapped_f(*args, **kw): - return self(f, *args, **kw) - def retry_with(*args, **kwargs): + @functools.wraps( + f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__") + ) + def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: + if not self.enabled: + return 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] + self._local.statistics = copy.statistics + return copy(f, *args, **kw) + + def retry_with(*args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]": return self.copy(*args, **kwargs).wraps(f) - wrapped_f.retry = self - wrapped_f.retry_with = retry_with + # Preserve attributes + 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] - return wrapped_f + return t.cast("_RetryDecorated[P, R]", wrapped_f) - def begin(self, fn): + def begin(self) -> None: self.statistics.clear() - self.statistics['start_time'] = _utils.now() - self.statistics['attempt_number'] = 1 - self.statistics['idle_for'] = 0 - self.fn = fn + self.statistics["start_time"] = time.monotonic() + self.statistics["attempt_number"] = 1 + self.statistics["idle_for"] = 0 + self.statistics["delay_since_first_attempt"] = 0 + + def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None: + self.iter_state.actions.append(fn) + + 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: + sleep = 0.0 + + retry_state.upcoming_sleep = sleep + + def _run_stop(self, retry_state: "RetryCallState") -> None: + self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start + self.iter_state.stop_run_result = self.stop(retry_state) + + def iter(self, retry_state: "RetryCallState") -> DoAttempt | DoSleep | t.Any: + 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: + self.iter_state.reset() - def iter(self, retry_state): # noqa fut = retry_state.outcome if fut is None: if self.before is not None: - self.before(retry_state) - return DoAttempt() + 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) - 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() + 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.after(retry_state=retry_state) + self._add_action_func(self.after) - self.statistics['delay_since_first_attempt'] = \ - retry_state.seconds_since_start - if self.stop(retry_state=retry_state): + 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_state) - retry_exc = self.retry_error_cls(fut) - if self.reraise: - raise retry_exc.reraise() - six.raise_from(retry_exc, fut.exception()) + self._add_action_func(self.retry_error_callback) + return - if self.wait: - sleep = self.wait(retry_state=retry_state) - else: - sleep = 0.0 - retry_state.next_action = RetryAction(sleep) - retry_state.idle_for += sleep - self.statistics['idle_for'] += sleep - self.statistics['attempt_number'] += 1 + def exc_check(rs: "RetryCallState") -> None: + fut = t.cast("Future", rs.outcome) + retry_exc = self.retry_error_cls(fut) + if self.reraise: + 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 + + self._add_action_func(next_action) if self.before_sleep is not None: - self.before_sleep(retry_state=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): - self.begin(None) + def __iter__(self) -> t.Generator[AttemptManager, None, None]: + self.begin() retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) while True: @@ -401,54 +481,59 @@ def __iter__(self): break @abstractmethod - def __call__(self, *args, **kwargs): + def __call__( + self, + fn: t.Callable[..., WrappedFnReturnT], + *args: t.Any, + **kwargs: t.Any, + ) -> WrappedFnReturnT: pass class Retrying(BaseRetrying): """Retrying controller.""" - def __call__(self, fn, *args, **kwargs): - self.begin(fn) + 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) + 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): try: result = fn(*args, **kwargs) except BaseException: - 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 - - 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) + return do # type: ignore[no-any-return] -class Future(futures.Future): +class Future(futures.Future[t.Any]): """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: @@ -458,12 +543,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: WrappedFn | None, + 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 @@ -474,43 +565,242 @@ 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: Future | None = None #: Timestamp of the last outcome - self.outcome_timestamp = None + self.outcome_timestamp: float | None = None #: Time spent sleeping in retries - self.idle_for = 0 + self.idle_for: float = 0.0 #: Next action as decided by the retry manager - self.next_action = None + self.next_action: RetryAction | None = None + #: Next sleep time as decided by the retry manager. + self.upcoming_sleep: float = 0.0 + + def get_fn_name(self) -> str: + """Get the name of the function being retried. + + Returns the fully-qualified name of the wrapped function when used as a + decorator, the ``name`` passed to the retrying object when used as a + context manager, or ``""`` if neither is available. + """ + if self.fn is not None: + return _utils.get_callback_name(self.fn) + return str(self.retry_object) @property - def seconds_since_start(self): + def seconds_since_start(self) -> float | None: 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: tuple[ + type[BaseException], BaseException, "types.TracebackType | None" + ], + ) -> 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 + def __repr__(self) -> str: + 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}>" -if iscoroutinefunction: - from tenacity._asyncio import AsyncRetrying + +class _RetryDecorated(t.Protocol[P, R]): + """Protocol for functions decorated with @retry. + + Provides the original callable signature plus retry control attributes. + """ + + retry: "BaseRetrying" + statistics: dict[str, t.Any] + + def retry_with(self, *args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]": ... + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... + + +class _AsyncRetryDecorator(t.Protocol): + @t.overload + def __call__( + self, fn: "t.Callable[P, types.CoroutineType[t.Any, t.Any, R]]" + ) -> "_RetryDecorated[P, types.CoroutineType[t.Any, t.Any, R]]": ... + @t.overload + def __call__( + self, fn: t.Callable[P, t.Coroutine[t.Any, t.Any, R]] + ) -> "_RetryDecorated[P, t.Coroutine[t.Any, t.Any, R]]": ... + @t.overload + def __call__( + self, fn: t.Callable[P, t.Awaitable[R]] + ) -> "_RetryDecorated[P, t.Awaitable[R]]": ... + @t.overload + def __call__( + self, fn: t.Callable[P, R] + ) -> "_RetryDecorated[P, t.Awaitable[R]]": ... + + +@t.overload +def retry(func: t.Callable[P, R]) -> _RetryDecorated[P, R]: ... + + +@t.overload +def retry( + *, + sleep: t.Callable[[int | float], t.Awaitable[None]], + stop: "StopBaseT" = ..., + wait: "WaitBaseT" = ..., + retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = ..., + before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ..., + after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ..., + before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] | None = ..., + reraise: bool = ..., + retry_error_cls: type["RetryError"] = ..., + retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]] + | None = ..., + enabled: bool = ..., +) -> _AsyncRetryDecorator: ... + + +@t.overload +def retry( + sleep: t.Callable[[int | float], None] = sleep, + stop: "StopBaseT" = stop_never, + wait: "WaitBaseT" = wait_none(), + retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = retry_if_exception_type(), + before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = before_nothing, + after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing, + before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] + | None = None, + reraise: bool = False, + retry_error_cls: type["RetryError"] = RetryError, + retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]] + | None = None, + enabled: bool = True, +) -> t.Callable[[t.Callable[P, R]], _RetryDecorated[P, R]]: ... + + +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]) + + def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: + 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__}(...)?)", + stacklevel=2, + ) + r: BaseRetrying + sleep = dkw.get("sleep") + if _utils.is_coroutine_callable(f) or ( + sleep is not None and _utils.is_coroutine_callable(sleep) + ): + 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 if tornado: from tenacity.tornadoweb import TornadoRetrying + + +__all__ = [ + "NO_RESULT", + "AsyncRetrying", + "AttemptManager", + "BaseAction", + "BaseRetrying", + "DoAttempt", + "DoSleep", + "Future", + "RetryAction", + "RetryCallState", + "RetryError", + "Retrying", + "TryAgain", + "WrappedFn", + "after_log", + "after_nothing", + "before_log", + "before_nothing", + "before_sleep_log", + "before_sleep_nothing", + "retry", + "retry_all", + "retry_always", + "retry_any", + "retry_base", + "retry_if_exception", + "retry_if_exception_cause_type", + "retry_if_exception_message", + "retry_if_exception_type", + "retry_if_not_exception_message", + "retry_if_not_exception_type", + "retry_if_not_result", + "retry_if_result", + "retry_never", + "retry_unless_exception_type", + "sleep", + "sleep_using_event", + "stop_after_attempt", + "stop_after_delay", + "stop_all", + "stop_any", + "stop_before_delay", + "stop_never", + "stop_when_event_set", + "wait_chain", + "wait_combine", + "wait_exception", + "wait_exponential", + "wait_exponential_jitter", + "wait_fixed", + "wait_full_jitter", + "wait_incrementing", + "wait_none", + "wait_random", + "wait_random_exponential", +] diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py deleted file mode 100644 index 6b24b2e1..00000000 --- a/tenacity/_asyncio.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2016 Étienne Bersac -# Copyright 2016 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 sys -from asyncio import sleep - -from tenacity import AttemptManager -from tenacity import BaseRetrying -from tenacity import DoAttempt -from tenacity import DoSleep -from tenacity import RetryCallState - - -class AsyncRetrying(BaseRetrying): - - 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) - while True: - do = self.iter(retry_state=retry_state) - if isinstance(do, DoAttempt): - try: - result = await fn(*args, **kwargs) - except BaseException: - retry_state.set_exception(sys.exc_info()) - else: - retry_state.set_result(result) - elif isinstance(do, DoSleep): - retry_state.prepare_for_next_attempt() - await self.sleep(do) - else: - return do - - def __aiter__(self): - self.begin(None) - self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) - return self - - async def __anext__(self): - while True: - do = self.iter(retry_state=self._retry_state) - if do is None: - raise StopAsyncIteration - elif isinstance(do, DoAttempt): - return AttemptManager(retry_state=self._retry_state) - elif isinstance(do, DoSleep): - self._retry_state.prepare_for_next_attempt() - await self.sleep(do) - else: - return do diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 6703bd9c..6a12678b 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -13,89 +13,54 @@ # 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 contextlib +import functools import inspect import sys -import time -from functools import update_wrapper - -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 - +import typing +from datetime import timedelta -if six.PY2: - from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES +# sys.maxsize: +# An integer giving the maximum value a variable of type Py_ssize_t can take. +MAX_WAIT = sys.maxsize / 2 - 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 +class LoggerProtocol(typing.Protocol): + """ + Protocol used by utils expecting a logger (eg: before_log). - def capture(fut, tb): - fut.set_exception(tb[1]) + Compatible with logging, structlog, loguru, etc... + """ - def getargspec(func): - return inspect.getfullargspec(func) + def log( + self, level: int, msg: str, *args: typing.Any, **kwargs: typing.Any + ) -> typing.Any: ... -def visible_attrs(obj, attrs=None): - 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 11 <= (pos_num % 100) <= 13: + return "th" -def find_ordinal(pos_num): - # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers if pos_num == 0: return "th" - elif pos_num == 1: - return 'st' - elif pos_num == 2: - return 'nd' - elif pos_num == 3: - return 'rd' - elif pos_num >= 4 and pos_num <= 20: - return 'th' - else: - return find_ordinal(pos_num % 10) + if pos_num == 1: + return "st" + if pos_num == 2: + return "nd" + if pos_num == 3: + return "rd" + if 4 <= pos_num <= 20: + return "th" + return find_ordinal(pos_num % 10) -def to_ordinal(pos_num): - return "%i%s" % (pos_num, find_ordinal(pos_num)) +def to_ordinal(pos_num: int) -> str: + return f"{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. @@ -104,51 +69,43 @@ def get_callback_name(cb): try: segments.append(cb.__qualname__) except AttributeError: - try: + with contextlib.suppress(AttributeError): 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: return repr(cb) - else: - try: - # When running under sphinx it appears this can be none? - if cb.__module__: - segments.insert(0, cb.__module__) - except AttributeError: - pass - return ".".join(segments) + with contextlib.suppress(AttributeError): + # When running under sphinx it appears this can be none? + if cb.__module__: + segments.insert(0, cb.__module__) + return ".".join(segments) + + +time_unit_type = int | float | timedelta -try: - now = time.monotonic # noqa -except AttributeError: - from monotonic import monotonic as now # noqa +def to_seconds(time_unit: time_unit_type) -> float: + return float( + time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit + ) -class cached_property(object): - """A property that is computed once per instance. +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) # noqa: B004 + return inspect.iscoroutinefunction(dunder_call) - 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 wrap_to_async_func( + call: typing.Callable[..., typing.Any], +) -> typing.Callable[..., typing.Awaitable[typing.Any]]: + if is_coroutine_callable(call): + return call - def __init__(self, func): - update_wrapper(self, func) - self.func = func + async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + return call(*args, **kwargs) - def __get__(self, obj, cls): - if obj is None: - return self - value = obj.__dict__[self.func.__name__] = self.func(obj) - return value + return inner diff --git a/tenacity/after.py b/tenacity/after.py index 55522c99..8e380681 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -14,22 +14,33 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + from tenacity import _utils +if typing.TYPE_CHECKING: + 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: _utils.LoggerProtocol, + log_level: int, + sec_format: str = "%.3g", +) -> typing.Callable[["RetryCallState"], None]: """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): - 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)) + + def log_it(retry_state: "RetryCallState") -> None: + fn_name = retry_state.get_fn_name() + secs = retry_state.seconds_since_start + logger.log( + log_level, + f"Finished call to '{fn_name}' " + f"after {sec_format % secs if secs is not None else '?'}(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", + ) return log_it diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py new file mode 100644 index 00000000..214a7ae9 --- /dev/null +++ b/tenacity/asyncio/__init__.py @@ -0,0 +1,218 @@ +# Copyright 2016 Étienne Bersac +# Copyright 2016 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 functools +import sys +import typing as t + +import tenacity +from tenacity import ( + AttemptManager, + BaseRetrying, + DoAttempt, + DoSleep, + RetryCallState, + RetryError, + _RetryDecorated, + _utils, + after_nothing, + before_nothing, +) + +# Import all built-in retry strategies for easier usage. +from .retry import ( + RetryBaseT, + retry_all, + retry_any, + retry_if_exception, + retry_if_result, +) + +if t.TYPE_CHECKING: + from tenacity.retry import RetryBaseT as SyncRetryBaseT + 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]]) +P = t.ParamSpec("P") +R = t.TypeVar("R") + + +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 sniffio + import trio + + if sniffio.current_async_library() == "trio": + return trio.sleep(seconds) # noqa: ASYNC105 + # Otherwise, assume asyncio + # Lazy import asyncio as it's expensive (responsible for 25-50% of total import overhead). + import asyncio + + return asyncio.sleep(seconds) + + +class AsyncRetrying(BaseRetrying): + def __init__( + self, + sleep: t.Callable[ + [int | float], None | t.Awaitable[None] + ] = _portable_async_sleep, + stop: "StopBaseT" = tenacity.stop.stop_never, + wait: "WaitBaseT" = tenacity.wait.wait_none(), + retry: "SyncRetryBaseT | RetryBaseT" = tenacity.retry_if_exception_type(), + before: t.Callable[ + ["RetryCallState"], None | t.Awaitable[None] + ] = before_nothing, + after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing, + before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] + | None = None, + reraise: bool = False, + retry_error_cls: type["RetryError"] = RetryError, + retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]] + | None = None, + name: str | None = None, + enabled: bool = True, + ) -> None: + 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, + name=name, + enabled=enabled, + ) + + async def __call__( # type: ignore[override] + self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any + ) -> WrappedFnReturnT: + self.begin() + + retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) + is_async = _utils.is_coroutine_callable(fn) + while True: + do = await self.iter(retry_state=retry_state) + if isinstance(do, DoAttempt): + try: + if is_async: + result = await fn(*args, **kwargs) + else: + result = fn(*args, **kwargs) + except BaseException: + 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) # type: ignore[misc] + else: + return do # type: ignore[no-any-return] + + def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None: + 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 _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 _utils.wrap_to_async_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 _utils.wrap_to_async_func(self.stop)( + retry_state + ) + + async def iter(self, retry_state: "RetryCallState") -> DoAttempt | DoSleep | t.Any: + 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") + + def __aiter__(self) -> "AsyncRetrying": + self.begin() + self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) + return self + + async def __anext__(self) -> AttemptManager: + while True: + do = await self.iter(retry_state=self._retry_state) + if do is None: + raise StopAsyncIteration + if isinstance(do, DoAttempt): + return AttemptManager(retry_state=self._retry_state) + if isinstance(do, DoSleep): + self._retry_state.prepare_for_next_attempt() + await self.sleep(do) # type: ignore[misc] + else: + raise StopAsyncIteration + + def wraps(self, fn: t.Callable[P, R]) -> _RetryDecorated[P, R]: + 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: + if not self.enabled: + return await fn(*args, **kwargs) # type: ignore[misc] + # 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] + self._local.statistics = copy.statistics + return await copy(fn, *args, **kwargs) # type: ignore[type-var] + + # Preserve attributes + 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] + + return t.cast("_RetryDecorated[P, R]", async_wrapped) + + +__all__ = [ + "AsyncRetrying", + "WrappedFn", + "retry_all", + "retry_any", + "retry_if_exception", + "retry_if_result", +] diff --git a/tenacity/asyncio/retry.py b/tenacity/asyncio/retry.py new file mode 100644 index 00000000..e0d44e82 --- /dev/null +++ b/tenacity/asyncio/retry.py @@ -0,0 +1,136 @@ +# 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, 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: "retry_base | async_retry_base" + ) -> "retry_all": + return retry_all(self, other) + + def __rand__( # type: ignore[misc,override] + self, other: "retry_base | async_retry_base" + ) -> "retry_all": + return retry_all(other, self) + + def __or__( # type: ignore[override] + self, other: "retry_base | async_retry_base" + ) -> "retry_any": + return retry_any(self, other) + + def __ror__( # type: ignore[misc,override] + self, other: "retry_base | async_retry_base" + ) -> "retry_any": + return retry_any(other, self) + + +RetryBaseT = ( + 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) + 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()) + return False + + +class retry_any(async_retry_base): + """Retries if any of the retries condition is valid.""" + + def __init__(self, *retries: 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 + + def __ror__( # type: ignore[misc,override] + self, other: "retry_base | async_retry_base" + ) -> "retry_any": + if isinstance(other, retry_any): + return retry_any(*other.retries, *self.retries) + return retry_any(other, *self.retries) + + +class retry_all(async_retry_base): + """Retries if all the retries condition are valid.""" + + def __init__(self, *retries: 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 + + def __rand__( # type: ignore[misc,override] + self, other: "retry_base | async_retry_base" + ) -> "retry_all": + if isinstance(other, retry_all): + return retry_all(*other.retries, *self.retries) + return retry_all(other, *self.retries) diff --git a/tenacity/before.py b/tenacity/before.py index 54259ddd..89b867ee 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -14,19 +14,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + from tenacity import _utils +if typing.TYPE_CHECKING: + 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: _utils.LoggerProtocol, log_level: int +) -> typing.Callable[["RetryCallState"], None]: """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)) + + def log_it(retry_state: "RetryCallState") -> None: + fn_name = retry_state.get_fn_name() + logger.log( + log_level, + f"Starting call to '{fn_name}', " + 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 c8b3d33c..6b691212 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -14,33 +14,54 @@ # 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: + from tenacity import RetryCallState + + +def before_sleep_nothing(retry_state: "RetryCallState") -> None: + """Before sleep strategy that does nothing.""" -def before_sleep_nothing(retry_state): - """Before call strategy that does nothing.""" +def before_sleep_log( + logger: _utils.LoggerProtocol, + 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.""" + 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") -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", f"{ex.__class__.__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: - 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) + fn_name = retry_state.get_fn_name() + + logger.log( + log_level, + f"Retrying {fn_name} " + f"in {sec_format % retry_state.next_action.sleep} seconds as it {verb} {value}.", + exc_info=local_exc_info, + ) + return log_it diff --git a/tenacity/compat.py b/tenacity/compat.py deleted file mode 100644 index 6bd815e0..00000000 --- a/tenacity/compat.py +++ /dev/null @@ -1,322 +0,0 @@ -"""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): - """ - 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..44fd94ae 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 @@ -17,9 +16,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 +31,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: float | None) -> 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 8e4fab32..df0cc4d6 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -1,4 +1,4 @@ -# Copyright 2016 Julien Danjou +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -16,31 +16,55 @@ import abc import re +import typing -import six +if typing.TYPE_CHECKING: + from tenacity import RetryCallState -from tenacity import compat as _compat - -@six.add_metaclass(abc.ABCMeta) -class retry_base(object): +class retry_base(abc.ABC): """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: "RetryBaseT") -> "retry_all": + if isinstance(other, retry_base): + return other.__rand__(self) + # Plain callable: flatten if self is already a retry_all + if isinstance(self, retry_all): + return retry_all(*self.retries, other) return retry_all(self, other) - def __or__(self, other): + def __rand__(self, other: "RetryBaseT") -> "retry_all": + # Flatten if other is already a retry_all + if isinstance(other, retry_all): + return retry_all(*other.retries, self) + return retry_all(other, self) + + def __or__(self, other: "RetryBaseT") -> "retry_any": + if isinstance(other, retry_base): + return other.__ror__(self) + # Plain callable: flatten if self is already a retry_any + if isinstance(self, retry_any): + return retry_any(*self.retries, other) return retry_any(self, other) + def __ror__(self, other: "RetryBaseT") -> "retry_any": + # Flatten if other is already a retry_any + if isinstance(other, retry_any): + return retry_any(*other.retries, self) + return retry_any(other, self) + + +RetryBaseT = retry_base | typing.Callable[["RetryCallState"], bool] + 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 +74,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,128 +84,210 @@ 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 - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): + 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) + return False 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: type[BaseException] + | tuple[type[BaseException], ...] = Exception, + ) -> None: self.exception_types = exception_types - super(retry_if_exception_type, self).__init__( - lambda e: isinstance(e, exception_types)) + super().__init__(self._check) + + def _check(self, e: BaseException) -> bool: + return isinstance(e, self.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: type[BaseException] + | tuple[type[BaseException], ...] = Exception, + ) -> None: + self.exception_types = exception_types + super().__init__(self._check) + + def _check(self, e: BaseException) -> bool: + return not isinstance(e, self.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: type[BaseException] + | tuple[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__(self._check) + + def _check(self, e: BaseException) -> bool: + return not isinstance(e, self.exception_types) + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") - @_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: 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): + """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: type[BaseException] + | tuple[type[BaseException], ...] = Exception, + ) -> None: + 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: + 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.""" - def __init__(self, predicate): + def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None: self.predicate = predicate - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): + 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()) + return False 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 - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): + 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()) + return False 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: str | None = None, + match: None | str | re.Pattern[str] = None, + ) -> None: if message and match: raise TypeError( - "{}() takes either 'message' or 'match', not both".format( - 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: + f"{self.__class__.__name__}() takes either 'message' or 'match', not both" + ) + + if not message and not match: raise TypeError( - "{}() missing 1 required argument 'message' or 'match'". - format(self.__class__.__name__)) + f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'" + ) + + self.message = message + self.match = re.compile(match) if match else None + super().__init__(self._check) - super(retry_if_exception_message, self).__init__(predicate) + def _check(self, exception: BaseException) -> bool: + if self.message: + return self.message == str(exception) + assert self.match is not None + return bool(self.match.match(str(exception))) 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) - # invert predicate - if_predicate = self.predicate - self.predicate = lambda *args_, **kwargs_: not if_predicate( - *args_, **kwargs_) + def _check(self, exception: BaseException) -> bool: + return not super()._check(exception) + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): 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): """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) + def __init__(self, *retries: "RetryBaseT") -> None: + self.retries = retries - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return any(r(retry_state) for r in self.retries) + def __ror__(self, other: "RetryBaseT") -> "retry_any": + if isinstance(other, retry_any): + return retry_any(*other.retries, *self.retries) + return retry_any(other, *self.retries) + 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) + def __init__(self, *retries: "RetryBaseT") -> None: + self.retries = retries - @_compat.retry_dunder_call_accept_old_params - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return all(r(retry_state) for r in self.retries) + + def __rand__(self, other: "RetryBaseT") -> "retry_all": + if isinstance(other, retry_all): + return retry_all(*other.retries, *self.retries) + return retry_all(other, *self.retries) diff --git a/tenacity/stop.py b/tenacity/stop.py index b5874092..c2251b39 100644 --- a/tenacity/stop.py +++ b/tenacity/stop.py @@ -1,4 +1,4 @@ -# Copyright 2016 Julien Danjou +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -14,56 +14,57 @@ # See the License for the specific language governing permissions and # limitations under the License. import abc +import typing -import six +from tenacity import _utils -from tenacity import compat as _compat +if typing.TYPE_CHECKING: + import threading + from tenacity import RetryCallState -@six.add_metaclass(abc.ABCMeta) -class stop_base(object): + +class stop_base(abc.ABC): """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) +StopBaseT = stop_base | typing.Callable[["RetryCallState"], bool] + + 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) + def __init__(self, *stops: stop_base) -> None: + self.stops = stops - @_compat.stop_dunder_call_accept_old_params - 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): - self.stops = tuple(_compat.stop_func_accept_retry_state(stop_func) - for stop_func in stops) + def __init__(self, *stops: stop_base) -> None: + self.stops = stops - @_compat.stop_dunder_call_accept_old_params - 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.""" - @_compat.stop_dunder_call_accept_old_params - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return False @@ -73,31 +74,57 @@ 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 - @_compat.stop_dunder_call_accept_old_params - 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 - @_compat.stop_dunder_call_accept_old_params - 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.""" + """ + 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): - self.max_delay = max_delay + def __init__(self, max_delay: _utils.time_unit_type) -> None: + self.max_delay = _utils.to_seconds(max_delay) - @_compat.stop_dunder_call_accept_old_params - def __call__(self, retry_state): + 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/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py deleted file mode 100644 index 617a0ef5..00000000 --- a/tenacity/tests/test_asyncio.py +++ /dev/null @@ -1,154 +0,0 @@ -# coding: utf-8 -# Copyright 2016 Étienne Bersac -# -# 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 asyncio -import unittest - -import six - -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 - - -def asynctest(callable_): - @six.wraps(callable_) - def wrapper(*a, **kw): - loop = asyncio.get_event_loop() - return loop.run_until_complete(callable_(*a, **kw)) - - return wrapper - - -async def _async_function(thing): - await asyncio.sleep(0.00001) - return thing.go() - - -@retry -async def _retryable_coroutine(thing): - await asyncio.sleep(0.00001) - return thing.go() - - -@retry(stop=stop_after_attempt(2)) -async def _retryable_coroutine_with_2_attempts(thing): - await asyncio.sleep(0.00001) - thing.go() - - -class TestAsync(unittest.TestCase): - @asynctest - async def test_retry(self): - thing = NoIOErrorAfterCount(5) - await _retryable_coroutine(thing) - assert thing.counter == thing.count - - @asynctest - async def test_retry_using_async_retying(self): - thing = NoIOErrorAfterCount(5) - retrying = AsyncRetrying() - await retrying(_async_function, thing) - assert thing.counter == thing.count - - @asynctest - async def test_stop_after_attempt(self): - thing = NoIOErrorAfterCount(2) - try: - await _retryable_coroutine_with_2_attempts(thing) - except RetryError: - assert thing.counter == 2 - - def test_repr(self): - repr(tasyncio.AsyncRetrying()) - - @asynctest - async def test_attempt_number_is_correct_for_interleaved_coroutines(self): - - attempts = [] - - def after(retry_state): - attempts.append((retry_state.args[0], retry_state.attempt_number)) - - thing1 = NoIOErrorAfterCount(3) - thing2 = NoIOErrorAfterCount(3) - - await asyncio.gather( - _retryable_coroutine.retry_with(after=after)(thing1), - _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. - even_thing_attempts = attempts[::2] - things, attempt_nos1 = zip(*even_thing_attempts) - assert len(set(things)) == 1 - assert list(attempt_nos1) == [1, 2, 3] - - odd_thing_attempts = attempts[1::2] - things, attempt_nos2 = zip(*odd_thing_attempts) - assert len(set(things)) == 1 - assert list(attempt_nos2) == [1, 2, 3] - - -class TestContextManager(unittest.TestCase): - @asynctest - async def test_do_max_attempts(self): - attempts = 0 - retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3)) - try: - async for attempt in retrying: - with attempt: - attempts += 1 - raise Exception - except RetryError: - pass - - assert attempts == 3 - - @asynctest - async def test_reraise(self): - class CustomError(Exception): - pass - - try: - async for attempt in tasyncio.AsyncRetrying( - stop=stop_after_attempt(1), reraise=True - ): - with attempt: - raise CustomError() - except CustomError: - pass - else: - raise Exception - - @asynctest - 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) - ): - with attempt: - raise Exception() - except RetryError: - pass - t = current_time_ms() - start - self.assertLess(t, 1.1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py deleted file mode 100644 index 223b19a3..00000000 --- a/tenacity/tests/test_tenacity.py +++ /dev/null @@ -1,1614 +0,0 @@ -# Copyright 2016 Julien Danjou -# Copyright 2016 Joshua Harlow -# Copyright 2013 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 logging -import os -import re -import sys -import time -import typing -import unittest -import warnings -from contextlib import contextmanager -from copy import copy - -import pytest - -import six.moves - -import tenacity -from tenacity import RetryError, Retrying, retry -from tenacity.compat import make_retry_state - - -class TestBase(unittest.TestCase): - - def test_repr(self): - class ConcreteRetrying(tenacity.BaseRetrying): - def __call__(self): - pass - - repr(ConcreteRetrying()) - - -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)) - - 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)) - self.assertTrue(s(4, 0.8)) - self.assertTrue(s(3, 1.8)) - 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)) - - 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)) - self.assertFalse(s(4, 0.8)) - self.assertFalse(s(3, 1.8)) - self.assertTrue(s(4, 1.8)) - - def test_stop_or(self): - stop = 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)) - self.assertTrue(s(4, 0.8)) - self.assertTrue(s(3, 1.8)) - self.assertTrue(s(4, 1.8)) - - def test_stop_and(self): - stop = 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)) - self.assertFalse(s(4, 0.8)) - self.assertFalse(s(3, 1.8)) - self.assertTrue(s(4, 1.8)) - - def test_stop_after_attempt(self): - r = Retrying(stop=tenacity.stop_after_attempt(3)) - self.assertFalse(r.stop(make_retry_state(2, 6546))) - self.assertTrue(r.stop(make_retry_state(3, 6546))) - 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))) - - 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 - return rs.attempt_number == rs.seconds_since_start - - r = Retrying(stop=stop_func) - self.assertFalse(r.stop(make_retry_state(1, 3))) - self.assertFalse(r.stop(make_retry_state(100, 99))) - self.assertTrue(r.stop(make_retry_state(101, 101))) - - -class TestWaitConditions(unittest.TestCase): - - def test_no_sleep(self): - r = Retrying() - self.assertEqual(0, r.wait(18, 9879)) - - def test_fixed_sleep(self): - r = Retrying(wait=tenacity.wait_fixed(1)) - self.assertEqual(1, r.wait(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)) - - 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)) - - # 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): - 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)) - - # this is kind of non-deterministic... - self.assertTrue(len(times) > 1) - for t in times: - self.assertTrue(t >= 0) - self.assertTrue(t <= 2) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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 - 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))) - # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) - self.assertLess(w, 8) - self.assertGreaterEqual(w, 5) - - 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) - self.assertLess(w, 8) - 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)) - # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) - self.assertLess(w, 9) - 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()])) - # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) - self.assertLess(w, 9) - self.assertGreaterEqual(w, 6) - - def _assert_range(self, wait, min_, max_): - self.assertLess(wait, max_) - self.assertGreaterEqual(wait, min_) - - def _assert_inclusive_range(self, wait, low, high): - self.assertLessEqual(wait, high) - self.assertGreaterEqual(wait, low) - - def _assert_inclusive_epsilon(self, wait, target, epsilon): - self.assertLessEqual(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)])) - - for i in six.moves.range(10): - w = r.wait(i + 1, 1) - if i < 2: - self._assert_range(w, 1, 2) - elif i < 4: - self._assert_range(w, 4, 5) - else: - self._assert_range(w, 8, 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) - ]), - stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_if_result(lambda x: x == 1), - ) - - @r.wraps - def always_return_1(): - return 1 - - self.assertRaises(tenacity.RetryError, always_return_1) - self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0]) - sleep_intervals[:] = [] - - # Clear and restart retrying. - self.assertRaises(tenacity.RetryError, always_return_1) - self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0]) - sleep_intervals[:] = [] - - def test_wait_random_exponential(self): - fn = tenacity.wait_random_exponential(0.5, 60.0) - - for _ in six.moves.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) - self._assert_inclusive_range(fn(make_retry_state(4, 0)), 0, 4.0) - self._assert_inclusive_range(fn(make_retry_state(5, 0)), 0, 8.0) - self._assert_inclusive_range(fn(make_retry_state(6, 0)), 0, 16.0) - self._assert_inclusive_range(fn(make_retry_state(7, 0)), 0, 32.0) - 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) - for _ in six.moves.range(1000): - self._assert_inclusive_range( - fn(make_retry_state(1, 0)), 0.00, 5.00 - ) - - # Default arguments exist - fn = tenacity.wait_random_exponential() - fn(0, 0) - - 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)] - ) - - def mean(lst): - return float(sum(lst)) / float(len(lst)) - - # skipping attempt 0 - self._assert_inclusive_epsilon(mean(attempt[1]), 0.25, 0.02) - self._assert_inclusive_epsilon(mean(attempt[2]), 0.50, 0.04) - self._assert_inclusive_epsilon(mean(attempt[3]), 1, 0.08) - self._assert_inclusive_epsilon(mean(attempt[4]), 2, 0.16) - self._assert_inclusive_epsilon(mean(attempt[5]), 4, 0.32) - self._assert_inclusive_epsilon(mean(attempt[6]), 8, 0.64) - self._assert_inclusive_epsilon(mean(attempt[7]), 16, 1.28) - 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): - pass - - # retry_state is mutable, so return it as an exception to extract the - # exact values it has when wait is called and bypass any other logic. - def waitfunc(retry_state): - raise ExtractCallState(retry_state) - - retrying = Retrying( - wait=waitfunc, - 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: - retry_state = err.args[0] - self.assertIs(retry_state.fn, returnval) - self.assertEqual(retry_state.args, ()) - 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) - - def dying(): - raise Exception("Broken") - try: - retrying(dying) - except ExtractCallState as err: - retry_state = err.args[0] - 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(retry_state.attempt_number, 1) - self.assertGreater(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)) - - 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)) - - 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)) - - 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))) - self.assertFalse(r(tenacity.Future.construct(1, 1, True))) - - 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))) - - 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))) - - 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))) - - 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))) - self.assertFalse(r(tenacity.Future.construct(1, 42, True))) - - def _raise_try_again(self): - self._attempts += 1 - if self._attempts < 3: - raise tenacity.TryAgain - - def test_retry_try_again(self): - self._attempts = 0 - 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']) - - 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']) - - def test_retry_if_exception_message_negative_no_inputs(self): - with self.assertRaises(TypeError): - tenacity.retry_if_exception_message() - - def test_retry_if_exception_message_negative_too_many_inputs(self): - with self.assertRaises(TypeError): - tenacity.retry_if_exception_message( - message="negative", match="negative") - - -class NoneReturnUntilAfterCount(object): - """Holds counter state for invoking a method several times in a row.""" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Return None until after count threshold has been crossed. - - Then return True. - """ - if self.counter < self.count: - self.counter += 1 - return None - return True - - -class NoIOErrorAfterCount(object): - """Holds counter state for invoking a method several times in a row.""" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Raise an IOError until after count threshold has been crossed. - - Then return True. - """ - if self.counter < self.count: - self.counter += 1 - raise IOError("Hi there, I'm an IOError") - return True - - -class NoNameErrorAfterCount(object): - """Holds counter state for invoking a method several times in a row.""" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Raise a NameError until after count threshold has been crossed. - - Then return True. - """ - if self.counter < self.count: - self.counter += 1 - raise NameError("Hi there, I'm a NameError") - return True - - -class NameErrorUntilCount(object): - """Holds counter state for invoking a method several times in a row.""" - - derived_message = "Hi there, I'm a NameError" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Return True until after count threshold has been crossed. - - Then raise a NameError. - """ - if self.counter < self.count: - self.counter += 1 - return True - raise NameError(self.derived_message) - - -class IOErrorUntilCount(object): - """Holds counter state for invoking a method several times in a row.""" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Return True until after count threshold has been crossed. - - Then raise an IOError. - """ - if self.counter < self.count: - self.counter += 1 - return True - raise IOError("Hi there, I'm an IOError") - - -class CustomError(Exception): - """This is a custom exception class. - - Note that For Python 2.x, we don't strictly need to extend BaseException, - however, Python 3.x will complain. While this test suite won't run - correctly under Python 3.x without extending from the Python exception - hierarchy, the actual module code is backwards compatible Python 2.x and - will allow for cases where exception classes don't extend from the - hierarchy. - """ - - def __init__(self, value): - self.value = value - - def __str__(self): - return self.value - - -class NoCustomErrorAfterCount(object): - """Holds counter state for invoking a method several times in a row.""" - - derived_message = "This is a Custom exception class" - - def __init__(self, count): - self.counter = 0 - self.count = count - - def go(self): - """Raise a CustomError until after count threshold has been crossed. - - Then return True. - """ - if self.counter < self.count: - self.counter += 1 - raise CustomError(self.derived_message) - return True - - -class CapturingHandler(logging.Handler): - """Captures log records for inspection.""" - - def __init__(self, *args, **kwargs): - super(CapturingHandler, self).__init__(*args, **kwargs) - self.records = [] - - def emit(self, record): - self.records.append(record) - - -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)) -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)) -def _retryable_test_with_stop(thing): - return thing.go() - - -@retry(retry=tenacity.retry_if_exception_type(IOError)) -def _retryable_test_with_exception_type_io(thing): - return thing.go() - - -@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() - - -@retry(retry=tenacity.retry_unless_exception_type(NameError)) -def _retryable_test_with_unless_exception_type_name(thing): - return thing.go() - - -@retry( - stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_unless_exception_type(NameError)) -def _retryable_test_with_unless_exception_type_name_attempt_limit(thing): - return thing.go() - - -@retry(retry=tenacity.retry_unless_exception_type()) -def _retryable_test_with_unless_exception_type_no_input(thing): - return thing.go() - - -@retry( - stop=tenacity.stop_after_attempt(5), - 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)) -def _retryable_test_if_not_exception_message_message(thing): - return thing.go() - - -@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] + ".*")) -def _retryable_test_if_not_exception_message_match(thing): - return thing.go() - - -@retry(retry=tenacity.retry_if_not_exception_message( - message=NameErrorUntilCount.derived_message)) -def _retryable_test_not_exception_message_delay(thing): - return thing.go() - - -@retry -def _retryable_default(thing): - return thing.go() - - -@retry() -def _retryable_default_f(thing): - return thing.go() - - -@retry(retry=tenacity.retry_if_exception_type(CustomError)) -def _retryable_test_with_exception_type_custom(thing): - return thing.go() - - -@retry( - stop=tenacity.stop_after_attempt(3), - 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)) - t = current_time_ms() - start - 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)) - self.fail("Expected RetryError after 3 attempts") - except RetryError as re: - self.assertFalse(re.last_attempt.failed) - self.assertEqual(3, re.last_attempt.attempt_number) - self.assertTrue(re.last_attempt.result() is None) - print(re) - - def test_with_stop_on_exception(self): - try: - _retryable_test_with_stop(NoIOErrorAfterCount(5)) - self.fail("Expected IOError") - except IOError as re: - self.assertTrue(isinstance(re, IOError)) - print(re) - - def test_retry_if_exception_of_type(self): - self.assertTrue(_retryable_test_with_exception_type_io( - NoIOErrorAfterCount(5))) - - try: - _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5)) - self.fail("Expected NameError") - except NameError as n: - self.assertTrue(isinstance(n, NameError)) - print(n) - - self.assertTrue(_retryable_test_with_exception_type_custom( - NoCustomErrorAfterCount(5))) - - try: - _retryable_test_with_exception_type_custom( - NoNameErrorAfterCount(5)) - self.fail("Expected NameError") - except NameError as n: - self.assertTrue(isinstance(n, NameError)) - print(n) - - def test_retry_until_exception_of_type_attempt_number(self): - try: - 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) - print(e) - else: - self.fail("Expected NameError") - - 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)) - ) - except NameError as e: - s = _retryable_test_with_unless_exception_type_no_input.\ - retry.statistics - self.assertTrue(s['attempt_number'] == 6) - print(e) - else: - self.fail("Expected NameError") - - 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)) - self.fail("Expected RetryError") - except RetryError as e: - self.assertTrue(isinstance(e, RetryError)) - print(e) - - def test_retry_if_exception_message(self): - try: - 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))) - 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))) - except NameError: - s = _retryable_test_not_exception_message_delay.retry.statistics - 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))) - 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))) - except CustomError: - 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))) - self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5))) - self.assertTrue(_retryable_default(NoCustomErrorAfterCount(5))) - self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5))) - - def test_retry_function_object(self): - """Test that six.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): - def __call__(self): - return "Hello" - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3)) - 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 - - def test_before_attempts(self): - TestBeforeAfterAttempts._attempt_number = 0 - - def _before(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number - - @retry(wait=tenacity.wait_fixed(1), - stop=tenacity.stop_after_attempt(1), - before=_before) - def _test_before(): - pass - - _test_before() - - self.assertTrue(TestBeforeAfterAttempts._attempt_number == 1) - - def test_after_attempts(self): - TestBeforeAfterAttempts._attempt_number = 0 - - def _after(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number - - @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") - else: - pass - - _test_after() - - self.assertTrue(TestBeforeAfterAttempts._attempt_number == 2) - - def test_before_sleep(self): - 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) - def _test_before_sleep(): - if _before_sleep.attempt_number < 2: - raise Exception("testing before_sleep_attempts handler") - - _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()) - logger.propagate = False - logger.setLevel(logging.INFO) - handler = CapturingHandler() - 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) - 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\.$") - 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) - - 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()) - logger.propagate = False - logger.setLevel(logging.INFO) - 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) - 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(os.linesep), - flags=re.MULTILINE) - 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) - - def test_before_sleep_log_returns(self, exc_info=False): - thing = NoneReturnUntilAfterCount(2) - logger = logging.getLogger(self.id()) - logger.propagate = False - logger.setLevel(logging.INFO) - handler = CapturingHandler() - logger.addHandler(handler) - try: - _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(thing.go) - finally: - logger.removeHandler(handler) - - 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) - - def test_before_sleep_log_returns_with_exc_info(self): - self.test_before_sleep_log_returns(exc_info=True) - - -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) - def _reraised_by_default(): - calls.append('x') - raise KeyError("Bad key") - - self.assertRaises(KeyError, _reraised_by_default) - self.assertEqual(2, len(calls)) - - def test_reraise_from_retry_error(self): - calls = [] - - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2)) - def _raise_key_error(): - calls.append('x') - raise KeyError("Bad key") - - def _reraised_key_error(): - try: - _raise_key_error() - except tenacity.RetryError as retry_err: - retry_err.reraise() - - self.assertRaises(KeyError, _reraised_key_error) - self.assertEqual(2, len(calls)) - - 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) - def _mock_fn(): - calls.append('x') - - def _reraised_mock_fn(): - try: - _mock_fn() - except tenacity.RetryError as retry_err: - retry_err.reraise() - - self.assertRaises(tenacity.RetryError, _reraised_mock_fn) - self.assertEqual(2, len(calls)) - - 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) - def _mock_fn(): - 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(): - return 42 - - self.assertEqual({}, _foobar.retry.statistics) - _foobar() - self.assertEqual(1, _foobar.retry.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) - try: - _foobar() - except Exception: - pass - self.assertEqual(2, _foobar.retry.statistics['attempt_number']) - - -class TestRetryErrorCallback(unittest.TestCase): - - def setUp(self): - self._attempt_number = 0 - self._callback_called = False - - 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 - - def retry_error_callback(retry_state): - retry_error_callback.called_times += 1 - return retry_state.outcome - - 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") - - result = _foobar() - - self.assertEqual(retry_error_callback.called_times, 1) - self.assertEqual(num_attempts, self._attempt_number) - self.assertIsInstance(result, tenacity.Future) - - -class TestContextManager(unittest.TestCase): - def test_context_manager_retry_one(self): - from tenacity import Retrying - - raise_ = True - - for attempt in Retrying(): - with attempt: - if raise_: - raise_ = False - raise Exception("Retry it!") - - def test_context_manager_on_error(self): - from tenacity import Retrying - - class CustomError(Exception): - pass - - retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) - - def test(): - for attempt in retry: - with attempt: - raise CustomError("Don't retry!") - - self.assertRaises(CustomError, test) - - def test_context_manager_retry_error(self): - from tenacity import Retrying - - retry = Retrying(stop=tenacity.stop_after_attempt(2)) - - def test(): - for attempt in retry: - with attempt: - raise Exception("Retry it!") - - self.assertRaises(RetryError, test) - - def test_context_manager_reraise(self): - from tenacity import Retrying - - class CustomError(Exception): - pass - - retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2)) - - def test(): - for attempt in retry: - with attempt: - raise CustomError("Don't retry!") - - self.assertRaises(CustomError, test) - - -class TestInvokeAsCallable: - """Test direct invocation of Retrying as a callable.""" - - @staticmethod - def invoke(retry, f): - """ - Invoke Retrying logic. - - Wrapper allows testing different call mechanisms in test sub-classes. - """ - return retry(f) - - def test_retry_one(self): - def f(): - f.calls.append(len(f.calls) + 1) - if len(f.calls) <= 1: - raise Exception("Retry it!") - return 42 - f.calls = [] - - retry = Retrying() - assert self.invoke(retry, f) == 42 - assert f.calls == [1, 2] - - def test_on_error(self): - class CustomError(Exception): - pass - - def f(): - f.calls.append(len(f.calls) + 1) - if len(f.calls) <= 1: - raise CustomError("Don't retry!") - return 42 - f.calls = [] - - retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) - with pytest.raises(CustomError): - self.invoke(retry, f) - assert f.calls == [1] - - 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)) - with pytest.raises(RetryError): - self.invoke(retry, f) - assert f.calls == [1, 2] - - def test_reraise(self): - class CustomError(Exception): - pass - - 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)) - with pytest.raises(CustomError): - self.invoke(retry, 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 - expected = RetryError(last_attempt=123) - pickled = pickle.dumps(expected) - actual = pickle.loads(pickled) - self.assertEqual(expected.last_attempt, actual.last_attempt) - - -class TestRetryTyping(unittest.TestCase): - - @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 - if sys.version_info < (3, 0): - return - - # Function-level import because we can't install this for python 2. - from typeguard import check_type - - def num_to_str(number): - # type: (int) -> str - return str(number) - - # equivalent to a raw @retry decoration - with_raw = retry(num_to_str) - with_raw_result = with_raw(1) - - # equivalent to a @retry(...) decoration - with_constructor = retry()(num_to_str) - 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) - - -@contextmanager -def reports_deprecation_warning(): - __tracebackhide__ = True - oldfilters = copy(warnings.filters) - warnings.simplefilter('always') - try: - with pytest.warns(DeprecationWarning): - yield - finally: - warnings.filters = oldfilters - - -class TestMockingSleep(): - RETRY_ARGS = dict( - wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(5), - ) - - def _fail(self): - raise NotImplementedError() - - @retry(**RETRY_ARGS) - def _decorated_fail(self): - self._fail() - - @pytest.fixture() - def mock_sleep(self, monkeypatch): - class MockSleep(object): - call_count = 0 - - def __call__(self, seconds): - self.call_count += 1 - - sleep = MockSleep() - 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() - assert mock_sleep.call_count == 4 - - def test_decorated_retry_with(self, mock_sleep): - fail_faster = self._decorated_fail.retry_with( - stop=tenacity.stop_after_attempt(2), - ) - with pytest.raises(RetryError): - fail_faster() - assert mock_sleep.call_count == 1 - - -if __name__ == '__main__': - unittest.main() diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py index 27dd349a..0d46ed7b 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"); @@ -14,36 +13,46 @@ # limitations under the License. import sys - -from tenacity import BaseRetrying -from tenacity import DoAttempt -from tenacity import DoSleep -from tenacity import RetryCallState +import typing from tornado import gen +from tenacity import BaseRetrying, DoAttempt, DoSleep, RetryCallState -class TornadoRetrying(BaseRetrying): +if typing.TYPE_CHECKING: + from tornado.concurrent import Future + +_RetValT = typing.TypeVar("_RetValT") - def __init__(self, - sleep=gen.sleep, - **kwargs): - super(TornadoRetrying, self).__init__(**kwargs) + +class TornadoRetrying(BaseRetrying): + sleep: typing.Callable[..., "Future[None]"] + + 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): - self.begin(fn) - - retry_state = RetryCallState( - retry_object=self, fn=fn, args=args, kwargs=kwargs) + def __call__( # type: ignore[override] + self, + fn: "typing.Callable[..., 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) while True: do = self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): try: result = yield fn(*args, **kwargs) except BaseException: - 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 d3c835f2..b28b7886 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -1,4 +1,4 @@ -# Copyright 2016 Julien Danjou +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013-2014 Ray Holder # @@ -16,72 +16,73 @@ import abc import random - -import six +import typing from tenacity import _utils -from tenacity import compat as _compat + +if typing.TYPE_CHECKING: + from tenacity import RetryCallState -@six.add_metaclass(abc.ABCMeta) -class wait_base(object): +class wait_base(abc.ABC): """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") -> "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) +WaitBaseT = wait_base | typing.Callable[["RetryCallState"], float | int] + + class wait_fixed(wait_base): """Wait strategy that waits a fixed amount of time between each retry.""" - def __init__(self, wait): - self.wait_fixed = wait + def __init__(self, wait: _utils.time_unit_type) -> None: + self.wait_fixed = _utils.to_seconds(wait) - @_compat.wait_dunder_call_accept_old_params - 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 - self.wait_random_min = min - self.wait_random_max = max + def __init__( + self, min: _utils.time_unit_type = 0, max: _utils.time_unit_type = 1 + ) -> None: + self.wait_random_min = _utils.to_seconds(min) + self.wait_random_max = _utils.to_seconds(max) - @_compat.wait_dunder_call_accept_old_params - def __call__(self, retry_state): - return (self.wait_random_min + - (random.random() * - (self.wait_random_max - self.wait_random_min))) + def __call__(self, retry_state: "RetryCallState") -> float: + return self.wait_random_min + ( + random.random() * (self.wait_random_max - self.wait_random_min) + ) 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) + def __init__(self, *strategies: wait_base) -> None: + self.wait_funcs = strategies - @_compat.wait_dunder_call_accept_old_params - 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) @@ -95,24 +96,60 @@ 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): - self.strategies = [_compat.wait_func_accept_retry_state(strategy) - for strategy in strategies] + def __init__(self, *strategies: wait_base) -> None: + 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)) + 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) +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. @@ -120,16 +157,18 @@ 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 - self.start = start - 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) - ) + def __init__( + self, + start: _utils.time_unit_type = 0, + increment: _utils.time_unit_type = 100, + max: _utils.time_unit_type = _utils.MAX_WAIT, + ) -> None: + 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)) return max(0, min(result, self.max)) @@ -146,14 +185,19 @@ 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: float = 1, + max: _utils.time_unit_type = _utils.MAX_WAIT, + exp_base: float = 2, + min: _utils.time_unit_type = 0, + ) -> None: self.multiplier = multiplier - self.min = min - self.max = max + self.min = _utils.to_seconds(min) + self.max = _utils.to_seconds(max) self.exp_base = exp_base - @_compat.wait_dunder_call_accept_old_params - 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 @@ -188,8 +232,40 @@ 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) - return random.uniform(0, high) + def __call__(self, retry_state: "RetryCallState") -> float: + high = super().__call__(retry_state=retry_state) + return random.uniform(self.min, 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, + 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/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/tests/test_after.py b/tests/test_after.py new file mode 100644 index 00000000..ccf4a730 --- /dev/null +++ b/tests/test_after.py @@ -0,0 +1,84 @@ +import logging +import random +import unittest.mock + +from tenacity import ( + _utils, + after_log, +) + +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) -> None: + """Test log formatting.""" + log = unittest.mock.MagicMock(spec="logging.Logger.log") + logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) + + sec_format = "%.3g" + 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 '{retry_state.get_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.", + ) + + def test_02_none_seconds_since_start(self) -> None: + """Test log formatting when seconds_since_start is None.""" + log = unittest.mock.MagicMock(spec="logging.Logger.log") + logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) + + retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, 0.1) + retry_state.outcome_timestamp = None + assert retry_state.seconds_since_start is None + + fun = after_log(logger=logger, log_level=self.log_level) + fun(retry_state) + log.assert_called_once_with( + self.log_level, + f"Finished call to '{retry_state.get_fn_name()}' " + f"after ?(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", + ) + + def test_02_custom_sec_format(self) -> None: + """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 '{retry_state.get_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/tests/test_asyncio.py b/tests/test_asyncio.py new file mode 100644 index 00000000..7b5c6416 --- /dev/null +++ b/tests/test_asyncio.py @@ -0,0 +1,535 @@ +# Copyright 2016 Étienne Bersac +# +# 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 asyncio +import inspect +import unittest +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, TypeVar +from unittest import mock + +try: + import trio +except ImportError: + have_trio = False +else: + have_trio = True + +import pytest + +import tenacity +from tenacity import ( + AsyncRetrying, + RetryCallState, + RetryError, + retry, + retry_if_exception, + retry_if_result, + stop_after_attempt, +) +from tenacity import asyncio as tasyncio +from tenacity.wait import wait_fixed + +from .test_tenacity import ( + NoIOErrorAfterCount, + NoneReturnUntilAfterCount, + current_time_ms, +) + +_F = TypeVar("_F", bound=Callable[..., Coroutine[Any, Any, Any]]) + + +def asynctest(callable_: _F) -> Callable[..., Any]: + @wraps(callable_) + def wrapper(*a: Any, **kw: Any) -> Any: + return asyncio.run(callable_(*a, **kw)) + + return wrapper + + +async def _async_function(thing: NoIOErrorAfterCount) -> Any: + await asyncio.sleep(0.00001) + return thing.go() + + +@retry +async def _retryable_coroutine(thing: NoIOErrorAfterCount) -> Any: + await asyncio.sleep(0.00001) + return thing.go() + + +@retry(stop=stop_after_attempt(2)) +async def _retryable_coroutine_with_2_attempts(thing: NoIOErrorAfterCount) -> Any: + await asyncio.sleep(0.00001) + return thing.go() + + +class TestAsyncio(unittest.TestCase): + @asynctest + async def test_retry(self) -> None: + thing = NoIOErrorAfterCount(5) + await _retryable_coroutine(thing) + assert thing.counter == thing.count + + @asynctest + async def test_iscoroutinefunction(self) -> None: + assert asyncio.iscoroutinefunction(_retryable_coroutine) + assert inspect.iscoroutinefunction(_retryable_coroutine) + + @asynctest + async def test_retry_using_async_retying(self) -> None: + thing = NoIOErrorAfterCount(5) + retrying = AsyncRetrying() + await retrying(_async_function, thing) + assert thing.counter == thing.count + + @asynctest + async def test_stop_after_attempt(self) -> None: + thing = NoIOErrorAfterCount(2) + try: + await _retryable_coroutine_with_2_attempts(thing) + except RetryError: + assert thing.counter == 2 + + def test_repr(self) -> None: + repr(tasyncio.AsyncRetrying()) + + def test_retry_attributes(self) -> None: + assert hasattr(_retryable_coroutine, "retry") + assert hasattr(_retryable_coroutine, "retry_with") + + def test_retry_preserves_argument_defaults(self) -> None: + async def function_with_defaults(a: int = 1) -> int: + return a + + async def function_with_kwdefaults(*, a: int = 1) -> int: + 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__, # type: ignore[attr-defined] + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, # type: ignore[attr-defined] + ) + + @asynctest + async def test_attempt_number_is_correct_for_interleaved_coroutines(self) -> None: + attempts: list[Any] = [] + + def after(retry_state: RetryCallState) -> None: + attempts.append((retry_state.args[0], retry_state.attempt_number)) + + thing1 = NoIOErrorAfterCount(3) + thing2 = NoIOErrorAfterCount(3) + + await asyncio.gather( + _retryable_coroutine.retry_with(after=after)(thing1), + _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. + even_thing_attempts = attempts[::2] + things, attempt_nos1 = zip(*even_thing_attempts) + assert len(set(things)) == 1 + assert list(attempt_nos1) == [1, 2, 3] + + odd_thing_attempts = attempts[1::2] + things, attempt_nos2 = zip(*odd_thing_attempts) + assert len(set(things)) == 1 + assert list(attempt_nos2) == [1, 2, 3] + + +class TestAsyncEnabled(unittest.TestCase): + @asynctest + async def test_enabled_false_skips_retry(self) -> None: + """When enabled=False, async function is called directly without retrying.""" + call_count = 0 + + @retry(enabled=False, stop=stop_after_attempt(3)) + async def always_fails() -> None: + nonlocal call_count + call_count += 1 + raise ValueError("fail") + + with pytest.raises(ValueError, match="fail"): + await always_fails() + assert call_count == 1 + + +@unittest.skipIf(not have_trio, "trio not installed") +class TestTrio(unittest.TestCase): + def test_trio_basic(self) -> None: + thing = NoIOErrorAfterCount(5) + + @retry + async def trio_function() -> Any: + 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) -> None: + attempts = 0 + retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3)) + try: + async for attempt in retrying: + with attempt: + attempts += 1 + raise Exception + except RetryError: + pass + + assert attempts == 3 + + @asynctest + async def test_async_with_attempt_manager(self) -> None: + """AttemptManager supports async with for use inside async for.""" + attempts = 0 + retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3)) + try: + async for attempt in retrying: + async with attempt: + attempts += 1 + raise Exception + except RetryError: + pass + + assert attempts == 3 + + @asynctest + async def test_reraise(self) -> None: + class CustomError(Exception): + pass + + try: + async for attempt in tasyncio.AsyncRetrying( + stop=stop_after_attempt(1), reraise=True + ): + with attempt: + raise CustomError + except CustomError: + pass + else: + raise Exception + + @asynctest + async def test_sleeps(self) -> None: + start = current_time_ms() + try: + async for attempt in tasyncio.AsyncRetrying( + stop=stop_after_attempt(1), wait=wait_fixed(1) + ): + with attempt: + raise Exception + except RetryError: + pass + t = current_time_ms() - start + self.assertLess(t, 1.1) + + @asynctest + async def test_retry_with_result(self) -> None: + async def test() -> int: + attempts = 0 + + # 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) + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_result(self) -> None: + async def test() -> int: + 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) -> None: + async def test() -> int: + 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) -> None: + async def test() -> int: + 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) -> None: + async def test() -> int: + 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) -> None: + async def test() -> int: + 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) -> None: + async def test() -> int: + 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) -> None: + thing = NoIOErrorAfterCount(5) + with pytest.raises(TypeError): + for attempts in AsyncRetrying(): + with attempts: + await _async_function(thing) + + +class TestDecoratorWrapper(unittest.TestCase): + @asynctest + async def test_retry_function_attributes(self) -> None: + """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 are synced with function statistics + """ + + 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, + expected_stats, + ) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, + expected_stats, + ) + + with mock.patch.object( + _retryable_coroutine_with_2_attempts.retry, + "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, + expected_stats, + ) + self.assertEqual(exc.last_attempt.attempt_number, 1) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, + expected_stats, + ) + 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: + await asyncio.sleep(x) + + +@retry(sleep=my_async_sleep) +async def foo() -> None: + pass + + +class TestSyncFunctionWithAsyncSleep(unittest.TestCase): + @asynctest + async def test_sync_function_with_async_sleep(self) -> None: + """A sync function with an async sleep callable uses AsyncRetrying.""" + mock_sleep = mock.AsyncMock() + + thing = NoneReturnUntilAfterCount(2) + + @retry( + sleep=mock_sleep, + wait=wait_fixed(1), + retry=retry_if_result(lambda x: x is None), + ) + def sync_function() -> Any: + return thing.go() + + result = await sync_function() + assert result is True + assert mock_sleep.await_count == 2 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_478.py b/tests/test_issue_478.py new file mode 100644 index 00000000..a8589436 --- /dev/null +++ b/tests/test_issue_478.py @@ -0,0 +1,116 @@ +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: + return asyncio.run(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: + raise AssertionError("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: + raise AssertionError("No exception caught") + + assert results == [ + "Error is not working", + "Fix is working", + "Error is not working", + ] diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py new file mode 100644 index 00000000..efefba8c --- /dev/null +++ b/tests/test_tenacity.py @@ -0,0 +1,2088 @@ +# Copyright 2016–2021 Julien Danjou +# Copyright 2016 Joshua Harlow +# Copyright 2013 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 contextlib +import datetime +import logging +import pickle +import re +import time +import typing +import unittest +from fractions import Fraction +from unittest import mock + +import pytest +from typeguard import check_type + +import tenacity +from tenacity import RetryCallState, RetryError, Retrying, retry +from tenacity.retry import retry_all, retry_any + +_unset = object() + + +def _make_unset_exception(func_name: str, **kwargs: typing.Any) -> TypeError: + missing = [] + for k, v in kwargs.items(): + 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: RetryCallState, delay: typing.Any) -> None: + # 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) # type: ignore[assignment] + retry_state.outcome_timestamp = retry_state.start_time + Fraction(delay) + assert retry_state.seconds_since_start == delay + + +def make_retry_state( + previous_attempt_number: typing.Any, + delay_since_first_attempt: typing.Any, + last_result: typing.Any = None, + upcoming_sleep: typing.Any = 0, +) -> RetryCallState: + """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, (), {}) # type: ignore[arg-type] + retry_state.attempt_number = previous_attempt_number + if last_result is not None: + 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 + + +class TestBase(unittest.TestCase): + def test_retrying_repr(self) -> None: + class ConcreteRetrying(tenacity.BaseRetrying): + def __call__( + self, fn: typing.Any, *args: typing.Any, **kwargs: typing.Any + ) -> typing.Any: + pass + + repr(ConcreteRetrying()) + + def test_callstate_repr(self) -> None: + rs = RetryCallState(None, None, (), {}) # type: ignore[arg-type] + 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 TestRetryingName(unittest.TestCase): + def test_str_default(self) -> None: + """Without a name, str() returns ''.""" + assert str(Retrying()) == "" + + def test_str_with_name(self) -> None: + """With a name, str() returns the given name.""" + assert str(Retrying(name="my_block")) == "my_block" + + def test_str_preserved_by_copy(self) -> None: + """copy() preserves the name.""" + r = Retrying(name="my_block") + assert str(r.copy()) == "my_block" + + def test_str_overridden_by_copy(self) -> None: + """copy() allows overriding the name.""" + r = Retrying(name="original") + assert str(r.copy(name="overridden")) == "overridden" + + def test_get_fn_name_decorator(self) -> None: + """get_fn_name() returns the function's qualified name when used as decorator.""" + captured: list[RetryCallState] = [] + + @tenacity.retry( + stop=tenacity.stop_after_attempt(1), + after=lambda rs: captured.append(rs), + ) + def my_func() -> None: + raise ValueError + + with contextlib.suppress(Exception): + my_func() + assert captured + assert "my_func" in captured[0].get_fn_name() + + def test_get_fn_name_context_manager_no_name(self) -> None: + """get_fn_name() returns '' in context manager mode without a name.""" + r = Retrying(stop=tenacity.stop_after_attempt(1)) + rs = RetryCallState(r, None, (), {}) + assert rs.get_fn_name() == "" + + def test_get_fn_name_context_manager_with_name(self) -> None: + """get_fn_name() returns the given name in context manager mode.""" + r = Retrying(name="ws_listener", stop=tenacity.stop_after_attempt(1)) + rs = RetryCallState(r, None, (), {}) + assert rs.get_fn_name() == "ws_listener" + + def test_logging_uses_name(self) -> None: + """before_log uses the name parameter in context manager mode.""" + import unittest.mock + + log = unittest.mock.MagicMock() + logger = unittest.mock.MagicMock(log=log) + + with contextlib.suppress(Exception): + for attempt in Retrying( + name="my_block", + before=tenacity.before_log(logger, logging.INFO), + stop=tenacity.stop_after_attempt(1), + ): + with attempt: + raise ValueError + + args = log.call_args[0] + assert "my_block" in args[1] + + +class TestStopConditions(unittest.TestCase): + def test_never_stop(self) -> None: + r = Retrying() + self.assertFalse(r.stop(make_retry_state(3, 6546))) + + def test_stop_any(self) -> None: + stop = tenacity.stop_any( + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) + + def s(*args: typing.Any) -> bool: + return stop(make_retry_state(*args)) + + self.assertFalse(s(1, 0.1)) + self.assertFalse(s(2, 0.2)) + self.assertFalse(s(2, 0.8)) + self.assertTrue(s(4, 0.8)) + self.assertTrue(s(3, 1.8)) + self.assertTrue(s(4, 1.8)) + + def test_stop_all(self) -> None: + stop = tenacity.stop_all( + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) + + def s(*args: typing.Any) -> bool: + return stop(make_retry_state(*args)) + + self.assertFalse(s(1, 0.1)) + self.assertFalse(s(2, 0.2)) + self.assertFalse(s(2, 0.8)) + self.assertFalse(s(4, 0.8)) + self.assertFalse(s(3, 1.8)) + self.assertTrue(s(4, 1.8)) + + def test_stop_or(self) -> None: + stop = tenacity.stop_after_delay(1) | tenacity.stop_after_attempt(4) + + def s(*args: typing.Any) -> bool: + return stop(make_retry_state(*args)) + + self.assertFalse(s(1, 0.1)) + self.assertFalse(s(2, 0.2)) + self.assertFalse(s(2, 0.8)) + self.assertTrue(s(4, 0.8)) + self.assertTrue(s(3, 1.8)) + self.assertTrue(s(4, 1.8)) + + def test_stop_and(self) -> None: + stop = tenacity.stop_after_delay(1) & tenacity.stop_after_attempt(4) + + def s(*args: typing.Any) -> bool: + return stop(make_retry_state(*args)) + + self.assertFalse(s(1, 0.1)) + self.assertFalse(s(2, 0.2)) + self.assertFalse(s(2, 0.8)) + self.assertFalse(s(4, 0.8)) + self.assertFalse(s(3, 1.8)) + self.assertTrue(s(4, 1.8)) + + def test_stop_after_attempt(self) -> None: + r = Retrying(stop=tenacity.stop_after_attempt(3)) + self.assertFalse(r.stop(make_retry_state(2, 6546))) + self.assertTrue(r.stop(make_retry_state(3, 6546))) + self.assertTrue(r.stop(make_retry_state(4, 6546))) + + def test_stop_after_delay(self) -> None: + 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_stop_before_delay(self) -> None: + 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) -> None: + Retrying(stop="stop_after_attempt") # type: ignore[arg-type] + + def test_stop_func_with_retry_state(self) -> None: + def stop_func(retry_state: RetryCallState) -> bool: + rs = retry_state + return rs.attempt_number == rs.seconds_since_start + + r = Retrying(stop=stop_func) + self.assertFalse(r.stop(make_retry_state(1, 3))) + self.assertFalse(r.stop(make_retry_state(100, 99))) + self.assertTrue(r.stop(make_retry_state(101, 101))) + + +class TestWaitConditions(unittest.TestCase): + def test_no_sleep(self) -> None: + r = Retrying() + self.assertEqual(0, r.wait(make_retry_state(18, 9879))) + + def test_fixed_sleep(self) -> None: + 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) -> None: + 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) -> None: + 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) -> None: + r = Retrying(wait=tenacity.wait_random(max=2)) + times = set() + 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) + for t in times: + self.assertTrue(t >= 0) + self.assertTrue(t <= 2) + + def test_exponential(self) -> None: + r = Retrying(wait=tenacity.wait_exponential()) + 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) -> None: + r = Retrying(wait=tenacity.wait_exponential(max=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) -> None: + r = Retrying(wait=tenacity.wait_exponential(min=20)) + 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) -> None: + 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) + 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) -> None: + 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) + 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_andmax__wait(self) -> None: + 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) -> None: + Retrying(wait="exponential_sleep") # type: ignore[arg-type] + + def test_wait_func(self) -> None: + def wait_func(retry_state: RetryCallState) -> typing.Any: + return retry_state.attempt_number * retry_state.seconds_since_start # type: ignore[operator] + + 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) -> None: + 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)) + self.assertLess(w, 8) + self.assertGreaterEqual(w, 5) + + def test_wait_exception(self) -> None: + def predicate(exc: BaseException) -> float: + 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) -> None: + r = Retrying(wait=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)) + self.assertLess(w, 8) + self.assertGreaterEqual(w, 5) + + def test_wait_triple_sum(self) -> None: + 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)) + self.assertLess(w, 9) + self.assertGreaterEqual(w, 6) + + def test_wait_arbitrary_sum(self) -> None: + r = Retrying( + wait=sum( # type: ignore[arg-type] + [ + tenacity.wait_fixed(1), # type: ignore[list-item] + tenacity.wait_random(0, 3), # type: ignore[list-item] + tenacity.wait_fixed(5), # type: ignore[list-item] + tenacity.wait_none(), # type: ignore[list-item] + ] + ) + ) + # Test it a few time since it's random + for _ in range(1000): + w = r.wait(make_retry_state(1, 5)) + self.assertLess(w, 9) + self.assertGreaterEqual(w, 6) + + def _assert_range(self, wait: float, min_: float, max_: float) -> None: + self.assertLess(wait, max_) + self.assertGreaterEqual(wait, min_) + + def _assert_inclusive_range(self, wait: float, low: float, high: float) -> None: + self.assertLessEqual(wait, high) + self.assertGreaterEqual(wait, low) + + def _assert_inclusive_epsilon( + self, wait: float, target: float, epsilon: float + ) -> None: + self.assertLessEqual(wait, target + epsilon) + self.assertGreaterEqual(wait, target - epsilon) + + def test_wait_chain(self) -> None: + r = Retrying( + wait=tenacity.wait_chain( + *[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 range(10): + w = r.wait(make_retry_state(i + 1, 1)) + if i < 2: + self._assert_range(w, 1, 2) + elif i < 4: + self._assert_range(w, 4, 5) + else: + self._assert_range(w, 8, 9) + + def test_wait_chain_multiple_invocations(self) -> None: + sleep_intervals: list[float] = [] + r = Retrying( + sleep=sleep_intervals.append, + 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), + ) + + @r.wraps + def always_return_1() -> int: + return 1 + + self.assertRaises(tenacity.RetryError, always_return_1) + self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0]) + sleep_intervals[:] = [] + + # Clear and restart retrying. + self.assertRaises(tenacity.RetryError, always_return_1) + self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0]) + sleep_intervals[:] = [] + + def test_wait_random_exponential(self) -> None: + fn = tenacity.wait_random_exponential(0.5, 60.0) + + 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) + self._assert_inclusive_range(fn(make_retry_state(4, 0)), 0, 4.0) + self._assert_inclusive_range(fn(make_retry_state(5, 0)), 0, 8.0) + self._assert_inclusive_range(fn(make_retry_state(6, 0)), 0, 16.0) + self._assert_inclusive_range(fn(make_retry_state(7, 0)), 0, 32.0) + 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) + + # 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, 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() + fn(make_retry_state(0, 0)) + + def test_wait_random_exponential_statistically(self) -> None: + fn = tenacity.wait_random_exponential(0.5, 60.0) + + attempt = [[fn(make_retry_state(i, 0)) for _ in range(4000)] for i in range(10)] + + def mean(lst: list[float]) -> float: + return float(sum(lst)) / float(len(lst)) + + # skipping attempt 0 + self._assert_inclusive_epsilon(mean(attempt[1]), 0.25, 0.02) + self._assert_inclusive_epsilon(mean(attempt[2]), 0.50, 0.04) + self._assert_inclusive_epsilon(mean(attempt[3]), 1, 0.08) + self._assert_inclusive_epsilon(mean(attempt[4]), 2, 0.16) + self._assert_inclusive_epsilon(mean(attempt[5]), 4, 0.32) + self._assert_inclusive_epsilon(mean(attempt[6]), 8, 0.64) + self._assert_inclusive_epsilon(mean(attempt[7]), 16, 1.28) + 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) -> None: + 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) -> None: + class ExtractCallState(Exception): + pass + + # retry_state is mutable, so return it as an exception to extract the + # exact values it has when wait is called and bypass any other logic. + def waitfunc(retry_state: RetryCallState) -> float: + raise ExtractCallState(retry_state) + + retrying = Retrying( + wait=waitfunc, + retry=( + tenacity.retry_if_exception_type() + | tenacity.retry_if_result(lambda result: result == 123) + ), + ) + + def returnval() -> int: + return 123 + + try: + retrying(returnval) + except ExtractCallState as err: + retry_state = err.args[0] + self.assertIs(retry_state.fn, returnval) + self.assertEqual(retry_state.args, ()) + 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) + + def dying() -> None: + raise Exception("Broken") + + try: + retrying(dying) + except ExtractCallState as err: + retry_state = err.args[0] + 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(retry_state.attempt_number, 1) + self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time) + + +class TestRetryConditions(unittest.TestCase): + def test_retry_if_result(self) -> None: + retry = tenacity.retry_if_result(lambda x: x == 1) + + def r(fut: tenacity.Future) -> bool: + 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) -> None: + retry = tenacity.retry_if_not_result(lambda x: x == 1) + + def r(fut: tenacity.Future) -> bool: + 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) -> None: + retry = tenacity.retry_any( + tenacity.retry_if_result(lambda x: x == 1), + tenacity.retry_if_result(lambda x: x == 2), + ) + + def r(fut: tenacity.Future) -> bool: + 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))) + self.assertFalse(r(tenacity.Future.construct(1, 1, True))) + + def test_retry_all(self) -> None: + retry = tenacity.retry_all( + tenacity.retry_if_result(lambda x: x == 1), + tenacity.retry_if_result(lambda x: isinstance(x, int)), + ) + + def r(fut: tenacity.Future) -> bool: + 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) -> None: + retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result( + lambda x: isinstance(x, int) + ) + + def r(fut: tenacity.Future) -> bool: + 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) -> None: + retry = tenacity.retry_if_result( + lambda x: x == "foo" + ) | tenacity.retry_if_result(lambda x: isinstance(x, int)) + + def r(fut: tenacity.Future) -> bool: + 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))) + self.assertFalse(r(tenacity.Future.construct(1, 42, True))) + + def test_retry_or_with_plain_function(self) -> None: + """Plain callables can be composed with retry_base via |.""" + + def my_retry(retry_state: tenacity.RetryCallState) -> bool: + return retry_state.outcome is not None and not retry_state.outcome.failed + + # retry_base | plain_callable (exercises __or__ fallback) + retry = tenacity.retry_if_exception_type(Exception) | my_retry + retry_state = make_retry_state( + 1, 1.0, last_result=tenacity.Future.construct(1, "ok", False) + ) + self.assertTrue(retry(retry_state)) + + # plain_callable | retry_base (exercises __ror__ via reflection) + retry2 = my_retry | tenacity.retry_if_exception_type(Exception) + self.assertTrue(retry2(retry_state)) + + def test_retry_and_with_plain_function(self) -> None: + """Plain callables can be composed with retry_base via &.""" + + def my_retry(retry_state: tenacity.RetryCallState) -> bool: + return True + + # retry_base & plain_callable (exercises __and__ fallback) + retry = tenacity.retry_if_result(lambda x: x == 1) & my_retry + retry_state = make_retry_state( + 1, 1.0, last_result=tenacity.Future.construct(1, 1, False) + ) + self.assertTrue(retry(retry_state)) + + # plain_callable & retry_base (exercises __rand__ via reflection) + retry2 = my_retry & tenacity.retry_if_result(lambda x: x == 1) + self.assertTrue(retry2(retry_state)) + + def test_retry_or_coalesces(self) -> None: + """Multiple | operations flatten into a single retry_any.""" + a = tenacity.retry_if_exception_type(IOError) + b = tenacity.retry_if_exception_type(OSError) + c = tenacity.retry_if_exception_type(ValueError) + + combined = a | b | c + self.assertIsInstance(combined, retry_any) + self.assertEqual(len(combined.retries), 3) + + def test_retry_and_coalesces(self) -> None: + """Multiple & operations flatten into a single retry_all.""" + a = tenacity.retry_if_result(lambda x: x == 1) + b = tenacity.retry_if_result(lambda x: x > 0) + c = tenacity.retry_if_result(lambda x: x < 10) + + combined = a & b & c + self.assertIsInstance(combined, retry_all) + self.assertEqual(len(combined.retries), 3) + + def _raise_try_again(self) -> None: + self._attempts += 1 + if self._attempts < 3: + raise tenacity.TryAgain + + def test_retry_try_again(self) -> None: + self._attempts = 0 + 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) -> None: + def _r() -> None: + 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"]) + + def test_retry_try_again_forever_reraise(self) -> None: + def _r() -> None: + 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"]) + + def test_retry_if_exception_message_negative_no_inputs(self) -> None: + with self.assertRaises(TypeError): + tenacity.retry_if_exception_message() + + def test_retry_if_exception_message_negative_too_many_inputs(self) -> None: + with self.assertRaises(TypeError): + tenacity.retry_if_exception_message(message="negative", match="negative") + + +class NoneReturnUntilAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Return None until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + return None + return True + + +class NoIOErrorAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Raise an IOError until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + raise OSError("Hi there, I'm an IOError") + return True + + +class NoNameErrorAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Raise a NameError until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + raise NameError("Hi there, I'm a NameError") + return True + + +class NoNameErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go2(self) -> typing.Any: + raise NameError("Hi there, I'm a NameError") + + def go(self) -> typing.Any: + """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 OSError from e + + return True + + +class NoIOErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go2(self) -> typing.Any: + raise OSError("Hi there, I'm an IOError") + + def go(self) -> typing.Any: + """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 OSError as e: + raise NameError from e + + return True + + +class NameErrorUntilCount: + """Holds counter state for invoking a method several times in a row.""" + + derived_message = "Hi there, I'm a NameError" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Return True until after count threshold has been crossed. + + Then raise a NameError. + """ + if self.counter < self.count: + self.counter += 1 + return True + raise NameError(self.derived_message) + + +class IOErrorUntilCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Return True until after count threshold has been crossed. + + Then raise an IOError. + """ + if self.counter < self.count: + self.counter += 1 + return True + raise OSError("Hi there, I'm an IOError") + + +class CustomError(Exception): + """This is a custom exception class. + + Note that For Python 2.x, we don't strictly need to extend BaseException, + however, Python 3.x will complain. While this test suite won't run + correctly under Python 3.x without extending from the Python exception + hierarchy, the actual module code is backwards compatible Python 2.x and + will allow for cases where exception classes don't extend from the + hierarchy. + """ + + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + +class NoCustomErrorAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + derived_message = "This is a Custom exception class" + + def __init__(self, count: int) -> None: + self.counter = 0 + self.count = count + + def go(self) -> typing.Any: + """Raise a CustomError until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + raise CustomError(self.derived_message) + return True + + +class CapturingHandler(logging.Handler): + """Captures log records for inspection.""" + + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().__init__(*args, **kwargs) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +def current_time_ms() -> int: + return round(time.time() * 1000) + + +@retry( + wait=tenacity.wait_fixed(0.05), + retry=tenacity.retry_if_result(lambda result: result is None), +) +def _retryable_test_with_wait(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_result(lambda result: result is None), +) +def _retryable_test_with_stop(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_if_exception_cause_type(NameError)) +def _retryable_test_with_exception_cause_type(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_if_exception_type(IOError)) +def _retryable_test_with_exception_type_io(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_if_not_exception_type(IOError)) +def _retryable_test_if_not_exception_type_io(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError) +) +def _retryable_test_with_exception_type_io_attempt_limit( + thing: typing.Any, +) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_unless_exception_type(NameError)) +def _retryable_test_with_unless_exception_type_name(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_unless_exception_type(NameError), +) +def _retryable_test_with_unless_exception_type_name_attempt_limit( + thing: typing.Any, +) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_unless_exception_type()) +def _retryable_test_with_unless_exception_type_no_input( + thing: typing.Any, +) -> typing.Any: + return thing.go() + + +@retry( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_if_exception_message( + message=NoCustomErrorAfterCount.derived_message + ), +) +def _retryable_test_if_exception_message_message(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NoCustomErrorAfterCount.derived_message + ) +) +def _retryable_test_if_not_exception_message_message(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + retry=tenacity.retry_if_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) +def _retryable_test_if_exception_message_match(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + retry=tenacity.retry_if_not_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) +def _retryable_test_if_not_exception_message_match(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NameErrorUntilCount.derived_message + ) +) +def _retryable_test_not_exception_message_delay(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry +def _retryable_default(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry() +def _retryable_default_f(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry(retry=tenacity.retry_if_exception_type(CustomError)) +def _retryable_test_with_exception_type_custom(thing: typing.Any) -> typing.Any: + return thing.go() + + +@retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_exception_type(CustomError), +) +def _retryable_test_with_exception_type_custom_attempt_limit( + thing: typing.Any, +) -> typing.Any: + return thing.go() + + +class TestDecoratorWrapper(unittest.TestCase): + def test_with_wait(self) -> None: + start = current_time_ms() + result = _retryable_test_with_wait(NoneReturnUntilAfterCount(5)) + t = current_time_ms() - start + self.assertGreaterEqual(t, 250) + self.assertTrue(result) + + def test_with_stop_on_return_value(self) -> None: + try: + _retryable_test_with_stop(NoneReturnUntilAfterCount(5)) + self.fail("Expected RetryError after 3 attempts") + except RetryError as re: + self.assertFalse(re.last_attempt.failed) + self.assertEqual(3, re.last_attempt.attempt_number) + self.assertTrue(re.last_attempt.result() is None) + print(re) + + def test_with_stop_on_exception(self) -> None: + try: + _retryable_test_with_stop(NoIOErrorAfterCount(5)) + self.fail("Expected IOError") + except OSError as re: + self.assertTrue(isinstance(re, IOError)) + print(re) + + def test_retry_if_exception_of_type(self) -> None: + self.assertTrue(_retryable_test_with_exception_type_io(NoIOErrorAfterCount(5))) + + try: + _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5)) + self.fail("Expected NameError") + except NameError as n: + self.assertTrue(isinstance(n, NameError)) + print(n) + + self.assertTrue( + _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5)) + ) + + try: + _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5)) + self.fail("Expected NameError") + except NameError as n: + self.assertTrue(isinstance(n, NameError)) + print(n) + + def test_retry_except_exception_of_type(self) -> None: + 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 OSError as err: + self.assertTrue(isinstance(err, IOError)) + print(err) + + def test_retry_until_exception_of_type_attempt_number(self) -> None: + try: + self.assertTrue( + _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) + ) + except NameError as e: + s = _retryable_test_with_unless_exception_type_name.statistics + self.assertTrue(s["attempt_number"] == 6) + print(e) + else: + self.fail("Expected NameError") + + def test_retry_until_exception_of_type_no_type(self) -> None: + try: + # no input should catch all subclasses of Exception + 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.statistics + self.assertTrue(s["attempt_number"] == 6) + print(e) + else: + self.fail("Expected NameError") + + def test_retry_until_exception_of_type_wrong_exception(self) -> None: + try: + # two iterations with IOError, one that returns True + _retryable_test_with_unless_exception_type_name_attempt_limit( + IOErrorUntilCount(2) + ) + self.fail("Expected RetryError") + except RetryError as e: + self.assertTrue(isinstance(e, RetryError)) + print(e) + + def test_retry_if_exception_message(self) -> None: + try: + self.assertTrue( + _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3)) + ) + except CustomError: + 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) -> None: + try: + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) + except CustomError: + s = _retryable_test_if_not_exception_message_message.statistics + self.assertTrue(s["attempt_number"] == 1) + + def test_retry_if_not_exception_message_delay(self) -> None: + try: + self.assertTrue( + _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) + ) + except NameError: + s = _retryable_test_not_exception_message_delay.statistics + print(s["attempt_number"]) + self.assertTrue(s["attempt_number"] == 4) + + def test_retry_if_exception_message_match(self) -> None: + try: + 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) -> None: + try: + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) + except CustomError: + s = _retryable_test_if_not_exception_message_message.statistics + self.assertTrue(s["attempt_number"] == 1) + + def test_retry_if_exception_cause_type(self) -> None: + 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_retry_preserves_argument_defaults(self) -> None: + def function_with_defaults(a: int = 1) -> int: + return a + + def function_with_kwdefaults(*, a: int = 1) -> int: + 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__, # type: ignore[attr-defined] + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, # type: ignore[attr-defined] + ) + + def test_defaults(self) -> None: + self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) + self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5))) + self.assertTrue(_retryable_default(NoCustomErrorAfterCount(5))) + self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5))) + + def test_retry_function_object(self) -> None: + """Test that functools.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: + def __call__(self) -> str: + return "Hello" + + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) + h = retrying.wraps(Hello()) + self.assertEqual(h(), "Hello") + + def test_retry_function_attributes(self) -> None: + """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 are synced with function statistics + """ + + 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, expected_stats) + + 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, expected_stats + ) + else: + self.fail("RetryError should have been raised after 1 attempt") + + +class TestStatisticsKeys: + def test_delay_since_first_attempt_available_on_first_attempt(self) -> None: + """delay_since_first_attempt should be in statistics from the start.""" + + @retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_result(lambda x: x is None), + ) + def succeeds_first_try() -> bool: + assert "delay_since_first_attempt" in succeeds_first_try.statistics + assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0 + return True + + succeeds_first_try() + assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0 + + +class TestEnabled: + def test_enabled_false_skips_retry(self) -> None: + """When enabled=False, the function is called directly without retrying.""" + call_count = 0 + + @retry(enabled=False, stop=tenacity.stop_after_attempt(3)) + def always_fails() -> None: + nonlocal call_count + call_count += 1 + raise ValueError("fail") + + with pytest.raises(ValueError, match="fail"): + always_fails() + assert call_count == 1 + + def test_enabled_false_preserves_attributes(self) -> None: + """When enabled=False, .retry, .retry_with, .statistics are still available.""" + + @retry(enabled=False, stop=tenacity.stop_after_attempt(3)) + def my_func() -> str: + return "ok" + + assert hasattr(my_func, "retry") + assert hasattr(my_func, "retry_with") + assert hasattr(my_func, "statistics") + assert my_func() == "ok" + + def test_enabled_false_via_retry_with(self) -> None: + """retry_with(enabled=False) disables retrying.""" + call_count = 0 + + @retry(stop=tenacity.stop_after_attempt(3)) + def always_fails() -> None: + nonlocal call_count + call_count += 1 + raise ValueError("fail") + + disabled = always_fails.retry_with(enabled=False) + with pytest.raises(ValueError, match="fail"): + disabled() + assert call_count == 1 + + def test_enabled_true_retries_normally(self) -> None: + """When enabled=True (default), retrying works as usual.""" + call_count = 0 + + @retry(enabled=True, stop=tenacity.stop_after_attempt(3), reraise=True) + def fails_twice() -> bool: + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ValueError("fail") + return True + + assert fails_twice() is True + assert call_count == 3 + + +class TestRetryWith: + def test_redefine_wait(self) -> None: + 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) -> None: + 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) -> None: + @retry(stop=tenacity.stop_after_attempt(10), retry_error_cls=ValueError) # type: ignore[arg-type] + def _retryable() -> None: + 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) -> None: + def return_text(retry_state: RetryCallState) -> str: + return f"Calling {retry_state.fn.__name__} keeps raising errors after {retry_state.attempt_number} attempts" # type: ignore[union-attr] + + @retry(stop=tenacity.stop_after_attempt(10), retry_error_callback=return_text) + def _retryable() -> None: + 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 + + def test_before_attempts(self) -> None: + TestBeforeAfterAttempts._attempt_number = 0 + + def _before(retry_state: RetryCallState) -> None: + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number + + @retry( + wait=tenacity.wait_fixed(1), + stop=tenacity.stop_after_attempt(1), + before=_before, + ) + def _test_before() -> None: + pass + + _test_before() + + self.assertTrue(TestBeforeAfterAttempts._attempt_number == 1) + + def test_after_attempts(self) -> None: + TestBeforeAfterAttempts._attempt_number = 0 + + def _after(retry_state: RetryCallState) -> None: + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number + + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(3), + after=_after, + ) + def _test_after() -> None: + if TestBeforeAfterAttempts._attempt_number < 2: + raise Exception("testing after_attempts handler") + + _test_after() + + self.assertTrue(TestBeforeAfterAttempts._attempt_number == 2) + + def test_before_sleep(self) -> None: + def _before_sleep(retry_state: RetryCallState) -> None: + self.assertGreater(retry_state.next_action.sleep, 0) # type: ignore[union-attr] + _before_sleep.attempt_number = retry_state.attempt_number # type: ignore[attr-defined] + + @retry( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) + def _test_before_sleep() -> None: + if _before_sleep.attempt_number < 2: # type: ignore[attr-defined] + raise Exception("testing before_sleep_attempts handler") + + _test_before_sleep() + self.assertEqual(_before_sleep.attempt_number, 2) # type: ignore[attr-defined] + + def _before_sleep_log_raises( + self, get_call_fn: typing.Callable[..., typing.Any] + ) -> None: + thing = NoIOErrorAfterCount(2) + logger = logging.getLogger(self.id()) + logger.propagate = False + logger.setLevel(logging.INFO) + handler = CapturingHandler() + 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, + ) + 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\.$" + ) + self.assertEqual(len(handler.records), 2) + fmt = logging.Formatter().format + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) + + def test_before_sleep_log_raises(self) -> None: + self._before_sleep_log_raises(lambda x: x) + + def test_before_sleep_log_raises_with_exc_info(self) -> None: + thing = NoIOErrorAfterCount(2) + logger = logging.getLogger(self.id()) + logger.propagate = False + logger.setLevel(logging.INFO) + 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, + ) + 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, + ) + self.assertEqual(len(handler.records), 2) + fmt = logging.Formatter().format + 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: bool = False) -> None: + thing = NoneReturnUntilAfterCount(2) + logger = logging.getLogger(self.id()) + logger.propagate = False + logger.setLevel(logging.INFO) + handler = CapturingHandler() + logger.addHandler(handler) + try: + _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(thing.go) + finally: + logger.removeHandler(handler) + + etalon_re = r"^Retrying .* in 0\.01 seconds as it returned None\.$" + self.assertEqual(len(handler.records), 2) + fmt = logging.Formatter().format + 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) -> None: + self.test_before_sleep_log_returns(exc_info=True) + + +class TestReraiseExceptions(unittest.TestCase): + def test_reraise_by_default(self) -> None: + calls = [] + + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + reraise=True, + ) + def _reraised_by_default() -> None: + calls.append("x") + raise KeyError("Bad key") + + self.assertRaises(KeyError, _reraised_by_default) + self.assertEqual(2, len(calls)) + + def test_reraise_from_retry_error(self) -> None: + calls = [] + + @retry(wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(2)) + def _raise_key_error() -> None: + calls.append("x") + raise KeyError("Bad key") + + def _reraised_key_error() -> None: + try: + _raise_key_error() + except tenacity.RetryError as retry_err: + retry_err.reraise() + + self.assertRaises(KeyError, _reraised_key_error) + self.assertEqual(2, len(calls)) + + def test_reraise_timeout_from_retry_error(self) -> None: + calls = [] + + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + ) + def _mock_fn() -> None: + calls.append("x") + + def _reraised_mock_fn() -> None: + try: + _mock_fn() + except tenacity.RetryError as retry_err: + retry_err.reraise() + + self.assertRaises(tenacity.RetryError, _reraised_mock_fn) + self.assertEqual(2, len(calls)) + + def test_reraise_no_exception(self) -> None: + calls = [] + + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + reraise=True, + ) + def _mock_fn() -> None: + calls.append("x") + + self.assertRaises(tenacity.RetryError, _mock_fn) + self.assertEqual(2, len(calls)) + + +class TestStatistics(unittest.TestCase): + def test_stats(self) -> None: + @retry() + def _foobar() -> int: + return 42 + + self.assertEqual({}, _foobar.statistics) + _foobar() + self.assertEqual(1, _foobar.statistics["attempt_number"]) + + def test_stats_failing(self) -> None: + @retry(stop=tenacity.stop_after_attempt(2)) + def _foobar() -> None: + raise ValueError(42) + + self.assertEqual({}, _foobar.statistics) + with contextlib.suppress(Exception): + _foobar() + self.assertEqual(2, _foobar.statistics["attempt_number"]) + + def test_retry_object_statistics_synced(self) -> None: + """Test that func.retry.statistics is synced with func.statistics.""" + + @retry(stop=tenacity.stop_after_attempt(3)) + def _foobar() -> int: + return 42 + + _foobar() + self.assertEqual( + _foobar.retry.statistics["attempt_number"], + _foobar.statistics["attempt_number"], + ) + + def test_retry_object_statistics_during_execution(self) -> None: + """Test that func.retry.statistics is accessible during execution.""" + attempts: list[int] = [] + + @retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_exception_type(ValueError), + reraise=True, + ) + def _foobar() -> int: + attempts.append(_foobar.retry.statistics["attempt_number"]) + if len(attempts) < 3: + raise ValueError("retry") + return 42 + + _foobar() + self.assertEqual(attempts, [1, 2, 3]) + + +class TestRetryErrorCallback(unittest.TestCase): + def setUp(self) -> None: + self._attempt_number = 0 + self._callback_called = False + + def _callback(self, fut: tenacity.Future) -> tenacity.Future: + self._callback_called = True + return fut + + def test_retry_error_callback(self) -> None: + num_attempts = 3 + + def retry_error_callback(retry_state: RetryCallState) -> typing.Any: + retry_error_callback.called_times += 1 # type: ignore[attr-defined] + return retry_state.outcome + + retry_error_callback.called_times = 0 # type: ignore[attr-defined] + + @retry( + stop=tenacity.stop_after_attempt(num_attempts), + retry_error_callback=retry_error_callback, + ) + def _foobar() -> None: + self._attempt_number += 1 + raise Exception("This exception should not be raised") + + result = _foobar() + + self.assertEqual(retry_error_callback.called_times, 1) # type: ignore[attr-defined] + self.assertEqual(num_attempts, self._attempt_number) + self.assertIsInstance(result, tenacity.Future) + + +class TestContextManager(unittest.TestCase): + def test_context_manager_retry_one(self) -> None: + from tenacity import Retrying + + raise_ = True + + for attempt in Retrying(): + with attempt: + if raise_: + raise_ = False + raise Exception("Retry it!") + + def test_context_manager_on_error(self) -> None: + from tenacity import Retrying + + class CustomError(Exception): + pass + + retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) + + def test() -> None: + for attempt in retry: + with attempt: + raise CustomError("Don't retry!") + + self.assertRaises(CustomError, test) + + def test_context_manager_retry_error(self) -> None: + from tenacity import Retrying + + retry = Retrying(stop=tenacity.stop_after_attempt(2)) + + def test() -> None: + for attempt in retry: + with attempt: + raise Exception("Retry it!") + + self.assertRaises(RetryError, test) + + def test_context_manager_reraise(self) -> None: + from tenacity import Retrying + + class CustomError(Exception): + pass + + retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2)) + + def test() -> None: + for attempt in retry: + with attempt: + raise CustomError("Don't retry!") + + self.assertRaises(CustomError, test) + + +class TestInvokeAsCallable: + """Test direct invocation of Retrying as a callable.""" + + @staticmethod + def invoke(retry: Retrying, f: typing.Callable[..., typing.Any]) -> typing.Any: + """ + Invoke Retrying logic. + + Wrapper allows testing different call mechanisms in test sub-classes. + """ + return retry(f) + + def test_retry_one(self) -> None: + def f() -> typing.Any: + f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined] + if len(f.calls) <= 1: # type: ignore[attr-defined] + raise Exception("Retry it!") + return 42 + + f.calls = [] # type: ignore[attr-defined] + + retry = Retrying() + assert self.invoke(retry, f) == 42 + assert f.calls == [1, 2] # type: ignore[attr-defined] + + def test_on_error(self) -> None: + class CustomError(Exception): + pass + + def f() -> typing.Any: + f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined] + if len(f.calls) <= 1: # type: ignore[attr-defined] + raise CustomError("Don't retry!") + return 42 + + f.calls = [] # type: ignore[attr-defined] + + retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) + with pytest.raises(CustomError): + self.invoke(retry, f) + assert f.calls == [1] # type: ignore[attr-defined] + + def test_retry_error(self) -> None: + def f() -> typing.Any: + f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined] + raise Exception("Retry it!") + + f.calls = [] # type: ignore[attr-defined] + + retry = Retrying(stop=tenacity.stop_after_attempt(2)) + with pytest.raises(RetryError): + self.invoke(retry, f) + assert f.calls == [1, 2] # type: ignore[attr-defined] + + def test_reraise(self) -> None: + class CustomError(Exception): + pass + + def f() -> typing.Any: + f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined] + raise CustomError("Retry it!") + + f.calls = [] # type: ignore[attr-defined] + + retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2)) + with pytest.raises(CustomError): + self.invoke(retry, f) + assert f.calls == [1, 2] # type: ignore[attr-defined] + + +class TestRetryException(unittest.TestCase): + def test_retry_error_is_pickleable(self) -> None: + import pickle + + expected = RetryError(last_attempt=123) # type: ignore[arg-type] + pickled = pickle.dumps(expected) + actual = pickle.loads(pickled) + self.assertEqual(expected.last_attempt, actual.last_attempt) + + +class TestRetryTyping(unittest.TestCase): + def test_retry_type_annotations(self) -> None: + """The decorator should maintain types of decorated functions.""" + + def num_to_str(number): + # type: (int) -> str + return str(number) + + # equivalent to a raw @retry decoration + with_raw = retry(num_to_str) + with_raw_result = with_raw(1) + + # equivalent to a @retry(...) decoration + with_constructor = retry()(num_to_str) + with_constructor_result = with_raw(1) + + # These raise TypeError exceptions if they fail + 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) + + +class TestMockingSleep: + RETRY_ARGS = { + "wait": tenacity.wait_fixed(0.1), + "stop": tenacity.stop_after_attempt(5), + } + + def _fail(self) -> None: + raise NotImplementedError + + @retry(**RETRY_ARGS) # type: ignore[call-overload, untyped-decorator] + def _decorated_fail(self) -> None: + self._fail() + + @pytest.fixture() + def mock_sleep( + self, monkeypatch: typing.Any + ) -> typing.Generator[typing.Any, None, None]: + class MockSleep: + call_count = 0 + + def __call__(self, seconds: float) -> None: + self.call_count += 1 + + sleep = MockSleep() + monkeypatch.setattr(tenacity.nap.time, "sleep", sleep) # type: ignore[attr-defined] + yield sleep + + def test_decorated(self, mock_sleep: typing.Any) -> None: + with pytest.raises(RetryError): + self._decorated_fail() + assert mock_sleep.call_count == 4 + + def test_decorated_retry_with(self, mock_sleep: typing.Any) -> None: + fail_faster = self._decorated_fail.retry_with( + stop=tenacity.stop_after_attempt(2), + ) + with pytest.raises(RetryError): + fail_faster() + assert mock_sleep.call_count == 1 + + +class TestPickle(unittest.TestCase): + def test_retrying_picklable(self) -> None: + """Retrying objects can be pickled for multiprocessing support.""" + retrying = Retrying(stop=tenacity.stop_after_attempt(3)) + pickled = pickle.dumps(retrying) + restored = pickle.loads(pickled) + assert isinstance(restored, Retrying) + assert isinstance(restored.stop, tenacity.stop_after_attempt) + + def test_retrying_picklable_after_run(self) -> None: + """Retrying objects can be pickled even after being used.""" + retrying = Retrying(stop=tenacity.stop_after_attempt(3)) + # Access statistics to populate _local + _ = retrying.statistics + pickled = pickle.dumps(retrying) + restored = pickle.loads(pickled) + assert isinstance(restored, Retrying) + # Statistics should be reset on the restored object + assert restored.statistics == {} + + def test_retry_strategies_picklable(self) -> None: + """All built-in retry strategies can be pickled.""" + strategies = [ + tenacity.retry_if_exception_type(ValueError), + tenacity.retry_if_not_exception_type(ValueError), + tenacity.retry_if_exception_message(message="fail"), + tenacity.retry_if_exception_message(match="fail.*"), + tenacity.retry_if_not_exception_message(message="fail"), + ] + for strategy in strategies: + restored = pickle.loads(pickle.dumps(strategy)) + assert type(restored) is type(strategy) + + def test_retrying_pickle_round_trip_works(self) -> None: + """A pickled-then-restored Retrying object retries correctly.""" + retrying = Retrying( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_exception_type(ValueError), + reraise=True, + ) + restored = pickle.loads(pickle.dumps(retrying)) + + calls = 0 + + def succeed_on_third() -> str: + nonlocal calls + calls += 1 + if calls < 3: + raise ValueError("not yet") + return "ok" + + result = restored(succeed_on_third) + assert result == "ok" + assert calls == 3 + + +if __name__ == "__main__": + unittest.main() diff --git a/tenacity/tests/test_tornado.py b/tests/test_tornado.py similarity index 71% rename from tenacity/tests/test_tornado.py rename to tests/test_tornado.py index 23380170..9233cca3 100644 --- a/tenacity/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -1,4 +1,3 @@ -# coding: utf-8 # Copyright 2017 Elisey Zanko # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,39 +13,42 @@ # limitations under the License. import unittest +from collections.abc import Generator +from typing import Any -from tenacity import RetryError, retry, stop_after_attempt -from tenacity import tornadoweb -from tenacity.tests.test_tenacity import NoIOErrorAfterCount +from tornado import gen, testing -from tornado import gen -from tornado import testing +from tenacity import RetryError, retry, stop_after_attempt, tornadoweb + +from .test_tenacity import NoIOErrorAfterCount @retry @gen.coroutine -def _retryable_coroutine(thing): +def _retryable_coroutine(thing: NoIOErrorAfterCount) -> Generator[Any, Any, None]: yield gen.sleep(0.00001) thing.go() @retry(stop=stop_after_attempt(2)) @gen.coroutine -def _retryable_coroutine_with_2_attempts(thing): +def _retryable_coroutine_with_2_attempts( + thing: NoIOErrorAfterCount, +) -> Generator[Any, Any, None]: yield gen.sleep(0.00001) thing.go() class TestTornado(testing.AsyncTestCase): @testing.gen_test - def test_retry(self): + def test_retry(self) -> Generator[Any, Any, None]: assert gen.is_coroutine_function(_retryable_coroutine) thing = NoIOErrorAfterCount(5) yield _retryable_coroutine(thing) assert thing.counter == thing.count @testing.gen_test - def test_stop_after_attempt(self): + def test_stop_after_attempt(self) -> Generator[Any, Any, None]: assert gen.is_coroutine_function(_retryable_coroutine) thing = NoIOErrorAfterCount(2) try: @@ -54,10 +56,10 @@ def test_stop_after_attempt(self): except RetryError: assert thing.counter == 2 - def test_repr(self): + def test_repr(self) -> None: repr(tornadoweb.TornadoRetrying()) - def test_old_tornado(self): + def test_old_tornado(self) -> None: old_attr = gen.is_coroutine_function try: del gen.is_coroutine_function @@ -65,11 +67,12 @@ def test_old_tornado(self): # is_coroutine_function was introduced in tornado 4.5; # verify that we don't *completely* fall over on old versions @retry - def retryable(thing): + def retryable(thing: NoIOErrorAfterCount) -> None: pass + finally: gen.is_coroutine_function = old_attr -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..50a0e3db --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,57 @@ +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 + + +def test_find_ordinal() -> None: + assert _utils.find_ordinal(1) == "st" + assert _utils.find_ordinal(2) == "nd" + assert _utils.find_ordinal(3) == "rd" + assert _utils.find_ordinal(4) == "th" + assert _utils.find_ordinal(11) == "th" + assert _utils.find_ordinal(12) == "th" + assert _utils.find_ordinal(13) == "th" + assert _utils.find_ordinal(21) == "st" + assert _utils.find_ordinal(22) == "nd" + assert _utils.find_ordinal(23) == "rd" + assert _utils.find_ordinal(111) == "th" + assert _utils.find_ordinal(112) == "th" + assert _utils.find_ordinal(113) == "th" diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 88076793..00000000 --- a/tox.ini +++ /dev/null @@ -1,37 +0,0 @@ -[tox] -envlist = py27, py35, py36, py37, py38, pep8, pypy - -[testenv] -usedevelop = True -sitepackages = False -deps = - .[doc] - pytest - 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 - -[testenv:pep8] -basepython = python3 -deps = flake8 - flake8-import-order - flake8-blind-except - flake8-builtins - flake8-docstrings - flake8-rst-docstrings - flake8-logging-format -commands = flake8 - -[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 -enable-extensions=G