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