Skip to content

Commit 5525861

Browse files
hynekTinche
andauthored
Hide Tenacity's AttemptManager from the users (#22)
* Hide Tenacity's AttemptManager from the users * Add PR link * Instantiate no-retry AsyncRetrying lazily * Save some lookups * Fix type * Clarify * This fits now * Better wording * Expose the number of the current attempt * Stop vendoring Co-authored-by: Tin Tvrtković <[email protected]> --------- Co-authored-by: Tin Tvrtković <[email protected]>
1 parent 3cafcfb commit 5525861

File tree

7 files changed

+141
-38
lines changed

7 files changed

+141
-38
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
1515

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

18+
### Changed
19+
20+
- Tenacity's internal `AttemptManager` object is no longer exposed to the user.
21+
This was an oversight and never documented.
22+
`stamina.retry_context()` now yields instances of `stamina.Attempt`.
23+
[#22](https://github.com/hynek/stamina/pull/22)
24+
1825

1926
## [23.1.0](https://github.com/hynek/stamina/compare/22.2.0...23.1.0) - 2023-07-04
2027

docs/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ API Reference
55

66
.. autofunction:: retry
77
.. autofunction:: retry_context
8+
.. autoclass:: Attempt
9+
:members: num
810

911

1012
Configuration

src/stamina/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
# SPDX-License-Identifier: MIT
44

55
from ._config import is_active, set_active
6-
from ._core import retry, retry_context
6+
from ._core import Attempt, retry, retry_context
77
from ._instrumentation import RETRY_COUNTER
88

99

1010
__all__ = [
11+
"Attempt",
1112
"retry",
1213
"retry_context",
1314
"is_active",

src/stamina/_core.py

Lines changed: 86 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@
1111
from dataclasses import dataclass, replace
1212
from functools import wraps
1313
from inspect import iscoroutinefunction
14-
from typing import (
15-
AsyncIterator,
16-
Iterable,
17-
Iterator,
18-
TypeVar,
19-
)
14+
from types import TracebackType
15+
from typing import AsyncIterator, Iterable, Iterator, TypeVar
2016

2117
import tenacity as _t
2218

@@ -68,10 +64,59 @@ def retry_context(
6864
)
6965

7066

67+
@dataclass
68+
class Attempt:
69+
"""
70+
A context manager that can be used to retry code blocks.
71+
72+
Instances are yielded by the :func:`stamina.retry_context` iterator.
73+
74+
.. versionadded:: 23.2.0
75+
"""
76+
77+
_t_attempt: _t.AttemptManager
78+
79+
@property
80+
def num(self) -> int:
81+
"""
82+
The number of the current attempt.
83+
"""
84+
return self._t_attempt.retry_state.attempt_number # type: ignore[no-any-return]
85+
86+
def __enter__(self) -> None:
87+
return self._t_attempt.__enter__() # type: ignore[no-any-return]
88+
89+
def __exit__(
90+
self,
91+
exc_type: type[BaseException] | None,
92+
exc_value: BaseException | None,
93+
traceback: TracebackType | None,
94+
) -> bool | None:
95+
return self._t_attempt.__exit__( # type: ignore[no-any-return]
96+
exc_type, exc_value, traceback
97+
)
98+
99+
100+
_STOP_NO_RETRY = _t.stop_after_attempt(1)
101+
102+
103+
class _LazyNoAsyncRetry:
104+
"""
105+
Allows us a free null object pattern using non-retries and avoid None.
106+
"""
107+
108+
def __aiter__(self) -> _t.AsyncRetrying:
109+
return _t.AsyncRetrying(reraise=True, stop=_STOP_NO_RETRY).__aiter__()
110+
111+
112+
_LAZY_NO_ASYNC_RETRY = _LazyNoAsyncRetry()
113+
114+
71115
@dataclass
72116
class _RetryContextIterator:
73-
__slots__ = ("_tenacity_kw", "_name", "_args", "_kw")
74-
_tenacity_kw: dict[str, object]
117+
__slots__ = ("_t_kw", "_t_a_retrying", "_name", "_args", "_kw")
118+
_t_kw: dict[str, object]
119+
_t_a_retrying: _t.AsyncRetrying
75120
_name: str
76121
_args: tuple[object, ...]
77122
_kw: dict[str, object]
@@ -94,7 +139,7 @@ def from_params(
94139
_name=name,
95140
_args=args,
96141
_kw=kw,
97-
_tenacity_kw={
142+
_t_kw={
98143
"retry": _t.retry_if_exception_type(on),
99144
"wait": _t.wait_exponential_jitter(
100145
initial=wait_initial.total_seconds()
@@ -116,45 +161,51 @@ def from_params(
116161
),
117162
"reraise": True,
118163
},
164+
_t_a_retrying=_LAZY_NO_ASYNC_RETRY,
119165
)
120166

121-
_STOP_NO_RETRY = _t.stop_after_attempt(1)
167+
def with_name(
168+
self, name: str, args: tuple[object, ...], kw: dict[str, object]
169+
) -> _RetryContextIterator:
170+
"""
171+
Recreate ourselves with a new name and arguments.
172+
"""
173+
return replace(self, _name=name, _args=args, _kw=kw)
122174

123-
def __iter__(self) -> Iterator[_t.AttemptManager]:
175+
def __iter__(self) -> Iterator[Attempt]:
124176
if not _CONFIG.is_active:
125-
yield from _t.Retrying(reraise=True, stop=self._STOP_NO_RETRY)
177+
for r in _t.Retrying(
178+
reraise=True, stop=_STOP_NO_RETRY
179+
): # pragma: no cover -- it's always once + GeneratorExit
180+
yield Attempt(r)
126181

127-
yield from _t.Retrying(
182+
for r in _t.Retrying(
128183
before_sleep=_make_before_sleep(
129184
self._name, _CONFIG.on_retry, self._args, self._kw
130185
)
131186
if _CONFIG.on_retry
132187
else None,
133-
**self._tenacity_kw,
134-
)
188+
**self._t_kw,
189+
):
190+
yield Attempt(r)
191+
192+
def __aiter__(self) -> AsyncIterator[Attempt]:
193+
if _CONFIG.is_active:
194+
self._t_a_retrying = _t.AsyncRetrying(
195+
before_sleep=_make_before_sleep(
196+
self._name, _CONFIG.on_retry, self._args, self._kw
197+
)
198+
if _CONFIG.on_retry
199+
else None,
200+
**self._t_kw,
201+
)
135202

136-
def __aiter__(self) -> AsyncIterator[_t.AttemptManager]:
137-
if not _CONFIG.is_active:
138-
return _t.AsyncRetrying( # type: ignore[no-any-return]
139-
reraise=True, stop=self._STOP_NO_RETRY
140-
).__aiter__()
203+
self._t_a_retrying = self._t_a_retrying.__aiter__()
141204

142-
return _t.AsyncRetrying( # type: ignore[no-any-return]
143-
before_sleep=_make_before_sleep(
144-
self._name, _CONFIG.on_retry, self._args, self._kw
145-
)
146-
if _CONFIG.on_retry
147-
else None,
148-
**self._tenacity_kw,
149-
).__aiter__()
205+
return self
150206

151-
def with_name(
152-
self, name: str, args: tuple[object, ...], kw: dict[str, object]
153-
) -> _RetryContextIterator:
154-
"""
155-
Recreate ourselves with a new name and arguments.
156-
"""
157-
return replace(self, _name=name, _args=args, _kw=kw)
207+
async def __anext__(self) -> Attempt:
208+
return Attempt(await self._t_a_retrying.__anext__())
158209

159210

160211
def _make_before_sleep(

tests/test_async.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,24 @@ async def f():
124124
assert 1 == num_called
125125

126126

127+
async def test_retry_inactive_ok():
128+
"""
129+
If inactive, the happy path still works.
130+
"""
131+
num_called = 0
132+
133+
@stamina.retry(on=Exception)
134+
async def f():
135+
nonlocal num_called
136+
num_called += 1
137+
138+
stamina.set_active(False)
139+
140+
await f()
141+
142+
assert 1 == num_called
143+
144+
127145
async def test_retry_block():
128146
"""
129147
Async retry_context blocks are retried.
@@ -133,6 +151,9 @@ async def test_retry_block():
133151
async for attempt in stamina.retry_context(on=ValueError, wait_max=0):
134152
with attempt:
135153
num_called += 1
154+
155+
assert num_called == attempt.num
156+
136157
if num_called < 2:
137158
raise ValueError
138159

tests/test_sync.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ def f():
9797
assert 1 == num_called
9898

9999

100+
def test_retry_inactive_ok():
101+
"""
102+
If inactive, the happy path still works.
103+
"""
104+
num_called = 0
105+
106+
@stamina.retry(on=Exception)
107+
def f():
108+
nonlocal num_called
109+
num_called += 1
110+
111+
stamina.set_active(False)
112+
113+
f()
114+
115+
assert 1 == num_called
116+
117+
100118
def test_retry_block():
101119
"""
102120
Sync retry_context blocks are retried.
@@ -106,6 +124,9 @@ def test_retry_block():
106124
for attempt in stamina.retry_context(on=ValueError, wait_max=0):
107125
with attempt:
108126
i += 1
127+
128+
assert i == attempt.num
129+
109130
if i < 2:
110131
raise ValueError
111132

tests/typing/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def hook(
9898

9999
for attempt in retry_context(on=ValueError, timeout=13):
100100
with attempt:
101-
...
101+
x: int = attempt.num
102102

103103
for attempt in retry_context(
104104
on=ValueError, timeout=dt.timedelta(seconds=13.0)
@@ -116,4 +116,4 @@ async def f() -> None:
116116
wait_jitter=one_sec,
117117
):
118118
with attempt:
119-
...
119+
pass

0 commit comments

Comments
 (0)