diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b80de7d..7f0acf7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -69,15 +69,20 @@ You will find the built documentation in `docs/_build/html`. ## Code - Obey [PEP 8] and [PEP 257]. - We use the `"""`-on-separate-lines style for docstrings with [Napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html)-style API documentation: + We use the `"""`-on-separate-lines style for docstrings with [Napoleon]-style API documentation: ```python - def func(x: str) -> str: + def func(x: str, y: int) -> str: """ Do something. Args: - x: A very important parameter. + x: A very important argument. + + y: + Another very important argument, but its description is so long + that it doesn't fit on one line. So we start the whole block on a + fresh new line to keep the block together. Returns: The result of doing something. @@ -140,6 +145,7 @@ You will find the built documentation in `docs/_build/html`. - Added `stamina.func()` that does foo. It's pretty cool. [#1](https://github.com/hynek/stamina/pull/1) + - `stamina.func()` now doesn't crash the Large Hadron Collider anymore. That was a nasty bug! [#2](https://github.com/hynek/stamina/pull/2) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 017610d..53b5d52 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,10 +2,10 @@ ## Supported Versions -We're following [*CalVer*](https://calver.org) with generous backwards-compatibility guarantees. -Therefore we only support the latest version. +We're following [Calendar Versioning](https://calver.org) with generous backwards-compatibility guarantees. +Therefore, we only support the latest version. -That said, you shouldn't be afraid to upgrade if you're only using our documented public APIs and pay attention to `DeprecationWarning`s. +That said, you shouldn't be afraid to upgrade if you only use our documented public APIs and pay attention to `DeprecationWarning`s. Whenever there is a need to break compatibility, it is announced in the changelog and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583d590..43a8b55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,13 +137,20 @@ jobs: allow-prereleases: true cache: pip - - name: Prepare & run Nox + - run: python -Im pip install nox "tomli; python_version<'3.11'" + + - name: Check using Mypy run: | - python -Im pip install nox "tomli; python_version<'3.11'" python -Im nox \ --python ${{ matrix.python-version }} \ --sessions mypy_api + - name: Check using Pyright + run: | + python -Im nox \ + --python ${{ matrix.python-version }} \ + --sessions pyright_api + mypy-pkg: name: Type-check package runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d918bd..49188e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ ci: repos: - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.11 + rev: v0.1.15 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index a136a24..83dfbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ +## [24.2.0](https://github.com/hynek/stamina/compare/24.1.0...24.2.0) - 2024-01-31 + +### Added + +- `stamina.RetryingCaller` and `stamina.AsyncRetryingCaller` that allow even easier retries of single callables: `stamina.RetryingCaller(attempts=5).on(ValueError)(do_something, "foo", bar=42)` and `stamina.RetryingCaller(attempts=5)(ValueError, do_something, "foo", bar=42)` will call `do_something("foo", bar=42)` and retry on `ValueError` up to 5 times. + + `stamina.RetryingCaller` and `stamina.AsyncRetryingCaller` take the same arguments as `stamina.retry()`, except for `on` that can be bound separately. + + [#56](https://github.com/hynek/stamina/pull/56) + [#57](https://github.com/hynek/stamina/pull/57) + + ## [24.1.0](https://github.com/hynek/stamina/compare/23.3.0...24.1.0) - 2024-01-03 ### Fixed diff --git a/docs/api.md b/docs/api.md index e138f7f..c2c91a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,6 +7,40 @@ .. autofunction:: retry_context .. autoclass:: Attempt :members: num +.. autoclass:: RetryingCaller + :members: on, __call__ + + For example:: + + def do_something_with_url(url, some_kw): + resp = httpx.get(url).raise_for_status() + ... + + rc = stamina.RetryingCaller(attempts=5) + + rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42) + + # Equivalent: + bound_rc = rc.on(httpx.HTTPError) + + bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42) + + Both calls to ``rc`` and ``bound_rc`` run + + .. code-block:: python + + do_something_with_url(f"https://httpbin.org/status/404", some_kw=42) + + and retry on ``httpx.HTTPError``. + +.. autoclass:: BoundRetryingCaller + :members: __call__ + +.. autoclass:: AsyncRetryingCaller + :members: on, __call__ + +.. autoclass:: BoundAsyncRetryingCaller + :members: __call__ ``` diff --git a/docs/conf.py b/docs/conf.py index 43943d9..ff5289f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,14 +54,17 @@ exclude_patterns = ["_build"] -nitpick_ignore = [("py:class", "httpx.HTTPError")] +nitpick_ignore = [ + ("py:class", "httpx.HTTPError"), + # ParamSpec is not well-supported. + ("py:obj", "typing.~P"), + ("py:class", "~P"), + ("py:class", "stamina._core.T"), +] # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). - # Move type hints into the description block, instead of the func definition. autodoc_typehints = "description" autodoc_typehints_description_target = "documented" @@ -101,7 +104,6 @@ # GitHub has rate limits linkcheck_ignore = [ r"https://github.com/.*/(issues|pull|compare)/\d+", - r"https://twitter.com/.*", ] intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} diff --git a/docs/tutorial.md b/docs/tutorial.md index 5879f06..1bc59c0 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -47,6 +47,36 @@ for attempt in stamina.retry_context(on=httpx.HTTPError): ``` +## Retry One Function or Method Call + +If you want to retry just one function or method call, *stamina* comes with an even easier way in the shape of {class}`stamina.RetryingCaller` and {class}`stamina.AsyncRetryingCaller`: + +```python +def do_something_with_url(url, some_kw): + resp = httpx.get(url) + resp.raise_for_status() + ... + +rc = stamina.RetryingCaller(attempts=5) + +rc(httpx.HTTPError, do_something_with_url, f"https://httpbin.org/status/404", some_kw=42) + +# You can also create a caller with a pre-bound exception type: +bound_rc = rc.on(httpx.HTTPError) + +bound_rc(do_something_with_url, f"https://httpbin.org/status/404", some_kw=42) +``` + +Both `rc` and `bound_rc` run: + +```python +do_something_with_url(f"https://httpbin.org/status/404", some_kw=42) +``` + +and retry on `httpx.HTTPError` and as before, the type hints are preserved. +It's up to you whether you want to share only the retry configuration or the exception type to retry on, too. + + ## Async Async works with the same functions and arguments for both [`asyncio`](https://docs.python.org/3/library/asyncio.html) and [Trio](https://trio.readthedocs.io/). diff --git a/noxfile.py b/noxfile.py index 3bb1a0d..2bacd67 100644 --- a/noxfile.py +++ b/noxfile.py @@ -18,7 +18,13 @@ import tomli as tomllib -nox.options.sessions = ["pre_commit", "tests", "mypy_api", "mypy_pkg"] +nox.options.sessions = [ + "pre_commit", + "tests", + "mypy_api", + "pyright_api", + "mypy_pkg", +] nox.options.reuse_existing_virtualenvs = True nox.options.error_on_external_run = True @@ -45,6 +51,13 @@ def mypy_api(session: nox.Session) -> None: session.run("mypy", "tests/typing") +@nox.session(python=ALL_SUPPORTED) +def pyright_api(session: nox.Session) -> None: + session.install(".[typing]", "pyright", "structlog", "prometheus-client") + + session.run("pyright", "tests/typing") + + @nox.session def mypy_pkg(session: nox.Session) -> None: session.install(".[typing]", "structlog", "prometheus-client") diff --git a/pyproject.toml b/pyproject.toml index 854b68a..bec35b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,12 +116,11 @@ source = ["src", ".nox/tests*/**/site-packages"] [tool.coverage.report] show_missing = true skip_covered = true -exclude_lines = [ - "no cov", - "if __name__ == .__main__.:", +exclude_also = [ + 'raise SystemError\("unreachable"\)', # Typing-related "if TYPE_CHECKING:", - "^ +\\.\\.\\.$", + ': \.\.\.$', ] diff --git a/src/stamina/__init__.py b/src/stamina/__init__.py index a672f8d..238dd75 100644 --- a/src/stamina/__init__.py +++ b/src/stamina/__init__.py @@ -4,16 +4,28 @@ from . import instrumentation from ._config import is_active, set_active -from ._core import Attempt, retry, retry_context +from ._core import ( + AsyncRetryingCaller, + Attempt, + BoundAsyncRetryingCaller, + BoundRetryingCaller, + RetryingCaller, + retry, + retry_context, +) __all__ = [ + "AsyncRetryingCaller", "Attempt", - "retry", - "retry_context", + "BoundAsyncRetryingCaller", + "BoundRetryingCaller", + "instrumentation", "is_active", + "retry_context", + "retry", + "RetryingCaller", "set_active", - "instrumentation", ] diff --git a/src/stamina/_config.py b/src/stamina/_config.py index 8456e9c..d010aba 100644 --- a/src/stamina/_config.py +++ b/src/stamina/_config.py @@ -23,9 +23,9 @@ class _Config: lock: Lock _is_active: bool - _on_retry: tuple[RetryHook, ...] | tuple[ - RetryHook | RetryHookFactory, ... - ] | None + _on_retry: ( + tuple[RetryHook, ...] | tuple[RetryHook | RetryHookFactory, ...] | None + ) _get_on_retry: Callable[[], tuple[RetryHook, ...]] def __init__(self, lock: Lock) -> None: diff --git a/src/stamina/_core.py b/src/stamina/_core.py index 4ab2d52..1284d29 100644 --- a/src/stamina/_core.py +++ b/src/stamina/_core.py @@ -12,7 +12,7 @@ from functools import wraps from inspect import iscoroutinefunction from types import TracebackType -from typing import AsyncIterator, Iterator, TypeVar +from typing import AsyncIterator, Awaitable, Iterator, TypedDict, TypeVar import tenacity as _t @@ -126,6 +126,222 @@ def __exit__( ) +class RetryKWs(TypedDict): + attempts: int | None + timeout: float | dt.timedelta | None + wait_initial: float | dt.timedelta + wait_max: float | dt.timedelta + wait_jitter: float | dt.timedelta + wait_exp_base: float + + +class BaseRetryingCaller: + """ + Simple base class that transforms retry parameters into a dictionary that + can be `**`-passed into `retry_context`. + """ + + __slots__ = ("_context_kws",) + + _context_kws: RetryKWs + + def __init__( + self, + attempts: int | None = 10, + timeout: float | dt.timedelta | None = 45.0, + wait_initial: float | dt.timedelta = 0.1, + wait_max: float | dt.timedelta = 5.0, + wait_jitter: float | dt.timedelta = 1.0, + wait_exp_base: float = 2.0, + ): + self._context_kws = { + "attempts": attempts, + "timeout": timeout, + "wait_initial": wait_initial, + "wait_max": wait_max, + "wait_jitter": wait_jitter, + "wait_exp_base": wait_exp_base, + } + + def __repr__(self) -> str: + kws = ", ".join( + f"{k}={self._context_kws[k]!r}" # type: ignore[literal-required] + for k in sorted(self._context_kws) + if k != "on" + ) + return f"<{self.__class__.__name__}({kws})>" + + +class RetryingCaller(BaseRetryingCaller): + """ + Call your callables with retries. + + Arguments have the same meaning as for :func:`stamina.retry`. + + Tip: + Instances of ``RetryingCaller`` may be reused because they internally + create a new :func:`retry_context` iterator on each call. + + .. versionadded:: 24.2.0 + """ + + def __call__( + self, + on: type[Exception] | tuple[type[Exception], ...], + callable_: Callable[P, T], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> T: + r""" + Call ``callable_(*args, **kw)`` with retries if *on* is raised. + + Args: + on: Exception(s) to retry on. + + callable\_: Callable to call. + + args: Positional arguments to pass to *callable_*. + + kw: Keyword arguments to pass to *callable_*. + """ + for attempt in retry_context(on, **self._context_kws): + with attempt: + return callable_(*args, **kwargs) + + raise SystemError("unreachable") # noqa: EM101 + + def on( + self, on: type[Exception] | tuple[type[Exception], ...], / + ) -> BoundRetryingCaller: + """ + Create a new instance of :class:`BoundRetryingCaller` with the same + parameters, but bound to a specific exception type. + + .. versionadded:: 24.2.0 + """ + # This should be a `functools.partial`, but unfortunately it's + # impossible to provide a nicely typed API with it, so we use a + # separate class. + return BoundRetryingCaller(self, on) + + +class BoundRetryingCaller: + """ + Same as :class:`RetryingCaller`, but pre-bound to a specific exception + type. + + Caution: + Returned by :meth:`RetryingCaller.on` -- do not instantiate directly. + + .. versionadded:: 24.2.0 + """ + + __slots__ = ("_caller", "_on") + + _caller: RetryingCaller + _on: type[Exception] | tuple[type[Exception], ...] + + def __init__( + self, + caller: RetryingCaller, + on: type[Exception] | tuple[type[Exception], ...], + ): + self._caller = caller + self._on = on + + def __repr__(self) -> str: + return ( + f"" + ) + + def __call__( + self, callable_: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs + ) -> T: + """ + Same as :func:`RetryingCaller.__call__`, except retry on the exception + that is bound to this instance. + """ + return self._caller(self._on, callable_, *args, **kwargs) + + +class AsyncRetryingCaller(BaseRetryingCaller): + """ + Same as :class:`RetryingCaller`, but for async callables. + + .. versionadded:: 24.2.0 + """ + + async def __call__( + self, + on: type[Exception] | tuple[type[Exception], ...], + callable_: Callable[P, Awaitable[T]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> T: + """ + Same as :meth:`RetryingCaller.__call__`, but *callable_* is awaited. + """ + async for attempt in retry_context(on, **self._context_kws): + with attempt: + return await callable_(*args, **kwargs) + + raise SystemError("unreachable") # noqa: EM101 + + def on( + self, on: type[Exception] | tuple[type[Exception], ...], / + ) -> BoundAsyncRetryingCaller: + """ + Create a new instance of :class:`BoundAsyncRetryingCaller` with the + same parameters, but bound to a specific exception type. + + .. versionadded:: 24.2.0 + """ + return BoundAsyncRetryingCaller(self, on) + + +class BoundAsyncRetryingCaller: + """ + Same as :class:`BoundRetryingCaller`, but for async callables. + + Caution: + Returned by :meth:`AsyncRetryingCaller.on` -- do not instantiate + directly. + + .. versionadded:: 24.2.0 + """ + + __slots__ = ("_caller", "_on") + + _caller: AsyncRetryingCaller + _on: type[Exception] | tuple[type[Exception], ...] + + def __init__( + self, + caller: AsyncRetryingCaller, + on: type[Exception] | tuple[type[Exception], ...], + ): + self._caller = caller + self._on = on + + def __repr__(self) -> str: + return f"" + + async def __call__( + self, + callable_: Callable[P, Awaitable[T]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> T: + """ + Same as :func:`AsyncRetryingCaller.__call__`, except retry on the + exception that is bound to this instance. + """ + return await self._caller(self._on, callable_, *args, **kwargs) + + _STOP_NO_RETRY = _t.stop_after_attempt(1) @@ -175,22 +391,30 @@ def from_params( _t_kw={ "retry": _t.retry_if_exception_type(on), "wait": _t.wait_exponential_jitter( - initial=wait_initial.total_seconds() - if isinstance(wait_initial, dt.timedelta) - else wait_initial, - max=wait_max.total_seconds() - if isinstance(wait_max, dt.timedelta) - else wait_max, + initial=( + wait_initial.total_seconds() + if isinstance(wait_initial, dt.timedelta) + else wait_initial + ), + max=( + wait_max.total_seconds() + if isinstance(wait_max, dt.timedelta) + else wait_max + ), exp_base=wait_exp_base, - jitter=wait_jitter.total_seconds() - if isinstance(wait_jitter, dt.timedelta) - else wait_jitter, + jitter=( + wait_jitter.total_seconds() + if isinstance(wait_jitter, dt.timedelta) + else wait_jitter + ), ), "stop": _make_stop( attempts=attempts, - timeout=timeout.total_seconds() - if isinstance(timeout, dt.timedelta) - else timeout, + timeout=( + timeout.total_seconds() + if isinstance(timeout, dt.timedelta) + else timeout + ), ), "reraise": True, }, diff --git a/src/stamina/instrumentation/_data.py b/src/stamina/instrumentation/_data.py index 92495b9..8497a7a 100644 --- a/src/stamina/instrumentation/_data.py +++ b/src/stamina/instrumentation/_data.py @@ -75,8 +75,7 @@ class RetryHook(Protocol): .. versionadded:: 23.2.0 """ - def __call__(self, details: RetryDetails) -> None: - ... + def __call__(self, details: RetryDetails) -> None: ... @dataclass(frozen=True) diff --git a/tests/test_async.py b/tests/test_async.py index 659cb39..78e2dbc 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -209,3 +209,62 @@ async def test_retry_blocks_can_be_disabled(): raise Exception("passed") assert 1 == num_called + + +class TestAsyncRetryingCaller: + async def test_ok(self): + """ + No error, no problem. + """ + arc = stamina.AsyncRetryingCaller().on(BaseException) + + async def f(): + return 42 + + assert 42 == await arc(f) + + async def test_retries(self): + """ + Retries if the specific error is raised. Arguments are passed through. + """ + i = 0 + + async def f(*args, **kw): + nonlocal i + if i < 1: + i += 1 + raise ValueError + + return args, kw + + arc = stamina.AsyncRetryingCaller().on(ValueError) + + args, kw = await arc(f, 42, foo="bar") + + assert 1 == i + assert (42,) == args + assert {"foo": "bar"} == kw + + def test_repr(self): + """ + repr() is useful + """ + arc = stamina.AsyncRetryingCaller( + attempts=42, + timeout=13.0, + wait_initial=23, + wait_max=123, + wait_jitter=0.42, + wait_exp_base=666, + ) + + r = repr(arc) + + assert ( + "" + ) == r + assert f"" == repr( + arc.on(ValueError) + ) diff --git a/tests/test_sync.py b/tests/test_sync.py index 67e5bd2..748e341 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -172,3 +172,62 @@ def test_never(self): If all conditions are None, return stop_never. """ assert tenacity.stop_never is _make_stop(attempts=None, timeout=None) + + +class TestRetryingCaller: + def test_ok(self): + """ + No error, no problem. + """ + rc = stamina.RetryingCaller().on(BaseException) + + def f(): + return 42 + + assert 42 == rc(f) + + def test_retries(self): + """ + Retries if the specific error is raised. Arguments are passed through. + """ + i = 0 + + def f(*args, **kw): + nonlocal i + if i < 1: + i += 1 + raise ValueError + + return args, kw + + bound_rc = stamina.RetryingCaller().on(ValueError) + + args, kw = bound_rc(f, 42, foo="bar") + + assert 1 == i + assert (42,) == args + assert {"foo": "bar"} == kw + + def test_repr(self): + """ + repr() is useful. + """ + rc = stamina.RetryingCaller( + attempts=42, + timeout=13.0, + wait_initial=23, + wait_max=123, + wait_jitter=0.42, + wait_exp_base=666, + ) + + r = repr(rc) + + assert ( + "" + ) == r + assert f"" == repr( + rc.on(ValueError) + ) diff --git a/tests/typing/api.py b/tests/typing/api.py index 610f85c..8a9550b 100644 --- a/tests/typing/api.py +++ b/tests/typing/api.py @@ -12,6 +12,10 @@ import datetime as dt from stamina import ( + AsyncRetryingCaller, + BoundAsyncRetryingCaller, + BoundRetryingCaller, + RetryingCaller, is_active, retry, retry_context, @@ -27,33 +31,27 @@ @retry(on=ValueError) -def just_exc() -> None: - ... +def just_exc() -> None: ... @retry(on=TypeError) -async def just_exc_async() -> None: - ... +async def just_exc_async() -> None: ... @retry(on=TypeError, timeout=13.0) -def exc_timeout() -> None: - ... +def exc_timeout() -> None: ... @retry(on=TypeError, timeout=dt.timedelta(seconds=13.0)) -def exc_timeout_timedelta() -> None: - ... +def exc_timeout_timedelta() -> None: ... @retry(on=TypeError, timeout=13.0, attempts=10) -def exc_timeout_attempts() -> None: - ... +def exc_timeout_attempts() -> None: ... @retry(on=TypeError, timeout=None, attempts=None) -def exc_timeout_attempts_none() -> None: - ... +def exc_timeout_attempts_none() -> None: ... @retry( @@ -63,8 +61,7 @@ def exc_timeout_attempts_none() -> None: wait_jitter=1.0, wait_exp_base=2.0, ) -def exc_tune_waiting() -> None: - ... +def exc_tune_waiting() -> None: ... @retry( @@ -74,8 +71,7 @@ def exc_tune_waiting() -> None: wait_jitter=3, wait_exp_base=4, ) -def exc_tune_waiting_ints() -> None: - ... +def exc_tune_waiting_ints() -> None: ... one_sec = dt.timedelta(seconds=1.0) @@ -88,8 +84,7 @@ def exc_tune_waiting_ints() -> None: wait_max=one_sec, wait_jitter=one_sec, ) -def exc_tune_waiting_timedelta() -> None: - ... +def exc_tune_waiting_timedelta() -> None: ... set_active(False) @@ -134,3 +129,30 @@ async def f() -> None: ): with attempt: pass + + +def sync_f(x: int, foo: str) -> bool: + return True + + +rc = RetryingCaller(timeout=13.0, attempts=10) + +bound_rc: BoundRetryingCaller = rc.on(ValueError) + +b: bool = rc(ValueError, sync_f, 1, foo="bar") +b = bound_rc(sync_f, 1, foo="bar") + + +async def async_f(x: int, foo: str) -> bool: + return True + + +arc = AsyncRetryingCaller(timeout=13.0, attempts=10) +bound_arc: BoundAsyncRetryingCaller = arc.on(ValueError) + + +async def g() -> bool: + global b # noqa: PLW0603 + + b = await arc(KeyError, async_f, 1, foo="bar") + return await bound_arc(async_f, 1, foo="bar")