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..0c35a1fa --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,52 @@ +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.9" + tox: py39 + - python: "3.10" + tox: py310 + - python: "3.11" + tox: py311 + - python: "3.12" + tox: py312 + - python: "3.13" + tox: py313 + - python: "3.14" + tox: py314,py314-trio + - python: "3.14" + tox: pep8 + - python: "3.14" + tox: mypy + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v6.0.1 + with: + fetch-depth: 0 + + - name: Setup Python 🔧 + uses: actions/setup-python@v6.1.0 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + + - name: Build 🔧 & Test 🔍 + run: | + pip install tox + tox -e ${{ matrix.tox }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..206c183f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: upload release to PyPI +on: + release: + types: + - published + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v6.0.1 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6.1.0 + with: + python-version: 3.14 + + - name: Install build + run: | + pip install setuptools-scm wheel + + - name: Build + run: | + python setup.py sdist bdist_wheel + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.mergify.yml b/.mergify.yml index c3c6be04..48d990dd 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.9, py39)" + - "check-success=test (3.10, py310)" + - "check-success=test (3.11, py311)" + - "check-success=test (3.12, py312)" + - "check-success=test (3.13, py313)" + - "check-success=test (3.14, py314,py314-trio)" + - "check-success=test (3.14, pep8)" + 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/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..cb1ff711 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016 Étienne Bersac # Copyright 2016 Julien Danjou # Copyright 2016 Joshua Harlow @@ -19,16 +18,16 @@ import os import sys -master_doc = 'index' +master_doc = "index" project = "Tenacity" # Add tenacity to the path, so sphinx can find the functions for autodoc. -sys.path.insert(0, os.path.abspath('../..')) +sys.path.insert(0, os.path.abspath("../..")) extensions = [ - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', - 'reno.sphinxext', + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "reno.sphinxext", ] # -- Options for sphinx.ext.doctest ----------------------------------------- diff --git a/doc/source/index.rst b/doc/source/index.rst index af3c5bba..6a5b66bb 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -6,13 +6,12 @@ Tenacity .. image:: https://circleci.com/gh/jd/tenacity.svg?style=svg :target: https://circleci.com/gh/jd/tenacity -.. image:: https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg - :target: https://saythanks.io/to/jd - -.. image:: https://img.shields.io/endpoint.svg?url=https://dashboard.mergify.io/badges/jd/tenacity&style=flat +.. 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 +79,7 @@ Examples Basic Retry ~~~~~~~~~~~ -.. testsetup:: * +.. testsetup:: import logging # @@ -98,7 +97,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 +124,16 @@ retrying stuff. print("Stopping after 10 seconds") raise Exception +If you're on a tight deadline, and exceeding your delay time isn't ok, +then you can give up on retries one attempt before you would exceed the delay. + +.. testcode:: + + @retry(stop=stop_before_delay(10)) + def stop_before_10_s(): + print("Stopping 1 attempt before 10 seconds") + raise Exception + You can combine several stop conditions by using the `|` operator: .. testcode:: @@ -208,11 +217,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 +242,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 +286,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 +314,7 @@ by using the before callback function: .. testcode:: import logging + import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) @@ -292,6 +329,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 +346,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 +362,7 @@ Statistics ~~~~~~~~~~ You can access the statistics about the retry made over a function by using the -`retry` attribute attached to the function and its `statistics` attribute: +`statistics` attribute attached to the function: .. testcode:: @@ -336,7 +375,7 @@ You can access the statistics about the retry made over a function by using the except Exception: pass - print(raise_my_exception.retry.statistics) + print(raise_my_exception.statistics) .. testoutput:: :hide: @@ -370,52 +409,10 @@ 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: - - .. autoinstanceattribute:: retry_object(BaseRetrying) - :annotation: - - .. autoinstanceattribute:: fn(callable) - :annotation: - - .. autoinstanceattribute:: args(tuple) - :annotation: - - .. autoinstanceattribute:: kwargs(dict) - :annotation: - - Variable attributes: - - .. autoinstanceattribute:: attempt_number(int) - :annotation: - - .. autoinstanceattribute:: outcome(tenacity.Future or None) - :annotation: - - .. autoinstanceattribute:: outcome_timestamp(float or None) - :annotation: - - .. autoinstanceattribute:: idle_for(float) - :annotation: - - .. autoinstanceattribute:: next_action(tenacity.RetryAction or None) - :annotation: +``retry_state`` argument is an object of :class:`~tenacity.RetryCallState` class. Other Custom Callbacks ~~~~~~~~~~~~~~~~~~~~~~ @@ -424,33 +421,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 +477,6 @@ Here's an example with a custom ``before_sleep`` function: except RetryError: pass -.. _deprecated-callbacks: - -.. note:: - - It was also possible to define custom callbacks before, but they accepted - varying parameter sets and none of those provided full state. The old way is - deprecated, but kept for backward compatibility. - - .. function:: my_deprecated_stop(previous_attempt_number, delay_since_first_attempt) - - *deprecated* - - :param previous_attempt_number: the number of current attempt - :type previous_attempt_number: int - :param delay_since_first_attempt: interval in seconds between the - beginning of first attempt and current time - :type delay_since_first_attempt: float - :rtype: bool - - .. function:: my_deprecated_wait(previous_attempt_number, delay_since_first_attempt [, last_result]) - - *deprecated* - - :param previous_attempt_number: the number of current attempt - :type previous_attempt_number: int - :param delay_since_first_attempt: interval in seconds between the - beginning of first attempt and current time - :type delay_since_first_attempt: float - :param tenacity.Future last_result: current outcome - - :return: number of seconds to wait before next retry - :rtype: float - - .. function:: my_deprecated_retry(attempt) - - *deprecated* - - :param tenacity.Future attempt: current outcome - :return: whether or not retrying should continue - :rtype: bool - - .. function:: my_deprecated_before(func, trial_number) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param int trial_number: the number of current attempt - - .. function:: my_deprecated_after(func, trial_number, trial_time_taken) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param int trial_number: the number of current attempt - :param float trial_time_taken: interval in seconds between the beginning - of first attempt and current time - - .. function:: my_deprecated_before_sleep(func, sleep, last_result) - - *deprecated* - - :param callable func: function whose outcome is to be retried - :param float sleep: number of seconds to wait until next retry - :param tenacity.Future last_result: current outcome - -.. testcode:: - - import logging - - logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - - logger = logging.getLogger(__name__) - - def my_before_sleep(retry_object, sleep, last_result): - logger.warning( - 'Retrying %s: last_result=%s, retrying in %s seconds...', - retry_object.fn, last_result, sleep) - - @retry(stop=stop_after_attempt(3), before_sleep=my_before_sleep) - def raise_my_exception(): - raise MyException("Fail") - - try: - raise_my_exception() - except RetryError: - pass Changing Arguments at Run Time ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -584,7 +495,7 @@ using the `retry_with` function attached to the wrapped function: except Exception: pass - print(raise_my_exception.retry.statistics) + print(raise_my_exception.statistics) .. testoutput:: :hide: @@ -603,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly: retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True) retryer(never_good_enough, 'I really do try') +You may also want to change the behaviour of a decorated function temporarily, +like in tests to avoid unnecessary wait times. You can modify/patch the `retry` +attribute attached to the function. Bear in mind this is a write-only attribute, +statistics should be read from the function `statistics` attribute. + +.. testcode:: + + @retry(stop=stop_after_attempt(3), wait=wait_fixed(3)) + def raise_my_exception(): + raise MyException("Fail") + + from unittest import mock + + with mock.patch.object(raise_my_exception.retry, "wait", wait_fixed(0)): + try: + raise_my_exception() + except Exception: + pass + + print(raise_my_exception.statistics) + +.. testoutput:: + :hide: + + ... + Retrying code block ~~~~~~~~~~~~~~~~~~~ @@ -638,31 +575,53 @@ 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 (>= 4.5) coroutines. Sleeps are done asynchronously too. .. code-block:: python @retry - async def my_async_function(loop): + async def my_asyncio_function(loop): await loop.getaddrinfo('8.8.8.8', 53) +.. code-block:: python + + @retry + async def my_async_trio_function(): + await trio.socket.getaddrinfo('8.8.8.8', 53) + .. code-block:: python @retry @tornado.gen.coroutine - def my_async_function(http_client, url): + def my_async_tornado_function(http_client, url): yield http_client.fetch(url) -You can even use alternative event loops such as `curio` or `Trio` by passing the correct sleep function: +You can even use alternative event loops such as `curio` by passing the correct sleep function: .. code-block:: python - @retry(sleep=trio.sleep) - async def my_async_function(loop): + @retry(sleep=curio.sleep) + async def my_async_curio_function(): await asks.get('https://example.org') Contribute @@ -671,24 +630,10 @@ 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. - -The doc generation will automatically compile the changelogs. You just need to add them. - -.. code-block:: sh - - # Opens a template file in an editor - tox -e reno -- new some-slug-for-my-change --edit - -.. _`reno`: https://docs.openstack.org/reno/latest/user/usage.html diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2ff52b9b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +# Minimum requirements for the build system to execute. +# PEP 508 specifications for PEP 518. +# Banned setuptools versions have well-known issues +requires = [ + "setuptools >= 21.0.0,!=24.0.0,!=34.0.0,!=34.0.1,!=34.0.2,!=34.0.3,!=34.1.0,!=34.1.1,!=34.2.0,!=34.3.0,!=34.3.1,!=34.3.2,!=36.2.0", # PSF/ZPL + "setuptools_scm[toml]>=3.4", +] +build-backend="setuptools.build_meta" + +[tool.ruff] +line-length = 88 +indent-width = 4 +target-version = "py39" + +[tool.mypy] +strict = true +files = ["tenacity", "tests"] +show_error_codes = true + +[[tool.mypy.overrides]] +module = "tornado.*" +ignore_missing_imports = true + +[tool.setuptools_scm] 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/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_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-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 index 5cc3cb18..b4aafb33 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,28 +5,30 @@ url = https://github.com/jd/tenacity summary = Retry code until it succeeds long_description = Tenacity is a general-purpose retrying library to simplify the task of adding retry behavior to just about anything. author = Julien Danjou -author-email = julien@danjou.info -home-page = https://github.com/jd/tenacity +author_email = julien@danjou.info +home_page = https://github.com/jd/tenacity classifier = Intended Audience :: Developers License :: OSI Approved :: Apache Software License 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 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.9 + 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 [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 +python_requires = >=3.9 +packages = find: + +[options.packages.find] +include = tenacity* +exclude = tests [options.package_data] tenacity = py.typed @@ -35,10 +37,10 @@ tenacity = py.typed doc = reno sphinx +test = + pytest tornado>=4.5 - -[wheel] -universal = 1 + typeguard [tool:pytest] filterwarnings = diff --git a/setup.py b/setup.py index 66163685..ec7ac861 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,6 @@ import setuptools setuptools.setup( - setup_requires=['setuptools_scm'], + setup_requires=["setuptools_scm"], use_scm_version=True, ) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 336f4f65..b0b47946 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,36 +15,27 @@ # 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 - -import six - -from tenacity import _utils -from tenacity import compat as _compat +from . import _utils # Import all built-in retry strategies for easier usage. +from .retry import retry_base # noqa from .retry import retry_all # noqa from .retry import retry_always # noqa from .retry import retry_any # noqa from .retry import retry_if_exception # noqa from .retry import retry_if_exception_type # noqa +from .retry import retry_if_exception_cause_type # noqa +from .retry import retry_if_not_exception_type # noqa from .retry import retry_if_not_result # noqa from .retry import retry_if_result # noqa from .retry import retry_never # noqa @@ -60,6 +50,7 @@ # Import all built-in stop strategies for easier usage. from .stop import stop_after_attempt # noqa from .stop import stop_after_delay # noqa +from .stop import stop_before_delay # noqa from .stop import stop_all # noqa from .stop import stop_any # noqa from .stop import stop_never # noqa @@ -68,6 +59,7 @@ # Import all built-in wait strategies for easier usage. from .wait import wait_chain # noqa from .wait import wait_combine # noqa +from .wait import wait_exception # noqa from .wait import wait_exponential # noqa from .wait import wait_fixed # noqa from .wait import wait_incrementing # noqa @@ -75,6 +67,7 @@ from .wait import wait_random # noqa from .wait import wait_random_exponential # noqa from .wait import wait_random_exponential as wait_full_jitter # noqa +from .wait import wait_exponential_jitter # noqa # Import all built-in before strategies for easier usage. from .before import before_log # noqa @@ -84,50 +77,51 @@ from .after import after_log # noqa from .after import after_nothing # noqa -# Import all built-in after strategies for easier usage. +# Import all built-in before sleep strategies for easier usage. from .before_sleep import before_sleep_log # noqa from .before_sleep import before_sleep_nothing # noqa +try: + import tornado +except ImportError: + tornado = None -WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable) +if t.TYPE_CHECKING: + import types + from typing_extensions import Self -@t.overload -def retry(fn): - # type: (WrappedFn) -> WrappedFn - """Type signature for @retry as a raw decorator.""" - pass + from . import asyncio as tasyncio + from .retry import RetryBaseT + from .stop import StopBaseT + from .wait import WaitBaseT -@t.overload -def retry(*dargs, **dkw): # noqa - # type: (...) -> t.Callable[[WrappedFn], WrappedFn] - """Type signature for the @retry() decorator constructor.""" - pass +WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") +WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) -def retry(*dargs, **dkw): # noqa - """Wrap a function with a new `Retrying` object. +dataclass_kwargs = {} +if sys.version_info >= (3, 10): + dataclass_kwargs.update({"slots": True}) - :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) +@dataclasses.dataclass(**dataclass_kwargs) +class IterState: + actions: t.List[t.Callable[["RetryCallState"], t.Any]] = dataclasses.field( + default_factory=list + ) + retry_run_result: bool = False + delay_since_first_attempt: int = 0 + stop_run_result: bool = False + is_explicit_retry: bool = False - return wrap + def reset(self) -> None: + self.actions = [] + self.retry_run_result = False + self.delay_since_first_attempt = 0 + self.stop_run_result = False + self.is_explicit_retry = False class TryAgain(Exception): @@ -137,7 +131,7 @@ class TryAgain(Exception): NO_RESULT = object() -class DoAttempt(object): +class DoAttempt: pass @@ -145,158 +139,155 @@ 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: t.Optional[str] = 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.Union[t.Any, object], second: t.Any) -> t.Any: + return second if first is _unset else first + + class RetryError(Exception): """Encapsulates the last attempt instance right before giving up.""" - def __init__(self, last_attempt): + def __init__(self, last_attempt: "Future") -> None: self.last_attempt = last_attempt - super(RetryError, self).__init__(last_attempt) + super().__init__(last_attempt) - def reraise(self): + def reraise(self) -> t.NoReturn: if self.last_attempt.failed: raise self.last_attempt.result() raise self - def __str__(self): - 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: t.Optional[t.Type[BaseException]], + exc_value: t.Optional[BaseException], + traceback: t.Optional["types.TracebackType"], + ) -> t.Optional[bool]: + 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) + return 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): +class BaseRetrying(ABC): + def __init__( + self, + sleep: t.Callable[[t.Union[int, float]], None] = sleep, + stop: "StopBaseT" = stop_never, + wait: "WaitBaseT" = wait_none(), + retry: "RetryBaseT" = retry_if_exception_type(), + before: t.Callable[["RetryCallState"], None] = before_nothing, + after: t.Callable[["RetryCallState"], None] = after_nothing, + before_sleep: t.Optional[t.Callable[["RetryCallState"], None]] = None, + reraise: bool = False, + retry_error_cls: t.Type[RetryError] = RetryError, + retry_error_callback: t.Optional[t.Callable[["RetryCallState"], t.Any]] = None, + ): 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 + + def copy( + self, + sleep: t.Union[t.Callable[[t.Union[int, float]], None], object] = _unset, + stop: t.Union["StopBaseT", object] = _unset, + wait: t.Union["WaitBaseT", object] = _unset, + retry: t.Union[retry_base, object] = _unset, + before: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, + after: t.Union[t.Callable[["RetryCallState"], None], object] = _unset, + before_sleep: t.Union[ + t.Optional[t.Callable[["RetryCallState"], None]], object + ] = _unset, + reraise: t.Union[bool, object] = _unset, + retry_error_cls: t.Union[t.Type[RetryError], object] = _unset, + retry_error_callback: t.Union[ + t.Optional[t.Callable[["RetryCallState"], t.Any]], object + ] = _unset, + ) -> "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 + ), ) - def __repr__(self): - attrs = dict( - _utils.visible_attrs(self, attrs={'me': id(self)}), - __class__=self.__class__.__name__, + 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})>" ) - 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) -> t.Dict[str, t.Any]: """Return a dictionary of runtime statistics. This dictionary will be empty when the controller has never been @@ -317,77 +308,134 @@ def statistics(self): 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(t.Dict[str, t.Any], {}) + return self._local.statistics # type: ignore[no-any-return] + + @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): + def wraps(self, f: WrappedFn) -> WrappedFn: """Wrap a function for retrying. :param f: A function to wraps for retrying. """ - @_utils.wraps(f) - def wrapped_f(*args, **kw): - 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: + # Always create a copy to prevent overwriting the local contexts when + # calling the same wrapped functions multiple times in the same stack + copy = self.copy() + wrapped_f.statistics = copy.statistics # type: ignore[attr-defined] + return copy(f, *args, **kw) + + def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return self.copy(*args, **kwargs).wraps(f) - wrapped_f.retry = self - 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 wrapped_f # type: ignore[return-value] - 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 + + 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") -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa + self._begin_iter(retry_state) + result = None + for action in self.iter_state.actions: + result = action(retry_state) + return result + + def _begin_iter(self, retry_state: "RetryCallState") -> None: # noqa + self.iter_state.reset() - 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 - 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() + self.iter_state.is_explicit_retry = fut.failed and isinstance( + fut.exception(), TryAgain + ) + if not self.iter_state.is_explicit_retry: + self._add_action_func(self._run_retry) + self._add_action_func(self._post_retry_check_actions) + + def _post_retry_check_actions(self, retry_state: "RetryCallState") -> None: + if not (self.iter_state.is_explicit_retry or self.iter_state.retry_run_result): + self._add_action_func(lambda rs: rs.outcome.result()) + return if self.after is not None: - self.after(retry_state=retry_state) + self._add_action_func(self.after) + + self._add_action_func(self._run_wait) + self._add_action_func(self._run_stop) + self._add_action_func(self._post_stop_check_actions) - self.statistics['delay_since_first_attempt'] = \ - retry_state.seconds_since_start - if self.stop(retry_state=retry_state): + 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: + raise retry_exc.reraise() + raise retry_exc from fut.exception() + + self._add_action_func(exc_check) + return + + def next_action(rs: "RetryCallState") -> None: + sleep = rs.upcoming_sleep + rs.next_action = RetryAction(sleep) + rs.idle_for += sleep + self.statistics["idle_for"] += sleep + self.statistics["attempt_number"] += 1 + + 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 +449,65 @@ 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()) + except BaseException: # noqa: B902 + retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type] else: retry_state.set_result(result) elif isinstance(do, DoSleep): retry_state.prepare_for_next_attempt() self.sleep(do) else: - return do + return do # type: ignore[no-any-return] + - 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) +if sys.version_info >= (3, 9): + FutureGenericT = futures.Future[t.Any] +else: + FutureGenericT = futures.Future -class Future(futures.Future): +class Future(FutureGenericT): """Encapsulates a (future or past) attempted call to a target function.""" - def __init__(self, attempt_number): - 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 +517,18 @@ def construct(cls, attempt_number, value, has_exception): return fut -class RetryCallState(object): +class RetryCallState: """State related to a single call wrapped with Retrying.""" - def __init__(self, retry_object, fn, args, kwargs): + def __init__( + self, + retry_object: BaseRetrying, + fn: t.Optional[WrappedFn], + args: t.Any, + kwargs: t.Any, + ) -> None: #: Retry call start timestamp - self.start_time = _utils.now() + self.start_time = time.monotonic() #: Retry manager object self.retry_object = retry_object #: Function wrapped by this retry call @@ -474,43 +539,182 @@ def __init__(self, retry_object, fn, args, kwargs): self.kwargs = kwargs #: The number of the current attempt - self.attempt_number = 1 + self.attempt_number: int = 1 #: Last outcome (result or exception) produced by the function - self.outcome = None + self.outcome: t.Optional[Future] = None #: Timestamp of the last outcome - self.outcome_timestamp = None + self.outcome_timestamp: t.Optional[float] = None #: Time spent sleeping in retries - self.idle_for = 0 + self.idle_for: float = 0.0 #: Next action as decided by the retry manager - self.next_action = None + self.next_action: t.Optional[RetryAction] = None + #: Next sleep time as decided by the retry manager. + self.upcoming_sleep: float = 0.0 @property - def seconds_since_start(self): + def seconds_since_start(self) -> t.Optional[float]: if self.outcome_timestamp is None: return None return self.outcome_timestamp - self.start_time - def prepare_for_next_attempt(self): + def prepare_for_next_attempt(self) -> None: self.outcome = None self.outcome_timestamp = None self.attempt_number += 1 self.next_action = None - def set_result(self, val): - ts = _utils.now() + def set_result(self, val: t.Any) -> None: + ts = time.monotonic() fut = Future(self.attempt_number) fut.set_result(val) self.outcome, self.outcome_timestamp = fut, ts - def set_exception(self, exc_info): - ts = _utils.now() + def set_exception( + self, + exc_info: t.Tuple[ + t.Type[BaseException], BaseException, "types.TracebackType| None" + ], + ) -> 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 +@t.overload +def retry(func: WrappedFn) -> WrappedFn: ... + + +@t.overload +def retry( + sleep: t.Callable[[t.Union[int, float]], t.Union[None, t.Awaitable[None]]] = sleep, + stop: "StopBaseT" = stop_never, + wait: "WaitBaseT" = wait_none(), + retry: "t.Union[RetryBaseT, tasyncio.retry.RetryBaseT]" = retry_if_exception_type(), + before: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = before_nothing, + after: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = after_nothing, + before_sleep: t.Optional[ + t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] + ] = None, + reraise: bool = False, + retry_error_cls: t.Type["RetryError"] = RetryError, + retry_error_callback: t.Optional[ + t.Callable[["RetryCallState"], t.Union[t.Any, t.Awaitable[t.Any]]] + ] = None, +) -> t.Callable[[WrappedFn], WrappedFn]: ... + + +def retry(*dargs: t.Any, **dkw: t.Any) -> t.Any: + """Wrap a function with a new `Retrying` object. + + :param dargs: positional arguments passed to Retrying object + :param dkw: keyword arguments passed to the Retrying object + """ + # support both @retry and @retry() as valid syntax + if len(dargs) == 1 and callable(dargs[0]): + return retry()(dargs[0]) + else: + + def wrap(f: WrappedFn) -> WrappedFn: + if isinstance(f, retry_base): + warnings.warn( + f"Got retry_base instance ({f.__class__.__name__}) as callable argument, " + f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" + ) + r: "BaseRetrying" + if _utils.is_coroutine_callable(f): + r = AsyncRetrying(*dargs, **dkw) + elif ( + tornado + and hasattr(tornado.gen, "is_coroutine_function") + and tornado.gen.is_coroutine_function(f) + ): + r = TornadoRetrying(*dargs, **dkw) + else: + r = Retrying(*dargs, **dkw) + + return r.wraps(f) + + return wrap + + +from tenacity.asyncio import AsyncRetrying # noqa:E402,I100 if tornado: from tenacity.tornadoweb import TornadoRetrying + + +__all__ = [ + "retry_base", + "retry_all", + "retry_always", + "retry_any", + "retry_if_exception", + "retry_if_exception_type", + "retry_if_exception_cause_type", + "retry_if_not_exception_type", + "retry_if_not_result", + "retry_if_result", + "retry_never", + "retry_unless_exception_type", + "retry_if_exception_message", + "retry_if_not_exception_message", + "sleep", + "sleep_using_event", + "stop_after_attempt", + "stop_after_delay", + "stop_before_delay", + "stop_all", + "stop_any", + "stop_never", + "stop_when_event_set", + "wait_chain", + "wait_combine", + "wait_exception", + "wait_exponential", + "wait_fixed", + "wait_incrementing", + "wait_none", + "wait_random", + "wait_random_exponential", + "wait_full_jitter", + "wait_exponential_jitter", + "before_log", + "before_nothing", + "after_log", + "after_nothing", + "before_sleep_log", + "before_sleep_nothing", + "retry", + "WrappedFn", + "TryAgain", + "NO_RESULT", + "DoAttempt", + "DoSleep", + "BaseAction", + "RetryAction", + "RetryError", + "AttemptManager", + "BaseRetrying", + "Retrying", + "Future", + "RetryCallState", + "AsyncRetrying", +] 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..be148719 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -13,89 +13,51 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import functools import inspect import sys -import 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 - - -if six.PY2: - from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES - - def wraps(fn): - """Do the same as six.wraps but only copy attributes that exist. - - For example, object instances don't have __name__ attribute, so - six.wraps fails. This is fixed in Python 3 - (https://bugs.python.org/issue3445), but didn't get backported to six. +import typing +from datetime import timedelta - 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]) +# sys.maxsize: +# An integer giving the maximum value a variable of type Py_ssize_t can take. +MAX_WAIT = sys.maxsize / 2 - def getargspec(func): - # This was deprecated in Python 3. - return inspect.getargspec(func) -else: - from functools import wraps # noqa - def capture(fut, tb): - fut.set_exception(tb[1]) - - def getargspec(func): - return inspect.getfullargspec(func) +class LoggerProtocol(typing.Protocol): + """ + Protocol used by utils expecting a logger (eg: before_log). + Compatible with logging, structlog, loguru, etc... + """ -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 log( + self, level: int, msg: str, /, *args: typing.Any, **kwargs: typing.Any + ) -> typing.Any: ... -def find_ordinal(pos_num): +def find_ordinal(pos_num: int) -> str: # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers if pos_num == 0: return "th" elif pos_num == 1: - return 'st' + return "st" elif pos_num == 2: - return 'nd' + return "nd" elif pos_num == 3: - return 'rd' - elif pos_num >= 4 and pos_num <= 20: - return 'th' + return "rd" + elif 4 <= pos_num <= 20: + return "th" else: 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. @@ -106,14 +68,6 @@ def get_callback_name(cb): except AttributeError: try: segments.append(cb.__name__) - if inspect.ismethod(cb): - try: - # This attribute doesn't exist on py3.x or newer, so - # we optionally ignore it... (on those versions of - # python `__qualname__` should have been found anyway). - segments.insert(0, cb.im_class.__name__) - except AttributeError: - pass except AttributeError: pass if not segments: @@ -128,27 +82,32 @@ def get_callback_name(cb): return ".".join(segments) -try: - now = time.monotonic # noqa -except AttributeError: - from monotonic import monotonic as now # noqa +time_unit_type = typing.Union[int, float, timedelta] + + +def to_seconds(time_unit: time_unit_type) -> float: + return float( + time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit + ) -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) + 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..a4b89224 100644 --- a/tenacity/after.py +++ b/tenacity/after.py @@ -14,22 +14,36 @@ # 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: + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) + logger.log( + log_level, + f"Finished call to '{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.", + ) return log_it diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py new file mode 100644 index 00000000..a9260914 --- /dev/null +++ b/tenacity/asyncio/__init__.py @@ -0,0 +1,206 @@ +# 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 +from tenacity import BaseRetrying +from tenacity import DoAttempt +from tenacity import DoSleep +from tenacity import RetryCallState +from tenacity import RetryError +from tenacity import after_nothing +from tenacity import before_nothing +from tenacity import _utils + +# Import all built-in retry strategies for easier usage. +from .retry import RetryBaseT +from .retry import retry_all # noqa +from .retry import retry_any # noqa +from .retry import retry_if_exception # noqa +from .retry import retry_if_result # noqa +from ..retry import RetryBaseT as SyncRetryBaseT + +if t.TYPE_CHECKING: + from tenacity.stop import StopBaseT + from tenacity.wait import WaitBaseT + +WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") +WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]]) + + +def _portable_async_sleep(seconds: float) -> t.Awaitable[None]: + # If trio is already imported, then importing it is cheap. + # If trio isn't already imported, then it's definitely not running, so we + # can skip further checks. + if "trio" in sys.modules: + # If trio is available, then sniffio is too + import trio + import sniffio + + if sniffio.current_async_library() == "trio": + return trio.sleep(seconds) + # Otherwise, assume asyncio + # Lazy import asyncio as it's expensive (responsible for 25-50% of total import overhead). + import asyncio + + return asyncio.sleep(seconds) + + +class AsyncRetrying(BaseRetrying): + def __init__( + self, + sleep: t.Callable[ + [t.Union[int, float]], t.Union[None, t.Awaitable[None]] + ] = _portable_async_sleep, + stop: "StopBaseT" = tenacity.stop.stop_never, + wait: "WaitBaseT" = tenacity.wait.wait_none(), + retry: "t.Union[SyncRetryBaseT, RetryBaseT]" = tenacity.retry_if_exception_type(), + before: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = before_nothing, + after: t.Callable[ + ["RetryCallState"], t.Union[None, t.Awaitable[None]] + ] = after_nothing, + before_sleep: t.Optional[ + t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] + ] = None, + reraise: bool = False, + retry_error_cls: t.Type["RetryError"] = RetryError, + retry_error_callback: t.Optional[ + t.Callable[["RetryCallState"], t.Union[t.Any, t.Awaitable[t.Any]]] + ] = None, + ) -> None: + super().__init__( + sleep=sleep, # type: ignore[arg-type] + stop=stop, + wait=wait, + retry=retry, # type: ignore[arg-type] + before=before, # type: ignore[arg-type] + after=after, # type: ignore[arg-type] + before_sleep=before_sleep, # type: ignore[arg-type] + reraise=reraise, + retry_error_cls=retry_error_cls, + retry_error_callback=retry_error_callback, + ) + + async def __call__( # type: ignore[override] + self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any + ) -> WrappedFnReturnT: + self.begin() + + retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) + while True: + do = await self.iter(retry_state=retry_state) + if isinstance(do, DoAttempt): + try: + result = await fn(*args, **kwargs) + except BaseException: # noqa: B902 + 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" + ) -> t.Union[DoAttempt, DoSleep, t.Any]: # noqa: A003 + self._begin_iter(retry_state) + result = None + for action in self.iter_state.actions: + result = await action(retry_state) + return result + + def __iter__(self) -> t.Generator[AttemptManager, None, None]: + raise TypeError("AsyncRetrying object is not iterable") + + 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 + 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) # type: ignore[misc] + else: + raise StopAsyncIteration + + def wraps(self, fn: WrappedFn) -> WrappedFn: + 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: + # Always create a copy to prevent overwriting the local contexts when + # calling the same wrapped functions multiple times in the same stack + copy = self.copy() + async_wrapped.statistics = copy.statistics # type: ignore[attr-defined] + return await copy(fn, *args, **kwargs) + + # Preserve attributes + async_wrapped.retry = self # type: ignore[attr-defined] + async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined] + async_wrapped.statistics = {} # type: ignore[attr-defined] + + return async_wrapped # type: ignore[return-value] + + +__all__ = [ + "retry_all", + "retry_any", + "retry_if_exception", + "retry_if_result", + "WrappedFn", + "AsyncRetrying", +] diff --git a/tenacity/asyncio/retry.py b/tenacity/asyncio/retry.py new file mode 100644 index 00000000..94b8b154 --- /dev/null +++ b/tenacity/asyncio/retry.py @@ -0,0 +1,125 @@ +# Copyright 2016–2021 Julien Danjou +# Copyright 2016 Joshua Harlow +# Copyright 2013-2014 Ray Holder +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import abc +import typing + +from tenacity import _utils +from tenacity import retry_base + +if typing.TYPE_CHECKING: + from tenacity import RetryCallState + + +class async_retry_base(retry_base): + """Abstract base class for async retry strategies.""" + + @abc.abstractmethod + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + pass + + def __and__( # type: ignore[override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_all": + return retry_all(self, other) + + def __rand__( # type: ignore[misc,override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_all": + return retry_all(other, self) + + def __or__( # type: ignore[override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_any": + return retry_any(self, other) + + def __ror__( # type: ignore[misc,override] + self, other: "typing.Union[retry_base, async_retry_base]" + ) -> "retry_any": + return retry_any(other, self) + + +RetryBaseT = typing.Union[ + async_retry_base, typing.Callable[["RetryCallState"], typing.Awaitable[bool]] +] + + +class retry_if_exception(async_retry_base): + """Retry strategy that retries if an exception verifies a predicate.""" + + def __init__( + self, predicate: typing.Callable[[BaseException], typing.Awaitable[bool]] + ) -> None: + self.predicate = predicate + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + + if retry_state.outcome.failed: + exception = retry_state.outcome.exception() + if exception is None: + raise RuntimeError("outcome failed but the exception is None") + return await self.predicate(exception) + else: + return False + + +class retry_if_result(async_retry_base): + """Retries if the result verifies a predicate.""" + + def __init__( + self, predicate: typing.Callable[[typing.Any], typing.Awaitable[bool]] + ) -> None: + self.predicate = predicate + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + if retry_state.outcome is None: + raise RuntimeError("__call__() called before outcome was set") + + if not retry_state.outcome.failed: + return await self.predicate(retry_state.outcome.result()) + else: + return False + + +class retry_any(async_retry_base): + """Retries if any of the retries condition is valid.""" + + def __init__(self, *retries: typing.Union[retry_base, async_retry_base]) -> None: + self.retries = retries + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + result = False + for r in self.retries: + result = result or await _utils.wrap_to_async_func(r)(retry_state) + if result: + break + return result + + +class retry_all(async_retry_base): + """Retries if all the retries condition are valid.""" + + def __init__(self, *retries: typing.Union[retry_base, async_retry_base]) -> None: + self.retries = retries + + async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override] + result = True + for r in self.retries: + result = result and await _utils.wrap_to_async_func(r)(retry_state) + if not result: + break + return result diff --git a/tenacity/before.py b/tenacity/before.py index 54259ddd..5d9aa24e 100644 --- a/tenacity/before.py +++ b/tenacity/before.py @@ -14,19 +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 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: + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) + logger.log( + log_level, + f"Starting call to '{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..a822cb86 100644 --- a/tenacity/before_sleep.py +++ b/tenacity/before_sleep.py @@ -14,33 +14,58 @@ # 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_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 before_sleep_nothing(retry_state): - """Before call strategy that does nothing.""" + 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) + if retry_state.fn is None: + # NOTE(sileht): can't really happen, but we must please mypy + fn_name = "" + else: + fn_name = _utils.get_callback_name(retry_state.fn) + + logger.log( + log_level, + f"Retrying {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..72aa5bfd 100644 --- a/tenacity/nap.py +++ b/tenacity/nap.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2016 Étienne Bersac # Copyright 2016 Julien Danjou # Copyright 2016 Joshua Harlow @@ -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: typing.Optional[float]) -> None: # NOTE(harlowja): this may *not* actually wait for timeout # seconds if the event is set (ie this may eject out early). self.event.wait(timeout=timeout) diff --git a/tenacity/retry.py b/tenacity/retry.py index 8e4fab32..9f099ec0 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,39 @@ 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): - return retry_all(self, other) + def __and__(self, other: "retry_base") -> "retry_all": + return other.__rand__(self) + + def __rand__(self, other: "retry_base") -> "retry_all": + return retry_all(other, self) + + def __or__(self, other: "retry_base") -> "retry_any": + return other.__ror__(self) + + def __ror__(self, other: "retry_base") -> "retry_any": + return retry_any(other, self) - def __or__(self, other): - return retry_any(self, other) + +RetryBaseT = typing.Union[retry_base, typing.Callable[["RetryCallState"], bool]] class _retry_never(retry_base): """Retry strategy that never rejects any result.""" - def __call__(self, retry_state): + def __call__(self, retry_state: "RetryCallState") -> bool: return False @@ -50,7 +58,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 +68,215 @@ 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) + else: + 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: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: + self.exception_types = exception_types + super().__init__(lambda e: isinstance(e, exception_types)) + + +class retry_if_not_exception_type(retry_if_exception): + """Retries except an exception has been raised of one or more types.""" + + def __init__( + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: self.exception_types = exception_types - super(retry_if_exception_type, self).__init__( - lambda e: isinstance(e, exception_types)) + super().__init__(lambda e: not isinstance(e, exception_types)) class retry_unless_exception_type(retry_if_exception): """Retries until an exception is raised of one or more types.""" - def __init__(self, exception_types=Exception): + def __init__( + self, + exception_types: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: self.exception_types = exception_types - super(retry_unless_exception_type, self).__init__( - lambda e: not isinstance(e, exception_types)) + super().__init__(lambda e: not isinstance(e, exception_types)) + + def __call__(self, retry_state: "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: typing.Union[ + typing.Type[BaseException], + typing.Tuple[typing.Type[BaseException], ...], + ] = Exception, + ) -> None: + self.exception_cause_types = exception_types + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome 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()) + else: + 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()) + else: + 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: typing.Optional[str] = None, + match: typing.Union[None, str, typing.Pattern[str]] = None, + ) -> None: if message and match: raise TypeError( - "{}() takes either 'message' or 'match', not both".format( - self.__class__.__name__)) + f"{self.__class__.__name__}() takes either 'message' or 'match', not both" + ) # set predicate if message: - def message_fnc(exception): + + def message_fnc(exception: BaseException) -> bool: return message == str(exception) + predicate = message_fnc elif match: prog = re.compile(match) - def match_fnc(exception): - return prog.match(str(exception)) + def match_fnc(exception: BaseException) -> bool: + return bool(prog.match(str(exception))) + predicate = match_fnc else: raise TypeError( - "{}() missing 1 required argument 'message' or 'match'". - format(self.__class__.__name__)) + f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'" + ) - super(retry_if_exception_message, self).__init__(predicate) + super().__init__(predicate) class retry_if_not_exception_message(retry_if_exception_message): """Retries until an exception message equals or matches.""" - def __init__(self, *args, **kwargs): - super(retry_if_not_exception_message, self).__init__(*args, **kwargs) + def __init__( + self, + message: typing.Optional[str] = None, + match: typing.Union[None, str, typing.Pattern[str]] = None, + ) -> None: + super().__init__(message, match) # invert predicate if_predicate = self.predicate - self.predicate = lambda *args_, **kwargs_: not if_predicate( - *args_, **kwargs_) + self.predicate = lambda *args_, **kwargs_: not if_predicate(*args_, **kwargs_) + + def __call__(self, retry_state: "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: retry_base) -> 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) 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: retry_base) -> 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) diff --git a/tenacity/stop.py b/tenacity/stop.py index b5874092..5cda59ab 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 = typing.Union[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/tornadoweb.py b/tenacity/tornadoweb.py index 27dd349a..a0aa6491 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,6 +13,7 @@ # limitations under the License. import sys +import typing from tenacity import BaseRetrying from tenacity import DoAttempt @@ -22,28 +22,38 @@ from tornado import gen +if typing.TYPE_CHECKING: + from tornado.concurrent import Future + +_RetValT = typing.TypeVar("_RetValT") -class TornadoRetrying(BaseRetrying): - def __init__(self, - sleep=gen.sleep, - **kwargs): - super(TornadoRetrying, self).__init__(**kwargs) +class TornadoRetrying(BaseRetrying): + 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) + @gen.coroutine # type: ignore[untyped-decorator] + def __call__( + self, + fn: "typing.Callable[..., typing.Union[typing.Generator[typing.Any, typing.Any, _RetValT], Future[_RetValT]]]", + *args: typing.Any, + **kwargs: typing.Any, + ) -> "typing.Generator[typing.Any, typing.Any, _RetValT]": + self.begin() - retry_state = RetryCallState( - retry_object=self, fn=fn, args=args, kwargs=kwargs) + 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()) + except BaseException: # noqa: B902 + 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..1cc17ea2 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,75 @@ 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") -> typing.Union["wait_combine", "wait_base"]: # make it possible to use multiple waits with the built-in sum function - if other == 0: + if other == 0: # type: ignore[comparison-overlap] return self return self.__add__(other) +WaitBaseT = typing.Union[ + wait_base, typing.Callable[["RetryCallState"], typing.Union[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: # noqa + 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 +98,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 +159,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, # noqa + ) -> 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 +187,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: typing.Union[int, float] = 1, + max: _utils.time_unit_type = _utils.MAX_WAIT, # noqa + exp_base: typing.Union[int, float] = 2, + min: _utils.time_unit_type = 0, # noqa + ) -> 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 +234,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, # noqa + exp_base: float = 2, + jitter: float = 1, + ) -> None: + self.initial = initial + self.max = max + self.exp_base = exp_base + self.jitter = jitter + + def __call__(self, retry_state: "RetryCallState") -> float: + jitter = random.uniform(0, self.jitter) + try: + exp = self.exp_base ** (retry_state.attempt_number - 1) + result = self.initial * exp + jitter + except OverflowError: + result = self.max + return max(0, min(result, self.max)) diff --git a/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..cec8d909 --- /dev/null +++ b/tests/test_after.py @@ -0,0 +1,75 @@ +# mypy: disable-error-code="no-untyped-def,no-untyped-call" +import logging +import random +import unittest.mock + +from tenacity import _utils # noqa +from tenacity import 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): + """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) + fn_name = ( + "" + if retry_state.fn is None + else _utils.get_callback_name(retry_state.fn) + ) + log.assert_called_once_with( + self.log_level, + f"Finished call to '{fn_name}' " + f"after {sec_format % retry_state.seconds_since_start}(s), " + f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.", + ) + + def test_02_custom_sec_format(self): + """Test log formatting with custom int format..""" + log = unittest.mock.MagicMock(spec="logging.Logger.log") + logger = unittest.mock.MagicMock(spec="logging.Logger", log=log) + + sec_format = "%.1f" + delay_since_first_attempt = 0.1 + + retry_state = test_tenacity.make_retry_state( + self.previous_attempt_number, delay_since_first_attempt + ) + fun = after_log(logger=logger, log_level=self.log_level, sec_format=sec_format) + fun(retry_state) + fn_name = ( + "" + if retry_state.fn is None + else _utils.get_callback_name(retry_state.fn) + ) + log.assert_called_once_with( + self.log_level, + f"Finished call to '{fn_name}' " + 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..f6793f0b --- /dev/null +++ b/tests/test_asyncio.py @@ -0,0 +1,467 @@ +# mypy: disable-error-code="no-untyped-def,no-untyped-call" +# 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 functools import wraps +from unittest import mock + +try: + import trio +except ImportError: + have_trio = False +else: + have_trio = True + +import pytest + +import tenacity +from tenacity import AsyncRetrying, RetryError +from tenacity import asyncio as tasyncio +from tenacity import retry, retry_if_exception, retry_if_result, stop_after_attempt +from tenacity.wait import wait_fixed + +from .test_tenacity import NoIOErrorAfterCount, current_time_ms + + +def asynctest(callable_): + @wraps(callable_) + def wrapper(*a, **kw): + return asyncio.run(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) + return thing.go() + + +class TestAsyncio(unittest.TestCase): + @asynctest + async def test_retry(self): + thing = NoIOErrorAfterCount(5) + await _retryable_coroutine(thing) + assert thing.counter == thing.count + + @asynctest + async def test_iscoroutinefunction(self): + assert asyncio.iscoroutinefunction(_retryable_coroutine) + assert inspect.iscoroutinefunction(_retryable_coroutine) + + @asynctest + async def test_retry_using_async_retying(self): + 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()) + + def test_retry_attributes(self): + assert hasattr(_retryable_coroutine, "retry") + assert hasattr(_retryable_coroutine, "retry_with") + + def test_retry_preserves_argument_defaults(self): + async def function_with_defaults(a=1): + return a + + async def function_with_kwdefaults(*, a=1): + return a + + retrying = AsyncRetrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) + wrapped_defaults_function = retrying.wraps(function_with_defaults) + wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) + + self.assertEqual( + function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__ + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, + ) + + @asynctest + async def test_attempt_number_is_correct_for_interleaved_coroutines(self): + attempts = [] + + 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), # type: ignore[attr-defined] + _retryable_coroutine.retry_with(after=after)(thing2), # type: ignore[attr-defined] + ) + + # There's no waiting on retry, only a wait in the coroutine, so the + # 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] + + +@unittest.skipIf(not have_trio, "trio not installed") +class TestTrio(unittest.TestCase): + def test_trio_basic(self): + thing = NoIOErrorAfterCount(5) + + @retry + async def trio_function(): + await trio.sleep(0.00001) + return thing.go() + + trio.run(trio_function) + + assert thing.counter == thing.count + + +class TestContextManager(unittest.TestCase): + @asynctest + async def test_do_max_attempts(self): + 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) + + @asynctest + async def test_retry_with_result(self): + async def test(): + 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): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + async for attempt in tasyncio.AsyncRetrying( + retry=tasyncio.retry_if_result(lt_3) + ): + with attempt: + attempts += 1 + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_exc(self): + async def test(): + attempts = 0 + + class CustomException(Exception): + pass + + async def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + async for attempt in tasyncio.AsyncRetrying( + retry=tasyncio.retry_if_exception(is_exc) + ): + with attempt: + attempts += 1 + if attempts < 3: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_result_or(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + class CustomException(Exception): + pass + + def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + retry_strategy = tasyncio.retry_if_result(lt_3) | retry_if_exception(is_exc) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + if 2 < attempts < 4: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(4, result) + + @asynctest + async def test_retry_with_async_result_ror(self): + async def test(): + attempts = 0 + + def lt_3(x: float) -> bool: + return x < 3 + + class CustomException(Exception): + pass + + async def is_exc(e: BaseException) -> bool: + return isinstance(e, CustomException) + + retry_strategy = retry_if_result(lt_3) | tasyncio.retry_if_exception(is_exc) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + if 2 < attempts < 4: + raise CustomException() + + assert attempt.retry_state.outcome # help mypy + if not attempt.retry_state.outcome.failed: + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(4, result) + + @asynctest + async def test_retry_with_async_result_and(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + def gt_0(x: float) -> bool: + return x > 0 + + retry_strategy = tasyncio.retry_if_result(lt_3) & retry_if_result(gt_0) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_retry_with_async_result_rand(self): + async def test(): + attempts = 0 + + async def lt_3(x: float) -> bool: + return x < 3 + + def gt_0(x: float) -> bool: + return x > 0 + + retry_strategy = retry_if_result(gt_0) & tasyncio.retry_if_result(lt_3) + async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy): + with attempt: + attempts += 1 + attempt.retry_state.set_result(attempts) + + return attempts + + result = await test() + + self.assertEqual(3, result) + + @asynctest + async def test_async_retying_iterator(self): + thing = NoIOErrorAfterCount(5) + 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): + """Test that the wrapped function attributes are exposed as intended. + + - statistics contains the value for the latest function run + - retry object can be modified to change its behaviour (useful to patch in tests) + - retry object statistics do not contain valid information + """ + + self.assertTrue( + await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(1)) + ) + + expected_stats = { + "attempt_number": 2, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual( + _retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined] + expected_stats, + ) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined] + {}, + ) + + with mock.patch.object( + _retryable_coroutine_with_2_attempts.retry, # type: ignore[attr-defined] + "stop", + tenacity.stop_after_attempt(1), + ): + try: + self.assertTrue( + await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(2)) + ) + except RetryError as exc: + expected_stats = { + "attempt_number": 1, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual( + _retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined] + expected_stats, + ) + self.assertEqual(exc.last_attempt.attempt_number, 1) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined] + {}, + ) + else: + self.fail("RetryError should have been raised after 1 attempt") + + +# make sure mypy accepts passing an async sleep function +# https://github.com/jd/tenacity/issues/399 +async def my_async_sleep(x: float) -> None: + await asyncio.sleep(x) + + +@retry(sleep=my_async_sleep) +async def foo(): + pass + + +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..83182ac4 --- /dev/null +++ b/tests/test_issue_478.py @@ -0,0 +1,117 @@ +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: + assert False, "No exception caught" + + assert results == [ + "Error is not working", + "Fix is working", + "Error is not working", + ] + + @asynctest + async def test_async(self) -> None: + results = [] + + async def do_retry(retry_state: RetryCallState) -> bool: + outcome = retry_state.outcome + assert outcome + ex = outcome.exception() + _subject_: str = retry_state.args[0] + + if _subject_ == "Fix": # no retry on fix failure + return False + + if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS: + return False + + if ex: + await do_fix_work() + return True + + return False + + @retry(reraise=True, retry=do_retry) + async def _do_work(subject: str) -> None: + if subject == "Error": + results.append(f"{subject} is not working") + raise Exception(f"{subject} is not working") + results.append(f"{subject} is working") + + async def do_any_work(subject: str) -> None: + await _do_work(subject) + + async def do_fix_work() -> None: + await _do_work("Fix") + + try: + await do_any_work("Error") + except Exception as exc: + assert str(exc) == "Error is not working" + else: + assert False, "No exception caught" + + assert results == [ + "Error is not working", + "Fix is working", + "Error is not working", + ] diff --git a/tenacity/tests/test_tenacity.py b/tests/test_tenacity.py similarity index 54% rename from tenacity/tests/test_tenacity.py rename to tests/test_tenacity.py index 223b19a3..5e5dddfc 100644 --- a/tenacity/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1,4 +1,5 @@ -# Copyright 2016 Julien Danjou +# mypy: disable_error_code="no-untyped-def,no-untyped-call,attr-defined,arg-type,no-any-return,list-item,var-annotated,import,call-overload" +# Copyright 2016–2021 Julien Danjou # Copyright 2016 Joshua Harlow # Copyright 2013 Ray Holder # @@ -13,8 +14,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import datetime import logging -import os import re import sys import time @@ -23,39 +24,104 @@ import warnings from contextlib import contextmanager from copy import copy +from fractions import Fraction +from unittest import mock import pytest -import six.moves - import tenacity -from tenacity import RetryError, Retrying, retry -from tenacity.compat import make_retry_state +from tenacity import RetryCallState, RetryError, Retrying, retry +_unset = object() -class TestBase(unittest.TestCase): - def test_repr(self): +def _make_unset_exception(func_name, **kwargs): + 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, 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, + upcoming_sleep=0, +): + """Construct RetryCallState for given attempt number & delay. + + Only used in testing and thus is extra careful about timestamp arithmetics. + """ + required_parameter_unset = ( + previous_attempt_number is _unset or delay_since_first_attempt is _unset + ) + if required_parameter_unset: + raise _make_unset_exception( + "wait/stop", + previous_attempt_number=previous_attempt_number, + delay_since_first_attempt=delay_since_first_attempt, + ) + + retry_state = RetryCallState(None, None, (), {}) + retry_state.attempt_number = previous_attempt_number + if last_result is not None: + retry_state.outcome = last_result + else: + retry_state.set_result(None) + + 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): class ConcreteRetrying(tenacity.BaseRetrying): - def __call__(self): + def __call__(self, fn, *args, **kwargs): pass repr(ConcreteRetrying()) + def test_callstate_repr(self): + rs = RetryCallState(None, None, (), {}) + rs.idle_for = 1.1111111 + assert repr(rs).endswith("attempt #1; slept for 1.11; last result: none yet>") + rs = make_retry_state(2, 5) + assert repr(rs).endswith( + "attempt #2; slept for 0.0; last result: returned None>" + ) + rs = make_retry_state( + 0, 0, last_result=tenacity.Future.construct(1, ValueError("aaa"), True) + ) + assert repr(rs).endswith( + "attempt #0; slept for 0.0; last result: failed (ValueError aaa)>" + ) -class TestStopConditions(unittest.TestCase): +class TestStopConditions(unittest.TestCase): def test_never_stop(self): r = Retrying() self.assertFalse(r.stop(make_retry_state(3, 6546))) def test_stop_any(self): stop = tenacity.stop_any( - tenacity.stop_after_delay(1), - tenacity.stop_after_attempt(4)) + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -65,11 +131,12 @@ def s(*args): def test_stop_all(self): stop = tenacity.stop_all( - tenacity.stop_after_delay(1), - tenacity.stop_after_attempt(4)) + tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4) + ) def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -82,6 +149,7 @@ def test_stop_or(self): def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -94,6 +162,7 @@ def test_stop_and(self): def s(*args): return stop(make_retry_state(*args)) + self.assertFalse(s(1, 0.1)) self.assertFalse(s(2, 0.2)) self.assertFalse(s(2, 0.8)) @@ -108,40 +177,31 @@ def test_stop_after_attempt(self): self.assertTrue(r.stop(make_retry_state(4, 6546))) def test_stop_after_delay(self): - r = Retrying(stop=tenacity.stop_after_delay(1)) - self.assertFalse(r.stop(make_retry_state(2, 0.999))) - self.assertTrue(r.stop(make_retry_state(2, 1))) - self.assertTrue(r.stop(make_retry_state(2, 1.001))) + for delay in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(stop=tenacity.stop_after_delay(delay)) + self.assertFalse(r.stop(make_retry_state(2, 0.999))) + self.assertTrue(r.stop(make_retry_state(2, 1))) + self.assertTrue(r.stop(make_retry_state(2, 1.001))) + + def test_stop_before_delay(self): + for delay in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(stop=tenacity.stop_before_delay(delay)) + self.assertFalse( + r.stop(make_retry_state(2, 0.999, upcoming_sleep=0.0001)) + ) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0.001))) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=1))) + + # It should act the same as stop_after_delay if upcoming sleep is 0 + self.assertFalse(r.stop(make_retry_state(2, 0.999, upcoming_sleep=0))) + self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0))) + self.assertTrue(r.stop(make_retry_state(2, 1.001, upcoming_sleep=0))) def test_legacy_explicit_stop_type(self): Retrying(stop="stop_after_attempt") - 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 @@ -154,41 +214,53 @@ def stop_func(retry_state): class TestWaitConditions(unittest.TestCase): - def test_no_sleep(self): r = Retrying() - self.assertEqual(0, r.wait(18, 9879)) + self.assertEqual(0, r.wait(make_retry_state(18, 9879))) def test_fixed_sleep(self): - r = Retrying(wait=tenacity.wait_fixed(1)) - self.assertEqual(1, r.wait(12, 6546)) + for wait in (1, datetime.timedelta(seconds=1)): + with self.subTest(): + r = Retrying(wait=tenacity.wait_fixed(wait)) + self.assertEqual(1, r.wait(make_retry_state(12, 6546))) def test_incrementing_sleep(self): - r = Retrying(wait=tenacity.wait_incrementing( - start=500, increment=100)) - self.assertEqual(500, r.wait(1, 6546)) - self.assertEqual(600, r.wait(2, 6546)) - self.assertEqual(700, r.wait(3, 6546)) + for start, increment in ( + (500, 100), + (datetime.timedelta(seconds=500), datetime.timedelta(seconds=100)), + ): + with self.subTest(): + r = Retrying( + wait=tenacity.wait_incrementing(start=start, increment=increment) + ) + self.assertEqual(500, r.wait(make_retry_state(1, 6546))) + self.assertEqual(600, r.wait(make_retry_state(2, 6546))) + self.assertEqual(700, r.wait(make_retry_state(3, 6546))) def test_random_sleep(self): - r = Retrying(wait=tenacity.wait_random(min=1, max=20)) - times = set() - for x in 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): + for min_, max_ in ( + (1, 20), + (datetime.timedelta(seconds=1), datetime.timedelta(seconds=20)), + ): + with self.subTest(): + r = Retrying(wait=tenacity.wait_random(min=min_, max=max_)) + times = set() + for _ in range(1000): + times.add(r.wait(make_retry_state(1, 6546))) + + # this is kind of non-deterministic... + self.assertTrue(len(times) > 1) + for t in times: + self.assertTrue(t >= 1) + self.assertTrue(t < 20) + + def test_random_sleep_withoutmin_(self): r = Retrying(wait=tenacity.wait_random(max=2)) times = set() - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) - times.add(r.wait(1, 6546)) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) + times.add(r.wait(make_retry_state(1, 6546))) # this is kind of non-deterministic... self.assertTrue(len(times) > 1) @@ -198,142 +270,157 @@ def test_random_sleep_without_min(self): def test_exponential(self): r = Retrying(wait=tenacity.wait_exponential()) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 64) - self.assertEqual(r.wait(8, 0), 128) + self.assertEqual(r.wait(make_retry_state(1, 0)), 1) + self.assertEqual(r.wait(make_retry_state(2, 0)), 2) + self.assertEqual(r.wait(make_retry_state(3, 0)), 4) + self.assertEqual(r.wait(make_retry_state(4, 0)), 8) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 128) def test_exponential_with_max_wait(self): r = Retrying(wait=tenacity.wait_exponential(max=40)) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 40) - self.assertEqual(r.wait(8, 0), 40) - self.assertEqual(r.wait(50, 0), 40) + self.assertEqual(r.wait(make_retry_state(1, 0)), 1) + self.assertEqual(r.wait(make_retry_state(2, 0)), 2) + self.assertEqual(r.wait(make_retry_state(3, 0)), 4) + self.assertEqual(r.wait(make_retry_state(4, 0)), 8) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 40) + self.assertEqual(r.wait(make_retry_state(8, 0)), 40) + self.assertEqual(r.wait(make_retry_state(50, 0)), 40) def test_exponential_with_min_wait(self): r = Retrying(wait=tenacity.wait_exponential(min=20)) - self.assertEqual(r.wait(1, 0), 20) - self.assertEqual(r.wait(2, 0), 20) - self.assertEqual(r.wait(3, 0), 20) - self.assertEqual(r.wait(4, 0), 20) - self.assertEqual(r.wait(5, 0), 20) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 64) - self.assertEqual(r.wait(8, 0), 128) - self.assertEqual(r.wait(20, 0), 524288) + self.assertEqual(r.wait(make_retry_state(1, 0)), 20) + self.assertEqual(r.wait(make_retry_state(2, 0)), 20) + self.assertEqual(r.wait(make_retry_state(3, 0)), 20) + self.assertEqual(r.wait(make_retry_state(4, 0)), 20) + self.assertEqual(r.wait(make_retry_state(5, 0)), 20) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 128) + self.assertEqual(r.wait(make_retry_state(20, 0)), 524288) def test_exponential_with_max_wait_and_multiplier(self): - r = Retrying(wait=tenacity.wait_exponential( - max=50, multiplier=1)) - self.assertEqual(r.wait(1, 0), 1) - self.assertEqual(r.wait(2, 0), 2) - self.assertEqual(r.wait(3, 0), 4) - self.assertEqual(r.wait(4, 0), 8) - self.assertEqual(r.wait(5, 0), 16) - self.assertEqual(r.wait(6, 0), 32) - self.assertEqual(r.wait(7, 0), 50) - self.assertEqual(r.wait(8, 0), 50) - self.assertEqual(r.wait(50, 0), 50) + 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): - 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) + 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): + for min_, max_ in ( + (10, 100), + (datetime.timedelta(seconds=10), datetime.timedelta(seconds=100)), + ): + with self.subTest(): + r = Retrying(wait=tenacity.wait_exponential(min=min_, max=max_)) + self.assertEqual(r.wait(make_retry_state(1, 0)), 10) + self.assertEqual(r.wait(make_retry_state(2, 0)), 10) + self.assertEqual(r.wait(make_retry_state(3, 0)), 10) + self.assertEqual(r.wait(make_retry_state(4, 0)), 10) + self.assertEqual(r.wait(make_retry_state(5, 0)), 16) + self.assertEqual(r.wait(make_retry_state(6, 0)), 32) + self.assertEqual(r.wait(make_retry_state(7, 0)), 64) + self.assertEqual(r.wait(make_retry_state(8, 0)), 100) + self.assertEqual(r.wait(make_retry_state(9, 0)), 100) + self.assertEqual(r.wait(make_retry_state(20, 0)), 100) def test_legacy_explicit_wait_type(self): Retrying(wait="exponential_sleep") - 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))) + 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) + 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): + def predicate(exc): + if isinstance(exc, ValueError): + return 3.5 + return 10.0 + + r = Retrying(wait=tenacity.wait_exception(predicate)) + + fut1 = tenacity.Future.construct(1, ValueError(), True) + self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut1)), 3.5) + + fut2 = tenacity.Future.construct(1, KeyError(), True) + self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut2)), 10.0) + + fut3 = tenacity.Future.construct(1, None, False) + with self.assertRaises(RuntimeError): + r.wait(make_retry_state(1, 0, last_result=fut3)) + def test_wait_double_sum(self): r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5)) # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) + 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): - r = Retrying(wait=tenacity.wait_fixed(1) + tenacity.wait_random(0, 3) + - tenacity.wait_fixed(5)) + r = Retrying( + wait=tenacity.wait_fixed(1) + + tenacity.wait_random(0, 3) + + tenacity.wait_fixed(5) + ) # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) + 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): - r = Retrying(wait=sum([tenacity.wait_fixed(1), - tenacity.wait_random(0, 3), - tenacity.wait_fixed(5), - tenacity.wait_none()])) + r = Retrying( + wait=sum( + [ + tenacity.wait_fixed(1), + tenacity.wait_random(0, 3), + tenacity.wait_fixed(5), + tenacity.wait_none(), + ] + ) + ) # Test it a few time since it's random - for i in six.moves.range(1000): - w = r.wait(1, 5) + for _ in range(1000): + w = r.wait(make_retry_state(1, 5)) self.assertLess(w, 9) self.assertGreaterEqual(w, 6) @@ -350,13 +437,16 @@ def _assert_inclusive_epsilon(self, wait, target, epsilon): self.assertGreaterEqual(wait, target - epsilon) def test_wait_chain(self): - r = Retrying(wait=tenacity.wait_chain( - *[tenacity.wait_fixed(1) for i in six.moves.range(2)] + - [tenacity.wait_fixed(4) for i in six.moves.range(2)] + - [tenacity.wait_fixed(8) for i in six.moves.range(1)])) + r = Retrying( + wait=tenacity.wait_chain( + *[tenacity.wait_fixed(1) for i in range(2)] + + [tenacity.wait_fixed(4) for i in range(2)] + + [tenacity.wait_fixed(8) for i in range(1)] + ) + ) - for i in six.moves.range(10): - w = r.wait(i + 1, 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: @@ -368,9 +458,7 @@ def test_wait_chain_multiple_invocations(self): sleep_intervals = [] r = Retrying( sleep=sleep_intervals.append, - wait=tenacity.wait_chain(*[ - tenacity.wait_fixed(i + 1) for i in six.moves.range(3) - ]), + wait=tenacity.wait_chain(*[tenacity.wait_fixed(i + 1) for i in range(3)]), stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_if_result(lambda x: x == 1), ) @@ -391,7 +479,7 @@ def always_return_1(): def test_wait_random_exponential(self): fn = tenacity.wait_random_exponential(0.5, 60.0) - for _ in six.moves.range(1000): + for _ in range(1000): self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0, 0.5) self._assert_inclusive_range(fn(make_retry_state(2, 0)), 0, 1.0) self._assert_inclusive_range(fn(make_retry_state(3, 0)), 0, 2.0) @@ -402,24 +490,28 @@ def test_wait_random_exponential(self): self._assert_inclusive_range(fn(make_retry_state(8, 0)), 0, 60.0) self._assert_inclusive_range(fn(make_retry_state(9, 0)), 0, 60.0) - fn = tenacity.wait_random_exponential(10, 5) - for _ in six.moves.range(1000): - self._assert_inclusive_range( - fn(make_retry_state(1, 0)), 0.00, 5.00 - ) + # 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(0, 0) + fn(make_retry_state(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)] - ) + for i in range(10): + attempt.append([fn(make_retry_state(i, 0)) for _ in range(4000)]) def mean(lst): return float(sum(lst)) / float(len(lst)) @@ -435,54 +527,29 @@ def mean(lst): self._assert_inclusive_epsilon(mean(attempt[8]), 30, 2.56) self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56) - def test_wait_backward_compat(self): - """Ensure Retrying object accepts both old and newstyle wait funcs.""" - def wait1(previous_attempt_number, delay_since_first_attempt): - wait1.calls.append(( - previous_attempt_number, delay_since_first_attempt)) - return 0 - wait1.calls = [] - - def wait2(previous_attempt_number, delay_since_first_attempt, - last_result): - wait2.calls.append(( - previous_attempt_number, delay_since_first_attempt, - last_result)) - return 0 - wait2.calls = [] + def test_wait_exponential_jitter(self): + fn = tenacity.wait_exponential_jitter(max=60) - def dying(): - raise Exception("Broken") + 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) - 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) + fn = tenacity.wait_exponential_jitter(10, 5) + for _ in range(1000): + self.assertEqual(fn(make_retry_state(1, 0)), 5) - def test_wait_retry_state_attributes(self): + # Default arguments exist + fn = tenacity.wait_exponential_jitter() + fn(make_retry_state(0, 0)) + def test_wait_retry_state_attributes(self): class ExtractCallState(Exception): pass @@ -493,11 +560,15 @@ def waitfunc(retry_state): retrying = Retrying( wait=waitfunc, - retry=(tenacity.retry_if_exception_type() | - tenacity.retry_if_result(lambda result: result == 123))) + retry=( + tenacity.retry_if_exception_type() + | tenacity.retry_if_result(lambda result: result == 123) + ), + ) def returnval(): return 123 + try: retrying(returnval) except ExtractCallState as err: @@ -507,11 +578,11 @@ def returnval(): self.assertEqual(retry_state.kwargs, {}) self.assertEqual(retry_state.outcome.result(), 123) self.assertEqual(retry_state.attempt_number, 1) - self.assertGreater(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time) def dying(): raise Exception("Broken") + try: retrying(dying) except ExtractCallState as err: @@ -519,40 +590,42 @@ def dying(): self.assertIs(retry_state.fn, dying) self.assertEqual(retry_state.args, ()) self.assertEqual(retry_state.kwargs, {}) - self.assertEqual(str(retry_state.outcome.exception()), 'Broken') + self.assertEqual(str(retry_state.outcome.exception()), "Broken") self.assertEqual(retry_state.attempt_number, 1) - self.assertGreater(retry_state.outcome_timestamp, - retry_state.start_time) + self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time) class TestRetryConditions(unittest.TestCase): - def test_retry_if_result(self): - retry = (tenacity.retry_if_result(lambda x: x == 1)) + retry = tenacity.retry_if_result(lambda x: x == 1) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) def test_retry_if_not_result(self): - retry = (tenacity.retry_if_not_result(lambda x: x == 1)) + retry = tenacity.retry_if_not_result(lambda x: x == 1) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, False))) def test_retry_any(self): retry = tenacity.retry_any( tenacity.retry_if_result(lambda x: x == 1), - tenacity.retry_if_result(lambda x: x == 2)) + tenacity.retry_if_result(lambda x: x == 2), + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertTrue(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) @@ -561,35 +634,41 @@ def r(fut): def test_retry_all(self): retry = tenacity.retry_all( tenacity.retry_if_result(lambda x: x == 1), - tenacity.retry_if_result(lambda x: isinstance(x, int))) + tenacity.retry_if_result(lambda x: isinstance(x, int)), + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_and(self): - retry = (tenacity.retry_if_result(lambda x: x == 1) & - tenacity.retry_if_result(lambda x: isinstance(x, int))) + retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result( + lambda x: isinstance(x, int) + ) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, 1, False))) self.assertFalse(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 3, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, True))) def test_retry_or(self): - retry = (tenacity.retry_if_result(lambda x: x == "foo") | - tenacity.retry_if_result(lambda x: isinstance(x, int))) + retry = tenacity.retry_if_result( + lambda x: x == "foo" + ) | tenacity.retry_if_result(lambda x: isinstance(x, int)) def r(fut): retry_state = make_retry_state(1, 1.0, last_result=fut) return retry(retry_state) + self.assertTrue(r(tenacity.Future.construct(1, "foo", False))) self.assertFalse(r(tenacity.Future.construct(1, "foobar", False))) self.assertFalse(r(tenacity.Future.construct(1, 2.2, False))) @@ -602,32 +681,30 @@ def _raise_try_again(self): def test_retry_try_again(self): self._attempts = 0 - Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never)(self._raise_try_again) + Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)( + self._raise_try_again + ) self.assertEqual(3, self._attempts) def test_retry_try_again_forever(self): def _r(): raise tenacity.TryAgain - r = Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never) - self.assertRaises(tenacity.RetryError, - r, - _r) - self.assertEqual(5, r.statistics['attempt_number']) + r = Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never) + self.assertRaises(tenacity.RetryError, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) def test_retry_try_again_forever_reraise(self): def _r(): raise tenacity.TryAgain - r = Retrying(stop=tenacity.stop_after_attempt(5), - retry=tenacity.retry_never, - reraise=True) - self.assertRaises(tenacity.TryAgain, - r, - _r) - self.assertEqual(5, r.statistics['attempt_number']) + r = Retrying( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_never, + reraise=True, + ) + self.assertRaises(tenacity.TryAgain, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) def test_retry_if_exception_message_negative_no_inputs(self): with self.assertRaises(TypeError): @@ -635,11 +712,10 @@ def test_retry_if_exception_message_negative_no_inputs(self): def test_retry_if_exception_message_negative_too_many_inputs(self): with self.assertRaises(TypeError): - tenacity.retry_if_exception_message( - message="negative", match="negative") + tenacity.retry_if_exception_message(message="negative", match="negative") -class NoneReturnUntilAfterCount(object): +class NoneReturnUntilAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -657,7 +733,7 @@ def go(self): return True -class NoIOErrorAfterCount(object): +class NoIOErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -671,11 +747,11 @@ def go(self): """ if self.counter < self.count: self.counter += 1 - raise IOError("Hi there, I'm an IOError") + raise OSError("Hi there, I'm an IOError") return True -class NoNameErrorAfterCount(object): +class NoNameErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -693,7 +769,57 @@ def go(self): return True -class NameErrorUntilCount(object): +class NoNameErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count): + self.counter = 0 + self.count = count + + def go2(self): + raise NameError("Hi there, I'm a NameError") + + def go(self): + """Raise an IOError with a NameError as cause until after count threshold has been crossed. + + Then return True. + """ + if self.counter < self.count: + self.counter += 1 + try: + self.go2() + except NameError as e: + raise OSError() from e + + return True + + +class NoIOErrorCauseAfterCount: + """Holds counter state for invoking a method several times in a row.""" + + def __init__(self, count): + self.counter = 0 + self.count = count + + def go2(self): + raise OSError("Hi there, I'm an IOError") + + def go(self): + """Raise a NameError with an IOError as cause until after count threshold has been crossed. + + 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" @@ -713,7 +839,7 @@ def go(self): raise NameError(self.derived_message) -class IOErrorUntilCount(object): +class IOErrorUntilCount: """Holds counter state for invoking a method several times in a row.""" def __init__(self, count): @@ -728,7 +854,7 @@ def go(self): if self.counter < self.count: self.counter += 1 return True - raise IOError("Hi there, I'm an IOError") + raise OSError("Hi there, I'm an IOError") class CustomError(Exception): @@ -749,7 +875,7 @@ def __str__(self): return self.value -class NoCustomErrorAfterCount(object): +class NoCustomErrorAfterCount: """Holds counter state for invoking a method several times in a row.""" derived_message = "This is a Custom exception class" @@ -773,7 +899,7 @@ class CapturingHandler(logging.Handler): """Captures log records for inspection.""" def __init__(self, *args, **kwargs): - super(CapturingHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.records = [] def emit(self, record): @@ -784,26 +910,40 @@ def current_time_ms(): return int(round(time.time() * 1000)) -@retry(wait=tenacity.wait_fixed(0.05), - retry=tenacity.retry_if_result(lambda result: result is None)) +@retry( + wait=tenacity.wait_fixed(0.05), + retry=tenacity.retry_if_result(lambda result: result is None), +) def _retryable_test_with_wait(thing): return thing.go() -@retry(stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_result(lambda result: result is None)) +@retry( + stop=tenacity.stop_after_attempt(3), + retry=tenacity.retry_if_result(lambda result: result is None), +) def _retryable_test_with_stop(thing): return thing.go() +@retry(retry=tenacity.retry_if_exception_cause_type(NameError)) +def _retryable_test_with_exception_cause_type(thing): + return thing.go() + + @retry(retry=tenacity.retry_if_exception_type(IOError)) def _retryable_test_with_exception_type_io(thing): return thing.go() +@retry(retry=tenacity.retry_if_not_exception_type(IOError)) +def _retryable_test_if_not_exception_type_io(thing): + return thing.go() + + @retry( - stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_exception_type(IOError)) + 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() @@ -815,7 +955,8 @@ def _retryable_test_with_unless_exception_type_name(thing): @retry( stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_unless_exception_type(NameError)) + retry=tenacity.retry_unless_exception_type(NameError), +) def _retryable_test_with_unless_exception_type_name_attempt_limit(thing): return thing.go() @@ -828,31 +969,45 @@ def _retryable_test_with_unless_exception_type_no_input(thing): @retry( stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_if_exception_message( - message=NoCustomErrorAfterCount.derived_message)) + message=NoCustomErrorAfterCount.derived_message + ), +) def _retryable_test_if_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - message=NoCustomErrorAfterCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NoCustomErrorAfterCount.derived_message + ) +) def _retryable_test_if_not_exception_message_message(thing): return thing.go() -@retry(retry=tenacity.retry_if_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - match=NoCustomErrorAfterCount.derived_message[:3] + ".*")) +@retry( + retry=tenacity.retry_if_not_exception_message( + match=NoCustomErrorAfterCount.derived_message[:3] + ".*" + ) +) def _retryable_test_if_not_exception_message_match(thing): return thing.go() -@retry(retry=tenacity.retry_if_not_exception_message( - message=NameErrorUntilCount.derived_message)) +@retry( + retry=tenacity.retry_if_not_exception_message( + message=NameErrorUntilCount.derived_message + ) +) def _retryable_test_not_exception_message_delay(thing): return thing.go() @@ -874,13 +1029,13 @@ def _retryable_test_with_exception_type_custom(thing): @retry( stop=tenacity.stop_after_attempt(3), - retry=tenacity.retry_if_exception_type(CustomError)) + retry=tenacity.retry_if_exception_type(CustomError), +) def _retryable_test_with_exception_type_custom_attempt_limit(thing): return thing.go() class TestDecoratorWrapper(unittest.TestCase): - def test_with_wait(self): start = current_time_ms() result = _retryable_test_with_wait(NoneReturnUntilAfterCount(5)) @@ -888,14 +1043,6 @@ def test_with_wait(self): self.assertGreaterEqual(t, 250) self.assertTrue(result) - def test_retry_with(self): - start = current_time_ms() - result = _retryable_test_with_wait.retry_with( - wait=tenacity.wait_fixed(0.1))(NoneReturnUntilAfterCount(5)) - t = current_time_ms() - start - self.assertGreaterEqual(t, 500) - self.assertTrue(result) - def test_with_stop_on_return_value(self): try: _retryable_test_with_stop(NoneReturnUntilAfterCount(5)) @@ -910,13 +1057,12 @@ def test_with_stop_on_exception(self): try: _retryable_test_with_stop(NoIOErrorAfterCount(5)) self.fail("Expected IOError") - except IOError as re: + except OSError as re: self.assertTrue(isinstance(re, IOError)) print(re) def test_retry_if_exception_of_type(self): - self.assertTrue(_retryable_test_with_exception_type_io( - NoIOErrorAfterCount(5))) + self.assertTrue(_retryable_test_with_exception_type_io(NoIOErrorAfterCount(5))) try: _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5)) @@ -925,25 +1071,37 @@ def test_retry_if_exception_of_type(self): self.assertTrue(isinstance(n, NameError)) print(n) - self.assertTrue(_retryable_test_with_exception_type_custom( - NoCustomErrorAfterCount(5))) + self.assertTrue( + _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5)) + ) try: - _retryable_test_with_exception_type_custom( - NoNameErrorAfterCount(5)) + _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5)) self.fail("Expected NameError") except NameError as n: self.assertTrue(isinstance(n, NameError)) print(n) + def test_retry_except_exception_of_type(self): + self.assertTrue( + _retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5)) + ) + + try: + _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5)) + self.fail("Expected IOError") + except OSError as err: + self.assertTrue(isinstance(err, IOError)) + print(err) + def test_retry_until_exception_of_type_attempt_number(self): try: - self.assertTrue(_retryable_test_with_unless_exception_type_name( - NameErrorUntilCount(5))) + self.assertTrue( + _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) + ) except NameError as e: - s = _retryable_test_with_unless_exception_type_name.\ - retry.statistics - self.assertTrue(s['attempt_number'] == 6) + s = _retryable_test_with_unless_exception_type_name.statistics + self.assertTrue(s["attempt_number"] == 6) print(e) else: self.fail("Expected NameError") @@ -953,12 +1111,12 @@ def test_retry_until_exception_of_type_no_type(self): # no input should catch all subclasses of Exception self.assertTrue( _retryable_test_with_unless_exception_type_no_input( - NameErrorUntilCount(5)) + NameErrorUntilCount(5) + ) ) except NameError as e: - s = _retryable_test_with_unless_exception_type_no_input.\ - retry.statistics - self.assertTrue(s['attempt_number'] == 6) + s = _retryable_test_with_unless_exception_type_no_input.statistics + self.assertTrue(s["attempt_number"] == 6) print(e) else: self.fail("Expected NameError") @@ -967,7 +1125,8 @@ def test_retry_until_exception_of_type_wrong_exception(self): try: # two iterations with IOError, one that returns True _retryable_test_with_unless_exception_type_name_attempt_limit( - IOErrorUntilCount(2)) + IOErrorUntilCount(2) + ) self.fail("Expected RetryError") except RetryError as e: self.assertTrue(isinstance(e, RetryError)) @@ -975,46 +1134,84 @@ def test_retry_until_exception_of_type_wrong_exception(self): def test_retry_if_exception_message(self): try: - self.assertTrue(_retryable_test_if_exception_message_message( - NoCustomErrorAfterCount(3))) + self.assertTrue( + _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3)) + ) except CustomError: - print(_retryable_test_if_exception_message_message.retry. - statistics) + print(_retryable_test_if_exception_message_message.statistics) self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.\ - statistics - self.assertTrue(s['attempt_number'] == 1) + s = _retryable_test_if_not_exception_message_message.statistics + self.assertTrue(s["attempt_number"] == 1) def test_retry_if_not_exception_message_delay(self): try: - self.assertTrue(_retryable_test_not_exception_message_delay( - NameErrorUntilCount(3))) + self.assertTrue( + _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) + ) except NameError: - s = _retryable_test_not_exception_message_delay.retry.statistics - print(s['attempt_number']) - self.assertTrue(s['attempt_number'] == 4) + 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): try: - self.assertTrue(_retryable_test_if_exception_message_match( - NoCustomErrorAfterCount(3))) + self.assertTrue( + _retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3)) + ) except CustomError: self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message_match(self): try: - self.assertTrue(_retryable_test_if_not_exception_message_message( - NoCustomErrorAfterCount(2))) + self.assertTrue( + _retryable_test_if_not_exception_message_message( + NoCustomErrorAfterCount(2) + ) + ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.\ - statistics - self.assertTrue(s['attempt_number'] == 1) + s = _retryable_test_if_not_exception_message_message.statistics + self.assertTrue(s["attempt_number"] == 1) + + def test_retry_if_exception_cause_type(self): + self.assertTrue( + _retryable_test_with_exception_cause_type(NoNameErrorCauseAfterCount(5)) + ) + + try: + _retryable_test_with_exception_cause_type(NoIOErrorCauseAfterCount(5)) + self.fail("Expected exception without NameError as cause") + except NameError: + pass + + def test_retry_preserves_argument_defaults(self): + def function_with_defaults(a=1): + return a + + def function_with_kwdefaults(*, a=1): + return a + + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) + wrapped_defaults_function = retrying.wraps(function_with_defaults) + wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults) + + self.assertEqual( + function_with_defaults.__defaults__, wrapped_defaults_function.__defaults__ + ) + self.assertEqual( + function_with_kwdefaults.__kwdefaults__, + wrapped_kwdefaults_function.__kwdefaults__, + ) def test_defaults(self): self.assertTrue(_retryable_default(NoNameErrorAfterCount(5))) @@ -1023,37 +1220,99 @@ def test_defaults(self): self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5))) def test_retry_function_object(self): - """Test that six.wraps doesn't cause problems with callable objects. + """Test that funсtools.wraps doesn't cause problems with callable objects. It raises an error upon trying to wrap it in Py2, because __name__ attribute is missing. It's fixed in Py3 but was never backported. """ - class Hello(object): + + class Hello: def __call__(self): return "Hello" - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3)) + + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3) + ) h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") - def test_retry_child_class_with_override_backward_compat(self): - def always_true(_): - return True + def test_retry_function_attributes(self): + """Test that the wrapped function attributes are exposed as intended. - class MyRetry(tenacity.retry_if_exception): - def __init__(self): - super(MyRetry, self).__init__(always_true) + - statistics contains the value for the latest function run + - retry object can be modified to change its behaviour (useful to patch in tests) + - retry object statistics do not contain valid information + """ - 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()) + self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2))) + + expected_stats = { + "attempt_number": 3, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual(_retryable_test_with_stop.statistics, expected_stats) + self.assertEqual(_retryable_test_with_stop.retry.statistics, {}) + + with mock.patch.object( + _retryable_test_with_stop.retry, "stop", tenacity.stop_after_attempt(1) + ): + try: + self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2))) + except RetryError as exc: + expected_stats = { + "attempt_number": 1, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual(_retryable_test_with_stop.statistics, expected_stats) + self.assertEqual(exc.last_attempt.attempt_number, 1) + self.assertEqual(_retryable_test_with_stop.retry.statistics, {}) + else: + self.fail("RetryError should have been raised after 1 attempt") - def failing(): - raise NotImplementedError() - with pytest.raises(RetryError): - retrying(failing) + +class TestRetryWith: + def test_redefine_wait(self): + start = current_time_ms() + result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))( + NoneReturnUntilAfterCount(5) + ) + t = current_time_ms() - start + assert t >= 500 + assert result is True + + def test_redefine_stop(self): + result = _retryable_test_with_stop.retry_with( + stop=tenacity.stop_after_attempt(5) + )(NoneReturnUntilAfterCount(4)) + assert result is True + + def test_retry_error_cls_should_be_preserved(self): + @retry(stop=tenacity.stop_after_attempt(10), retry_error_cls=ValueError) + def _retryable(): + raise Exception("raised for test purposes") + + with pytest.raises(Exception) as exc_ctx: + _retryable.retry_with(stop=tenacity.stop_after_attempt(2))() + + assert exc_ctx.type is ValueError, "Should remap to specific exception type" + + def test_retry_error_callback_should_be_preserved(self): + def return_text(retry_state): + return "Calling {} keeps raising errors after {} attempts".format( + retry_state.fn.__name__, + retry_state.attempt_number, + ) + + @retry(stop=tenacity.stop_after_attempt(10), retry_error_callback=return_text) + def _retryable(): + raise Exception("raised for test purposes") + + result = _retryable.retry_with(stop=tenacity.stop_after_attempt(5))() + assert result == "Calling _retryable keeps raising errors after 5 attempts" class TestBeforeAfterAttempts(unittest.TestCase): @@ -1063,12 +1322,13 @@ def test_before_attempts(self): TestBeforeAfterAttempts._attempt_number = 0 def _before(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(1), - stop=tenacity.stop_after_attempt(1), - before=_before) + @retry( + wait=tenacity.wait_fixed(1), + stop=tenacity.stop_after_attempt(1), + before=_before, + ) def _test_before(): pass @@ -1080,12 +1340,13 @@ def test_after_attempts(self): TestBeforeAfterAttempts._attempt_number = 0 def _after(retry_state): - TestBeforeAfterAttempts._attempt_number = \ - retry_state.attempt_number + TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(3), - after=_after) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(3), + after=_after, + ) def _test_after(): if TestBeforeAfterAttempts._attempt_number < 2: raise Exception("testing after_attempts handler") @@ -1101,9 +1362,11 @@ def _before_sleep(retry_state): self.assertGreater(retry_state.next_action.sleep, 0) _before_sleep.attempt_number = retry_state.attempt_number - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + @retry( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) def _test_before_sleep(): if _before_sleep.attempt_number < 2: raise Exception("testing before_sleep_attempts handler") @@ -1111,43 +1374,6 @@ def _test_before_sleep(): _test_before_sleep() self.assertEqual(_before_sleep.attempt_number, 2) - def test_before_sleep_backward_compat(self): - def _before_sleep(retry_obj, sleep, last_result): - self.assertGreater(sleep, 0) - _before_sleep.attempt_number = \ - retry_obj.statistics['attempt_number'] - _before_sleep.attempt_number = 0 - - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) - def _test_before_sleep(): - if _before_sleep.attempt_number < 2: - raise Exception("testing before_sleep_attempts handler") - - with reports_deprecation_warning(): - _test_before_sleep() - self.assertEqual(_before_sleep.attempt_number, 2) - - def _before_sleep(self, retry_state): - self.slept += 1 - - def test_before_sleep_backward_compat_method(self): - self.slept = 0 - - @retry(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=self._before_sleep) - def _test_before_sleep(): - raise Exception("testing before_sleep_attempts handler") - - try: - _test_before_sleep() - except tenacity.RetryError: - pass - - self.assertEqual(self.slept, 2) - def _before_sleep_log_raises(self, get_call_fn): thing = NoIOErrorAfterCount(2) logger = logging.getLogger(self.id()) @@ -1157,26 +1383,27 @@ def _before_sleep_log_raises(self, get_call_fn): logger.addHandler(handler) try: _before_sleep = tenacity.before_sleep_log(logger, logging.INFO) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) get_call_fn(retrying)(thing.go) finally: logger.removeHandler(handler) - etalon_re = (r"^Retrying .* in 0\.01 seconds as it raised " - r"(IO|OS)Error: Hi there, I'm an IOError\.$") + etalon_re = ( + r"^Retrying .* in 0\.01 seconds as it raised " + r"(IO|OS)Error: Hi there, I'm an IOError\.$" + ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_raises(self): self._before_sleep_log_raises(lambda x: x) - 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()) @@ -1185,25 +1412,29 @@ def test_before_sleep_log_raises_with_exc_info(self): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, - logging.INFO, - exc_info=True) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - before_sleep=_before_sleep) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=True + ) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + before_sleep=_before_sleep, + ) retrying(thing.go) finally: logger.removeHandler(handler) - etalon_re = re.compile(r"^Retrying .* in 0\.01 seconds as it raised " - r"(IO|OS)Error: Hi there, I'm an IOError\.{0}" - r"Traceback \(most recent call last\):{0}" - r".*$".format(os.linesep), - flags=re.MULTILINE) + etalon_re = re.compile( + r"^Retrying .* in 0\.01 seconds as it raised " + r"(IO|OS)Error: Hi there, I'm an IOError\.{0}" + r"Traceback \(most recent call last\):{0}" + r".*$".format("\n"), + flags=re.MULTILINE, + ) self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_returns(self, exc_info=False): thing = NoneReturnUntilAfterCount(2) @@ -1213,37 +1444,41 @@ def test_before_sleep_log_returns(self, exc_info=False): handler = CapturingHandler() logger.addHandler(handler) try: - _before_sleep = tenacity.before_sleep_log(logger, - logging.INFO, - exc_info=exc_info) + _before_sleep = tenacity.before_sleep_log( + logger, logging.INFO, exc_info=exc_info + ) _retry = tenacity.retry_if_result(lambda result: result is None) - retrying = Retrying(wait=tenacity.wait_fixed(0.01), - stop=tenacity.stop_after_attempt(3), - retry=_retry, before_sleep=_before_sleep) + retrying = Retrying( + wait=tenacity.wait_fixed(0.01), + stop=tenacity.stop_after_attempt(3), + retry=_retry, + before_sleep=_before_sleep, + ) retrying(thing.go) finally: logger.removeHandler(handler) - etalon_re = r'^Retrying .* in 0\.01 seconds as it returned None\.$' + etalon_re = r"^Retrying .* in 0\.01 seconds as it returned None\.$" self.assertEqual(len(handler.records), 2) fmt = logging.Formatter().format - self.assertRegexpMatches(fmt(handler.records[0]), etalon_re) - self.assertRegexpMatches(fmt(handler.records[1]), etalon_re) + self.assertRegex(fmt(handler.records[0]), etalon_re) + self.assertRegex(fmt(handler.records[1]), etalon_re) def test_before_sleep_log_returns_with_exc_info(self): self.test_before_sleep_log_returns(exc_info=True) class TestReraiseExceptions(unittest.TestCase): - def test_reraise_by_default(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - reraise=True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + reraise=True, + ) def _reraised_by_default(): - calls.append('x') + calls.append("x") raise KeyError("Bad key") self.assertRaises(KeyError, _reraised_by_default) @@ -1252,10 +1487,9 @@ def _reraised_by_default(): def test_reraise_from_retry_error(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2)) + @retry(wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(2)) def _raise_key_error(): - calls.append('x') + calls.append("x") raise KeyError("Bad key") def _reraised_key_error(): @@ -1270,11 +1504,13 @@ def _reraised_key_error(): def test_reraise_timeout_from_retry_error(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - retry=lambda retry_state: True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + ) def _mock_fn(): - calls.append('x') + calls.append("x") def _reraised_mock_fn(): try: @@ -1288,43 +1524,43 @@ def _reraised_mock_fn(): def test_reraise_no_exception(self): calls = [] - @retry(wait=tenacity.wait_fixed(0.1), - stop=tenacity.stop_after_attempt(2), - retry=lambda retry_state: True, - reraise=True) + @retry( + wait=tenacity.wait_fixed(0.1), + stop=tenacity.stop_after_attempt(2), + retry=lambda retry_state: True, + reraise=True, + ) def _mock_fn(): - calls.append('x') + calls.append("x") self.assertRaises(tenacity.RetryError, _mock_fn) self.assertEqual(2, len(calls)) class TestStatistics(unittest.TestCase): - def test_stats(self): @retry() def _foobar(): return 42 - self.assertEqual({}, _foobar.retry.statistics) + self.assertEqual({}, _foobar.statistics) _foobar() - self.assertEqual(1, _foobar.retry.statistics['attempt_number']) + self.assertEqual(1, _foobar.statistics["attempt_number"]) def test_stats_failing(self): @retry(stop=tenacity.stop_after_attempt(2)) def _foobar(): raise ValueError(42) - self.assertEqual({}, _foobar.retry.statistics) + self.assertEqual({}, _foobar.statistics) try: _foobar() - except Exception: + except Exception: # noqa: B902 pass - self.assertEqual(2, _foobar.retry.statistics['attempt_number']) + self.assertEqual(2, _foobar.statistics["attempt_number"]) class TestRetryErrorCallback(unittest.TestCase): - def setUp(self): self._attempt_number = 0 self._callback_called = False @@ -1333,28 +1569,6 @@ def _callback(self, fut): self._callback_called = True return fut - def test_retry_error_callback_backward_compat(self): - num_attempts = 3 - - def retry_error_callback(fut): - retry_error_callback.called_times += 1 - return fut - - retry_error_callback.called_times = 0 - - @retry(stop=tenacity.stop_after_attempt(num_attempts), - retry_error_callback=retry_error_callback) - def _foobar(): - self._attempt_number += 1 - raise Exception("This exception should not be raised") - - with reports_deprecation_warning(): - result = _foobar() - - self.assertEqual(retry_error_callback.called_times, 1) - self.assertEqual(num_attempts, self._attempt_number) - self.assertIsInstance(result, tenacity.Future) - def test_retry_error_callback(self): num_attempts = 3 @@ -1364,8 +1578,10 @@ def retry_error_callback(retry_state): retry_error_callback.called_times = 0 - @retry(stop=tenacity.stop_after_attempt(num_attempts), - retry_error_callback=retry_error_callback) + @retry( + stop=tenacity.stop_after_attempt(num_attempts), + retry_error_callback=retry_error_callback, + ) def _foobar(): self._attempt_number += 1 raise Exception("This exception should not be raised") @@ -1450,6 +1666,7 @@ def f(): if len(f.calls) <= 1: raise Exception("Retry it!") return 42 + f.calls = [] retry = Retrying() @@ -1465,6 +1682,7 @@ def f(): if len(f.calls) <= 1: raise CustomError("Don't retry!") return 42 + f.calls = [] retry = Retrying(retry=tenacity.retry_if_exception_type(IOError)) @@ -1476,6 +1694,7 @@ def test_retry_error(self): def f(): f.calls.append(len(f.calls) + 1) raise Exception("Retry it!") + f.calls = [] retry = Retrying(stop=tenacity.stop_after_attempt(2)) @@ -1490,6 +1709,7 @@ class CustomError(Exception): def f(): f.calls.append(len(f.calls) + 1) raise CustomError("Retry it!") + f.calls = [] retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2)) @@ -1498,19 +1718,10 @@ def f(): assert f.calls == [1, 2] -class TestInvokeViaLegacyCallMethod(TestInvokeAsCallable): - """Retrying.call() method should work the same as Retrying.__call__().""" - - @staticmethod - def invoke(retry, f): - with reports_deprecation_warning(): - return retry.call(f) - - class TestRetryException(unittest.TestCase): - def test_retry_error_is_pickleable(self): import pickle + expected = RetryError(last_attempt=123) pickled = pickle.dumps(expected) actual = pickle.loads(pickled) @@ -1518,10 +1729,8 @@ def test_retry_error_is_pickleable(self): class TestRetryTyping(unittest.TestCase): - @pytest.mark.skipif( - sys.version_info < (3, 0), - reason="typeguard not supported for python 2" + sys.version_info < (3, 0), reason="typeguard not supported for python 2" ) def test_retry_type_annotations(self): """The decorator should maintain types of decorated functions.""" @@ -1545,19 +1754,17 @@ def num_to_str(number): with_constructor_result = with_raw(1) # These raise TypeError exceptions if they fail - check_type("with_raw", with_raw, typing.Callable[[int], str]) - check_type("with_raw_result", with_raw_result, str) - check_type( - "with_constructor", with_constructor, typing.Callable[[int], str] - ) - check_type("with_constructor_result", with_constructor_result, str) + check_type(with_raw, typing.Callable[[int], str]) + check_type(with_raw_result, str) + check_type(with_constructor, typing.Callable[[int], str]) + check_type(with_constructor_result, str) @contextmanager def reports_deprecation_warning(): __tracebackhide__ = True oldfilters = copy(warnings.filters) - warnings.simplefilter('always') + warnings.simplefilter("always") try: with pytest.warns(DeprecationWarning): yield @@ -1565,7 +1772,7 @@ def reports_deprecation_warning(): warnings.filters = oldfilters -class TestMockingSleep(): +class TestMockingSleep: RETRY_ARGS = dict( wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(5), @@ -1580,7 +1787,7 @@ def _decorated_fail(self): @pytest.fixture() def mock_sleep(self, monkeypatch): - class MockSleep(object): + class MockSleep: call_count = 0 def __call__(self, seconds): @@ -1590,12 +1797,6 @@ def __call__(self, seconds): monkeypatch.setattr(tenacity.nap.time, "sleep", sleep) yield sleep - def test_call(self, mock_sleep): - retrying = Retrying(**self.RETRY_ARGS) - with pytest.raises(RetryError): - retrying.call(self._fail) - assert mock_sleep.call_count == 4 - def test_decorated(self, mock_sleep): with pytest.raises(RetryError): self._decorated_fail() @@ -1610,5 +1811,5 @@ def test_decorated_retry_with(self, mock_sleep): assert mock_sleep.call_count == 1 -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tenacity/tests/test_tornado.py b/tests/test_tornado.py similarity index 91% rename from tenacity/tests/test_tornado.py rename to tests/test_tornado.py index 23380170..24858cff 100644 --- a/tenacity/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -1,4 +1,4 @@ -# coding: utf-8 +# mypy: disable-error-code="no-untyped-def,no-untyped-call" # Copyright 2017 Elisey Zanko # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,11 +17,12 @@ from tenacity import RetryError, retry, stop_after_attempt from tenacity import tornadoweb -from tenacity.tests.test_tenacity import NoIOErrorAfterCount from tornado import gen from tornado import testing +from .test_tenacity import NoIOErrorAfterCount + @retry @gen.coroutine @@ -37,7 +38,7 @@ def _retryable_coroutine_with_2_attempts(thing): thing.go() -class TestTornado(testing.AsyncTestCase): +class TestTornado(testing.AsyncTestCase): # type: ignore[misc] @testing.gen_test def test_retry(self): assert gen.is_coroutine_function(_retryable_coroutine) @@ -67,9 +68,10 @@ def test_old_tornado(self): @retry def retryable(thing): pass + finally: gen.is_coroutine_function = old_attr -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..ec7b3ee5 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +import functools + +from tenacity import _utils + + +def test_is_coroutine_callable() -> None: + async def async_func() -> None: + pass + + def sync_func() -> None: + pass + + class AsyncClass: + async def __call__(self) -> None: + pass + + class SyncClass: + def __call__(self) -> None: + pass + + lambda_fn = lambda: None # noqa: E731 + + partial_async_func = functools.partial(async_func) + partial_sync_func = functools.partial(sync_func) + partial_async_class = functools.partial(AsyncClass().__call__) + partial_sync_class = functools.partial(SyncClass().__call__) + partial_lambda_fn = functools.partial(lambda_fn) + + assert _utils.is_coroutine_callable(async_func) is True + assert _utils.is_coroutine_callable(sync_func) is False + assert _utils.is_coroutine_callable(AsyncClass) is False + assert _utils.is_coroutine_callable(AsyncClass()) is True + assert _utils.is_coroutine_callable(SyncClass) is False + assert _utils.is_coroutine_callable(SyncClass()) is False + assert _utils.is_coroutine_callable(lambda_fn) is False + + assert _utils.is_coroutine_callable(partial_async_func) is True + assert _utils.is_coroutine_callable(partial_sync_func) is False + assert _utils.is_coroutine_callable(partial_async_class) is True + assert _utils.is_coroutine_callable(partial_sync_class) is False + assert _utils.is_coroutine_callable(partial_lambda_fn) is False diff --git a/tox.ini b/tox.ini index 88076793..e3641348 100644 --- a/tox.ini +++ b/tox.ini @@ -1,37 +1,36 @@ [tox] -envlist = py27, py35, py36, py37, py38, pep8, pypy +# we only test trio on latest python version +envlist = py3{9,10,11,12,13,14,14-trio}, pep8, pypy3 +skip_missing_interpreters = True [testenv] usedevelop = True sitepackages = False deps = + .[test] .[doc] - pytest - typeguard;python_version>='3.0' + trio: trio commands = - py{27,py}: pytest --ignore='tenacity/tests/test_asyncio.py' {posargs} - py3{5,6,7,8}: pytest {posargs} - py3{5,6,7,8}: sphinx-build -a -E -W -b doctest doc/source doc/build - py3{5,6,7,8}: sphinx-build -a -E -W -b html doc/source doc/build + py3{8,9,10,11,12,13,14},pypy3: pytest {posargs} + py3{8,9,10,11,12,13,14},pypy3: sphinx-build -a -E -W -b doctest doc/source doc/build + py3{8,9,10,11,12,13,14},pypy3: sphinx-build -a -E -W -b html doc/source doc/build [testenv:pep8] basepython = python3 -deps = flake8 - flake8-import-order - flake8-blind-except - flake8-builtins - flake8-docstrings - flake8-rst-docstrings - flake8-logging-format -commands = flake8 +deps = ruff +commands = + ruff check . {posargs} + ruff format --check . {posargs} + +[testenv:mypy] +deps = + mypy>=1.0.0 + pytest # for stubs + trio +commands = + mypy {posargs} [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