Skip to content

Commit

Permalink
Add Trio support (#43)
Browse files Browse the repository at this point in the history
* Add Trio support

Ref #42

* Add versionadded tags

* Add PR#

* Use consistently bare Trio URL

* Mention Trio in tutorial

* Be smart
  • Loading branch information
hynek authored Dec 5, 2023
1 parent 4267f46 commit d9f7748
Show file tree
Hide file tree
Showing 10 changed files with 61 additions and 6 deletions.
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

0 comments on commit d9f7748

Please sign in to comment.