Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Trio support #43

Merged
merged 6 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

## [Unreleased](https://github.com/hynek/stamina/compare/23.2.0...HEAD)

### Added

- [Trio](https://trio.readthedocs.io/) support!
[#43](https://github.com/hynek/stamina/pull/43)


## [23.2.0](https://github.com/hynek/stamina/compare/23.1.0...23.2.0) - 2023-10-30

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ It is the result of years of copy-pasting the same configuration over and over a
- Retry only on certain exceptions.
- Exponential **backoff** with **jitter** between retries.
- Limit the number of retries **and** total time.
- Automatic **async** support.
- Automatic **async** support – including [Trio](https://trio.readthedocs.io/).
- Preserve **type hints** of the decorated callable.
- Flexible **instrumentation** with [Prometheus](https://github.com/prometheus/client_python), [*structlog*](https://www.structlog.org/), and standard library's `logging` support out-of-the-box.
- Easy _global_ deactivation for testing.
Expand Down
3 changes: 2 additions & 1 deletion docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ for attempt in stamina.retry_context(on=httpx.HTTPError):

## Async

Async works with the same functions and arguments -- you just have to use async functions and `async for`:
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/).
Just use async functions and `async for`:

```python
import datetime as dt
Expand Down
4 changes: 3 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ def _get_pkg(posargs: list[str]) -> tuple[str, list[str]]:


@nox.session(python=ALL_SUPPORTED)
@nox.parametrize("opt_deps", [[], ["structlog", "prometheus-client"]])
@nox.parametrize(
"opt_deps", [[], ["structlog", "prometheus-client"], ["trio"]]
)
def tests(session: nox.Session, opt_deps: list[str]) -> None:
pkg, posargs = _get_pkg(session.posargs)

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
dependencies = ["tenacity", "typing-extensions; python_version<'3.10'"]

[project.optional-dependencies]
tests = ["pytest", "pytest-asyncio!=0.22.0"]
tests = ["pytest", "anyio"]
typing = ["mypy>=1.4"]
docs = [
"sphinx>=7.2.2",
Expand All @@ -41,6 +41,7 @@ dev = [
"tomli; python_version<'3.11'",
"structlog",
"prometheus-client",
"trio",
]

[project.urls]
Expand Down Expand Up @@ -100,7 +101,6 @@ addopts = ["-ra", "--strict-markers", "--strict-config"]
testpaths = "tests"
xfail_strict = true
filterwarnings = ["once::Warning"]
asyncio_mode = "auto"


[tool.coverage.run]
Expand Down
31 changes: 30 additions & 1 deletion src/stamina/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@
else:
from typing_extensions import ParamSpec

try:
from sniffio import current_async_library
except ImportError: # pragma: no cover -- we always have sniffio in tests

def current_async_library() -> str:
return "asyncio"


async def _smart_sleep(delay: float) -> None:
io = current_async_library()

if io == "asyncio":
import asyncio

await asyncio.sleep(delay)
elif io == "trio":
import trio

await trio.sleep(delay)
else: # pragma: no cover
msg = f"Unknown async library: {io!r}"
raise RuntimeError(msg)


T = TypeVar("T")
P = ParamSpec("P")
Expand All @@ -46,6 +69,7 @@ def retry_context(
Arguments have the same meaning as for :func:`stamina.retry`.

.. versionadded:: 23.1.0
.. versionadded:: 23.3.0 `Trio <https://trio.readthedocs.io/>`_ support.
"""

return _RetryContextIterator.from_params(
Expand Down Expand Up @@ -113,7 +137,9 @@ class _LazyNoAsyncRetry:
__slots__ = ()

def __aiter__(self) -> _t.AsyncRetrying:
return _t.AsyncRetrying(reraise=True, stop=_STOP_NO_RETRY).__aiter__()
return _t.AsyncRetrying(
reraise=True, stop=_STOP_NO_RETRY, sleep=_smart_sleep
).__aiter__()


_LAZY_NO_ASYNC_RETRY = _LazyNoAsyncRetry()
Expand Down Expand Up @@ -197,6 +223,7 @@ def __iter__(self) -> Iterator[Attempt]:
def __aiter__(self) -> AsyncIterator[Attempt]:
if CONFIG.is_active:
self._t_a_retrying = _t.AsyncRetrying(
sleep=_smart_sleep,
before_sleep=_make_before_sleep(
self._name, CONFIG, self._args, self._kw
),
Expand Down Expand Up @@ -323,6 +350,8 @@ def retry(

All time-related parameters can now be specified as a
:class:`datetime.timedelta`.

.. versionadded:: 23.3.0 `Trio <https://trio.readthedocs.io/>`_ support.
"""
retry_ctx = _RetryContextIterator.from_params(
on=on,
Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#
# SPDX-License-Identifier: MIT

import importlib.util

import pytest

import stamina._config
Expand All @@ -14,3 +16,13 @@ def _reset_config():
"""
stamina.set_active(True)
stamina.instrumentation.set_on_retry_hooks(None)


BACKENDS = [pytest.param(("asyncio", {}), id="asyncio")]
if importlib.util.find_spec("trio"):
BACKENDS += [pytest.param(("trio", {}), id="trio")]


@pytest.fixture(params=BACKENDS)
def anyio_backend(request):
return request.param
3 changes: 3 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import stamina


pytestmark = pytest.mark.anyio


@pytest.mark.parametrize("attempts", [None, 1])
@pytest.mark.parametrize("timeout", [None, 1, dt.timedelta(days=1)])
@pytest.mark.parametrize("duration", [1, dt.timedelta(days=1)])
Expand Down
1 change: 1 addition & 0 deletions tests/test_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def f():
("stamina", 30, "stamina.retry_scheduled")
] == caplog.record_tuples

@pytest.mark.anyio()
async def test_async(self, caplog):
"""
Async retries are logged.
Expand Down
2 changes: 2 additions & 0 deletions tests/test_structlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def f():
] == log_output


@pytest.mark.anyio()
async def test_decorator_async(log_output):
"""
Retries decorators log correct name / arguments.
Expand Down Expand Up @@ -97,6 +98,7 @@ def test_context_sync(log_output):
] == log_output


@pytest.mark.anyio()
async def test_context_async(log_output):
"""
Retries context blocks log correct name / arguments.
Expand Down