python-backoff 2.3.1__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {python_backoff-2.3.1 → python_backoff-3.0.0}/.gitignore +3 -1
  2. {python_backoff-2.3.1 → python_backoff-3.0.0}/PKG-INFO +4 -7
  3. {python_backoff-2.3.1 → python_backoff-3.0.0}/README.rst +1 -1
  4. {python_backoff-2.3.1 → python_backoff-3.0.0}/backoff/__init__.py +11 -7
  5. python_backoff-3.0.0/backoff/_async.py +310 -0
  6. python_backoff-3.0.0/backoff/_common.py +268 -0
  7. python_backoff-3.0.0/backoff/_decorator.py +442 -0
  8. python_backoff-3.0.0/backoff/_sync.py +240 -0
  9. python_backoff-3.0.0/backoff/_typing.py +58 -0
  10. {python_backoff-2.3.1 → python_backoff-3.0.0}/backoff/_wait_gen.py +58 -39
  11. python_backoff-3.0.0/backoff/types.py +6 -0
  12. python_backoff-3.0.0/pyproject.toml +284 -0
  13. python_backoff-3.0.0/tests/common.py +43 -0
  14. python_backoff-3.0.0/tests/conftest.py +10 -0
  15. python_backoff-3.0.0/tests/test_backoff.py +1055 -0
  16. python_backoff-3.0.0/tests/test_backoff_async.py +824 -0
  17. {python_backoff-2.3.1 → python_backoff-3.0.0}/tests/test_integration.py +10 -10
  18. {python_backoff-2.3.1 → python_backoff-3.0.0}/tests/test_jitter.py +2 -2
  19. {python_backoff-2.3.1 → python_backoff-3.0.0}/tests/test_package.py +9 -10
  20. python_backoff-3.0.0/tests/test_retry_context.py +417 -0
  21. python_backoff-3.0.0/tests/test_types.py +4 -0
  22. {python_backoff-2.3.1 → python_backoff-3.0.0}/tests/test_wait_gen.py +30 -20
  23. python_backoff-3.0.0/tests/typing/__init__.py +0 -0
  24. python_backoff-2.3.1/tests/test_typing.py → python_backoff-3.0.0/tests/typing/basic.py +3 -4
  25. python_backoff-3.0.0/tests/typing/decorators.py +54 -0
  26. python_backoff-3.0.0/tests/typing/pyrefly.py +21 -0
  27. python_backoff-2.3.1/backoff/_async.py +0 -201
  28. python_backoff-2.3.1/backoff/_common.py +0 -121
  29. python_backoff-2.3.1/backoff/_decorator.py +0 -227
  30. python_backoff-2.3.1/backoff/_sync.py +0 -144
  31. python_backoff-2.3.1/backoff/_typing.py +0 -43
  32. python_backoff-2.3.1/backoff/types.py +0 -5
  33. python_backoff-2.3.1/pyproject.toml +0 -208
  34. python_backoff-2.3.1/tests/common.py +0 -23
  35. python_backoff-2.3.1/tests/test_backoff.py +0 -949
  36. python_backoff-2.3.1/tests/test_backoff_async.py +0 -712
  37. python_backoff-2.3.1/tests/test_types.py +0 -4
  38. {python_backoff-2.3.1 → python_backoff-3.0.0}/LICENSE +0 -0
  39. {python_backoff-2.3.1 → python_backoff-3.0.0}/backoff/_jitter.py +0 -0
  40. {python_backoff-2.3.1 → python_backoff-3.0.0}/backoff/py.typed +0 -0
  41. {python_backoff-2.3.1 → python_backoff-3.0.0}/tests/__init__.py +0 -0
@@ -1,6 +1,8 @@
1
+ uv.lock
2
+
1
3
  __pycache__/
2
4
  *.pyc
3
- .coverage
5
+ .coverage*
4
6
  virtualenv
5
7
  MANIFEST
6
8
  build/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-backoff
3
- Version: 2.3.1
3
+ Version: 3.0.0
4
4
  Summary: Function decoration for backoff and retry
5
5
  Project-URL: Documentation, https://python-backoff.github.io/backoff
6
6
  Project-URL: Repository, https://github.com/python-backoff/backoff
@@ -11,13 +11,10 @@ License-File: LICENSE
11
11
  Keywords: backoff,decorators,retry
12
12
  Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Classifier: Natural Language :: English
16
15
  Classifier: Operating System :: OS Independent
17
16
  Classifier: Programming Language :: Python
18
17
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.8
20
- Classifier: Programming Language :: Python :: 3.9
21
18
  Classifier: Programming Language :: Python :: 3.10
22
19
  Classifier: Programming Language :: Python :: 3.11
23
20
  Classifier: Programming Language :: Python :: 3.12
@@ -27,7 +24,7 @@ Classifier: Programming Language :: Python :: 3.15
27
24
  Classifier: Topic :: Internet :: WWW/HTTP
28
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
26
  Classifier: Topic :: Utilities
30
- Requires-Python: >=3.8
27
+ Requires-Python: >=3.10
31
28
  Description-Content-Type: text/x-rst
32
29
 
33
30
  backoff
@@ -406,7 +403,7 @@ asynchronous HTTP client/server library.
406
403
  max_time=60,
407
404
  )
408
405
  async def get_url(url):
409
- async with aiohttp.ClientSession(raise_for_status=True) as session: # noqa: SIM117
406
+ async with aiohttp.ClientSession(raise_for_status=True) as session: # ruff:ignore[multiple-with-statements]
410
407
  async with session.get(url) as response:
411
408
  return await response.text()
412
409
 
@@ -374,7 +374,7 @@ asynchronous HTTP client/server library.
374
374
  max_time=60,
375
375
  )
376
376
  async def get_url(url):
377
- async with aiohttp.ClientSession(raise_for_status=True) as session: # noqa: SIM117
377
+ async with aiohttp.ClientSession(raise_for_status=True) as session: # ruff:ignore[multiple-with-statements]
378
378
  async with session.get(url) as response:
379
379
  return await response.text()
380
380
 
@@ -12,20 +12,24 @@ For examples and full documentation see the README at
12
12
  https://github.com/python-backoff/backoff
13
13
  """
14
14
 
15
- from backoff._decorator import on_exception, on_predicate
15
+ from backoff._common import Attempt
16
+ from backoff._decorator import aretry_context, on_exception, on_predicate, retry_context
16
17
  from backoff._jitter import full_jitter, random_jitter
17
- from backoff._wait_gen import constant, expo, fibo, runtime, decay
18
+ from backoff._wait_gen import constant, decay, expo, fibo, runtime
18
19
 
19
20
  __all__ = [
20
- "on_predicate",
21
- "on_exception",
21
+ "Attempt",
22
+ "aretry_context",
22
23
  "constant",
23
- "expo",
24
24
  "decay",
25
+ "expo",
25
26
  "fibo",
26
- "runtime",
27
27
  "full_jitter",
28
+ "on_exception",
29
+ "on_predicate",
28
30
  "random_jitter",
31
+ "retry_context",
32
+ "runtime",
29
33
  ]
30
34
 
31
- __version__ = "2.3.1"
35
+ __version__ = "3.0.0"
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import functools
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
+
9
+ from backoff._common import Attempt, _RetryState
10
+
11
+ if TYPE_CHECKING:
12
+ import sys
13
+ from collections.abc import AsyncGenerator, Coroutine, Iterable
14
+ from typing import ParamSpec
15
+
16
+ from backoff._typing import (
17
+ ContextDetails,
18
+ Details,
19
+ _BaseDetails,
20
+ _CallDetails,
21
+ _ContextHandler,
22
+ _Handler,
23
+ _Jitterer,
24
+ _MaybeCallable,
25
+ _MaybeTuple,
26
+ _Predicate,
27
+ _WaitGenerator,
28
+ )
29
+
30
+ if sys.version_info >= (3, 11):
31
+ from typing import Unpack
32
+ else:
33
+ from typing_extensions import Unpack
34
+
35
+ T = TypeVar("T")
36
+ P = ParamSpec("P")
37
+
38
+ _AsyncHandler = Callable[["Details"], "Coroutine[Any, Any, None]"]
39
+
40
+
41
+ def _ensure_coroutine(
42
+ coro_or_func: Callable[..., Any],
43
+ ) -> Callable[..., Coroutine[Any, Any, Any]]:
44
+ if inspect.iscoroutinefunction(coro_or_func):
45
+ return coro_or_func
46
+
47
+ @functools.wraps(coro_or_func)
48
+ async def f(*args: Any, **kwargs: Any) -> Any: # ruff:ignore[unused-async]
49
+ return coro_or_func(*args, **kwargs)
50
+
51
+ return f
52
+
53
+
54
+ def _ensure_coroutines(
55
+ coros_or_funcs: Iterable[Callable[..., Any]],
56
+ ) -> list[Callable[..., Coroutine[Any, Any, Any]]]:
57
+ return [_ensure_coroutine(f) for f in coros_or_funcs]
58
+
59
+
60
+ async def _call_handlers(
61
+ handlers: Iterable[_AsyncHandler],
62
+ *,
63
+ target: Callable[..., Any],
64
+ args: tuple[Any, ...],
65
+ kwargs: dict[str, Any],
66
+ tries: int,
67
+ elapsed: float,
68
+ **extra: Unpack[_CallDetails],
69
+ ) -> None:
70
+ details: Details = {
71
+ "target": target,
72
+ "args": args,
73
+ "kwargs": kwargs,
74
+ "tries": tries,
75
+ "elapsed": elapsed,
76
+ **extra,
77
+ }
78
+ for handler in handlers:
79
+ await handler(details)
80
+
81
+
82
+ def retry_predicate(
83
+ target: Callable[P, Coroutine[object, object, T]],
84
+ wait_gen: _WaitGenerator,
85
+ predicate: _Predicate[T],
86
+ *,
87
+ max_tries: _MaybeCallable[int] | None,
88
+ max_time: _MaybeCallable[float] | None,
89
+ jitter: _Jitterer | None,
90
+ on_try: Iterable[_Handler],
91
+ on_success: Iterable[_Handler],
92
+ on_backoff: Iterable[_Handler],
93
+ on_giveup: Iterable[_Handler],
94
+ wait_gen_kwargs: dict[str, Any],
95
+ ) -> Callable[P, Coroutine[object, object, T]]:
96
+ on_try = _ensure_coroutines(on_try)
97
+ on_success = _ensure_coroutines(on_success)
98
+ on_backoff = _ensure_coroutines(on_backoff)
99
+ on_giveup = _ensure_coroutines(on_giveup)
100
+
101
+ # Easy to implement, please report if you need this.
102
+ assert not inspect.iscoroutinefunction(max_tries)
103
+ assert not inspect.iscoroutinefunction(jitter)
104
+
105
+ assert inspect.iscoroutinefunction(target)
106
+
107
+ @functools.wraps(target)
108
+ async def retry(*args: P.args, **kwargs: P.kwargs) -> T:
109
+ state = _RetryState(
110
+ wait_gen,
111
+ wait_gen_kwargs,
112
+ max_tries=max_tries,
113
+ max_time=max_time,
114
+ )
115
+ while True:
116
+ state.start_attempt()
117
+ details: _BaseDetails = {
118
+ "target": target,
119
+ "args": args,
120
+ "kwargs": kwargs,
121
+ "tries": state.tries,
122
+ "elapsed": state.elapsed,
123
+ }
124
+ await _call_handlers(on_try, **details)
125
+ ret = await target(*args, **kwargs)
126
+ details["elapsed"] = state.record_elapsed()
127
+
128
+ if predicate(ret):
129
+ if state.exhausted():
130
+ await _call_handlers(on_giveup, **details, value=ret)
131
+ break
132
+
133
+ try:
134
+ seconds = state.next_wait(ret, jitter)
135
+ except StopIteration:
136
+ await _call_handlers(on_giveup, **details, value=ret)
137
+ break
138
+
139
+ await _call_handlers(on_backoff, **details, value=ret, wait=seconds)
140
+
141
+ # Note: there is no convenient way to pass explicit event
142
+ # loop to decorator, so here we assume that either default
143
+ # thread event loop is set and correct (it mostly is
144
+ # by default), or Python >= 3.5.3 or Python >= 3.6 is used
145
+ # where loop.get_event_loop() in coroutine guaranteed to
146
+ # return correct value.
147
+ # See for details:
148
+ # <https://groups.google.com/forum/#!topic/python-tulip/yF9C-rFpiKk>
149
+ # <https://bugs.python.org/issue28613>
150
+ await asyncio.sleep(seconds)
151
+ continue
152
+ await _call_handlers(on_success, **details, value=ret)
153
+ break
154
+
155
+ return ret
156
+
157
+ return retry
158
+
159
+
160
+ def _adapt_context_handlers(
161
+ handlers: Iterable[_AsyncHandler],
162
+ target: Callable[..., Any],
163
+ args: tuple[Any, ...],
164
+ kwargs: dict[str, Any],
165
+ ) -> list[_ContextHandler]:
166
+ async def adapted(details: ContextDetails) -> None:
167
+ full_details: Details = {
168
+ "target": target,
169
+ "args": args,
170
+ "kwargs": kwargs,
171
+ **details,
172
+ }
173
+ for hdlr in handlers:
174
+ await hdlr(full_details)
175
+
176
+ return [adapted]
177
+
178
+
179
+ def retry_exception(
180
+ target: Callable[P, Coroutine[object, object, T]],
181
+ wait_gen: _WaitGenerator,
182
+ exception: _MaybeTuple[type[Exception]],
183
+ *,
184
+ max_tries: _MaybeCallable[int] | None,
185
+ max_time: _MaybeCallable[float] | None,
186
+ jitter: _Jitterer | None,
187
+ giveup: _Predicate[Exception],
188
+ on_try: Iterable[_Handler],
189
+ on_success: Iterable[_Handler],
190
+ on_backoff: Iterable[_Handler],
191
+ on_giveup: Iterable[_Handler],
192
+ raise_on_giveup: bool,
193
+ wait_gen_kwargs: dict[str, Any],
194
+ ) -> Callable[P, Coroutine[object, object, T]]:
195
+ on_try = _ensure_coroutines(on_try)
196
+ on_success = _ensure_coroutines(on_success)
197
+ on_backoff = _ensure_coroutines(on_backoff)
198
+ on_giveup = _ensure_coroutines(on_giveup)
199
+ giveup = _ensure_coroutine(giveup)
200
+
201
+ # Easy to implement, please report if you need this.
202
+ assert not inspect.iscoroutinefunction(max_tries)
203
+ assert not inspect.iscoroutinefunction(jitter)
204
+
205
+ @functools.wraps(target)
206
+ async def retry(
207
+ *args: P.args,
208
+ **kwargs: P.kwargs,
209
+ ) -> T:
210
+ ret: T = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
211
+
212
+ async for attempt in aretry_context(
213
+ exception,
214
+ wait_gen,
215
+ max_tries=max_tries,
216
+ max_time=max_time,
217
+ jitter=jitter,
218
+ giveup=giveup,
219
+ on_try=_adapt_context_handlers(on_try, target, args, kwargs),
220
+ on_success=_adapt_context_handlers(on_success, target, args, kwargs),
221
+ on_backoff=_adapt_context_handlers(on_backoff, target, args, kwargs),
222
+ on_giveup=_adapt_context_handlers(on_giveup, target, args, kwargs),
223
+ raise_on_giveup=raise_on_giveup,
224
+ wait_gen_kwargs=wait_gen_kwargs,
225
+ ):
226
+ with attempt:
227
+ ret = await target(*args, **kwargs)
228
+
229
+ return ret
230
+
231
+ return retry
232
+
233
+
234
+ async def _dispatch_handlers(
235
+ handlers: Iterable[_ContextHandler], **details: Any
236
+ ) -> None:
237
+ for hdlr in _ensure_coroutines(handlers):
238
+ await hdlr(details)
239
+
240
+
241
+ async def aretry_context(
242
+ exception: _MaybeTuple[type[Exception]],
243
+ wait_gen: _WaitGenerator,
244
+ *,
245
+ max_tries: _MaybeCallable[int] | None,
246
+ max_time: _MaybeCallable[float] | None,
247
+ jitter: _Jitterer | None,
248
+ giveup: _Predicate[BaseException],
249
+ on_try: Iterable[_ContextHandler],
250
+ on_success: Iterable[_ContextHandler],
251
+ on_backoff: Iterable[_ContextHandler],
252
+ on_giveup: Iterable[_ContextHandler],
253
+ raise_on_giveup: bool,
254
+ wait_gen_kwargs: dict[str, Any],
255
+ ) -> AsyncGenerator[Attempt, None]:
256
+ giveup = _ensure_coroutine(giveup)
257
+
258
+ state = _RetryState(
259
+ wait_gen,
260
+ wait_gen_kwargs,
261
+ max_tries=max_tries,
262
+ max_time=max_time,
263
+ )
264
+ while True:
265
+ state.start_attempt()
266
+ attempt = Attempt(exception)
267
+ await _dispatch_handlers(
268
+ handlers=on_try,
269
+ tries=state.tries,
270
+ elapsed=state.elapsed,
271
+ )
272
+ yield attempt
273
+ elapsed = state.record_elapsed()
274
+
275
+ exc = attempt.exception
276
+ if exc is None:
277
+ await _dispatch_handlers(on_success, tries=state.tries, elapsed=elapsed)
278
+ return
279
+
280
+ if await giveup(exc) or state.exhausted():
281
+ await _dispatch_handlers(
282
+ on_giveup,
283
+ tries=state.tries,
284
+ elapsed=elapsed,
285
+ exception=exc,
286
+ )
287
+ if raise_on_giveup:
288
+ raise exc
289
+ return
290
+
291
+ try:
292
+ seconds = state.next_wait(exc, jitter)
293
+ except StopIteration:
294
+ await _dispatch_handlers(
295
+ on_giveup,
296
+ tries=state.tries,
297
+ elapsed=elapsed,
298
+ exception=exc,
299
+ )
300
+ raise exc from None
301
+
302
+ await _dispatch_handlers(
303
+ on_backoff,
304
+ tries=state.tries,
305
+ elapsed=elapsed,
306
+ wait=seconds,
307
+ exception=exc,
308
+ )
309
+
310
+ await asyncio.sleep(seconds)
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ import time
6
+ import traceback
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
9
+
10
+ if TYPE_CHECKING:
11
+ import sys
12
+ from collections.abc import Callable, Generator, Iterable
13
+
14
+ from backoff._typing import (
15
+ ContextDetails,
16
+ Details,
17
+ _AnyLogger,
18
+ _AnyLoggerOrName,
19
+ _ContextHandler,
20
+ _Jitterer,
21
+ _MaybeCallable,
22
+ _WaitGenerator,
23
+ )
24
+
25
+ if sys.version_info >= (3, 11):
26
+ from typing import Self
27
+ else:
28
+ from typing_extensions import Self
29
+
30
+ ExceptionTypes: TypeAlias = type[Exception] | tuple[type[Exception], ...]
31
+
32
+
33
+ # Use module-specific logger with a default null handler.
34
+ _logger = logging.getLogger("backoff")
35
+ _logger.addHandler(logging.NullHandler()) # pragma: no cover
36
+ _logger.setLevel(logging.INFO)
37
+
38
+ T = TypeVar("T")
39
+ _HandlerT = TypeVar("_HandlerT")
40
+
41
+
42
+ # Evaluate arg that can be either a fixed value or a callable.
43
+ def _maybe_call(f: _MaybeCallable[T] | None, *args: Any, **kwargs: Any) -> T | None:
44
+ if callable(f):
45
+ try:
46
+ return f(*args, **kwargs) # ty:ignore[call-top-callable, invalid-return-type]
47
+ except TypeError:
48
+ return f # type: ignore[return-value] # ty:ignore[invalid-return-type]
49
+ else:
50
+ return f
51
+
52
+
53
+ def _init_wait_gen(
54
+ wait_gen: _WaitGenerator,
55
+ wait_gen_kwargs: dict[str, Any],
56
+ ) -> Generator[float, Any, None]:
57
+ kwargs = {k: _maybe_call(v) for k, v in wait_gen_kwargs.items()}
58
+ initialized = wait_gen(**kwargs)
59
+ initialized.send(None) # Initialize with an empty send
60
+ return initialized
61
+
62
+
63
+ def _next_wait(
64
+ wait: Generator[float, None, None],
65
+ send_value: Any,
66
+ jitter: _Jitterer | None,
67
+ elapsed: float,
68
+ max_time: float | None,
69
+ ) -> float:
70
+ value = wait.send(send_value)
71
+ seconds = jitter(value) if jitter is not None else value
72
+
73
+ # don't sleep longer than remaining allotted max_time
74
+ if max_time is not None:
75
+ seconds = min(seconds, max_time - elapsed)
76
+
77
+ return seconds
78
+
79
+
80
+ class _RetryState:
81
+ """Bookkeeping shared by the sync and async retry loops.
82
+
83
+ Tracks try count and elapsed time, and owns the initialized wait
84
+ generator, so `retry_predicate`/`retry_exception` only need to drive
85
+ the call/check/sleep sequence around it.
86
+ """
87
+
88
+ __slots__ = (
89
+ "elapsed",
90
+ "max_time",
91
+ "max_tries",
92
+ "start",
93
+ "tries",
94
+ "wait",
95
+ )
96
+
97
+ def __init__(
98
+ self,
99
+ wait_gen: _WaitGenerator,
100
+ wait_gen_kwargs: dict[str, Any],
101
+ *,
102
+ max_tries: _MaybeCallable[int] | None = None,
103
+ max_time: _MaybeCallable[float] | None = None,
104
+ ) -> None:
105
+ self.tries = 0
106
+ self.elapsed: float = 0
107
+ self.start = time.monotonic()
108
+ self.max_tries = _maybe_call(max_tries)
109
+ self.max_time = _maybe_call(max_time)
110
+ self.wait = _init_wait_gen(wait_gen, wait_gen_kwargs)
111
+
112
+ def start_attempt(self) -> None:
113
+ self.tries += 1
114
+
115
+ def record_elapsed(self) -> float:
116
+ self.elapsed = time.monotonic() - self.start
117
+ return self.elapsed
118
+
119
+ def exhausted(self) -> bool:
120
+ max_tries_exceeded = self.tries == self.max_tries
121
+ max_time_exceeded = self.max_time is not None and self.elapsed >= self.max_time
122
+ return max_tries_exceeded or max_time_exceeded
123
+
124
+ def next_wait(self, send_value: Any, jitter: _Jitterer | None) -> float:
125
+ return _next_wait(self.wait, send_value, jitter, self.elapsed, self.max_time)
126
+
127
+
128
+ @dataclass(slots=True)
129
+ class Attempt:
130
+ """A single attempt yielded by `retry_context`/`aretry_context`.
131
+
132
+ Used as `with attempt: ...`. Exceptions matching `exception_types` are
133
+ caught here so the driving generator (not this object) decides whether
134
+ to retry, sleep, or let the exception propagate; anything else, or no
135
+ exception at all, is left for the `with` block's normal exit behavior.
136
+ """
137
+
138
+ exception_types: ExceptionTypes
139
+ exception: BaseException | None = field(init=False)
140
+
141
+ def __post_init__(self) -> None:
142
+ self.exception: BaseException | None = None
143
+
144
+ def __enter__(self) -> Self:
145
+ return self
146
+
147
+ def __exit__(
148
+ self,
149
+ exc_type: type[BaseException] | None,
150
+ exc: BaseException | None,
151
+ tb: object,
152
+ ) -> bool:
153
+ if exc_type is None:
154
+ return False
155
+
156
+ if not issubclass(exc_type, self.exception_types):
157
+ return False # not ours; propagate immediately
158
+
159
+ self.exception = exc
160
+ return True # suppress for now; the driving generator decides next
161
+
162
+
163
+ def _dispatch_handlers(handlers: Iterable[_ContextHandler], **details: Any) -> None:
164
+ for hdlr in handlers:
165
+ hdlr(details) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
166
+
167
+
168
+ def _prepare_logger(logger: _AnyLoggerOrName | None) -> _AnyLogger | None:
169
+ if isinstance(logger, str):
170
+ logger = logging.getLogger(logger)
171
+ return logger
172
+
173
+
174
+ # Configure handler list with user specified handler and optionally
175
+ # with a default handler bound to the specified logger.
176
+ def _config_handlers(
177
+ user_handlers: _HandlerT | Iterable[_HandlerT] | None,
178
+ *,
179
+ default_handler: Callable[..., None] | None = None,
180
+ logger: _AnyLogger | None = None,
181
+ log_level: int | None = None,
182
+ ) -> list[_HandlerT]:
183
+ handlers: list[_HandlerT] = []
184
+ if logger is not None:
185
+ assert log_level is not None, "Log level is not specified"
186
+ assert default_handler is not None, "Default handler is not specified"
187
+ # bind the specified logger to the default log handler
188
+ log_handler = functools.partial(
189
+ default_handler,
190
+ logger=logger,
191
+ log_level=log_level,
192
+ )
193
+ handlers.append(log_handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
194
+
195
+ if user_handlers is None:
196
+ return handlers
197
+
198
+ # user specified handlers can either be an iterable of handlers
199
+ # or a single handler. either way append them to the list.
200
+ if hasattr(user_handlers, "__iter__"):
201
+ # add all handlers in the iterable
202
+ # pyrefly: ignore [bad-argument-type]
203
+ handlers += list(user_handlers) # ty:ignore[invalid-argument-type]
204
+ else:
205
+ # append a single handler
206
+ # pyrefly: ignore [bad-argument-type]
207
+ handlers.append(user_handlers)
208
+
209
+ return handlers
210
+
211
+
212
+ # Default backoff handler
213
+ def _log_backoff(details: Details, logger: _AnyLogger, log_level: int) -> None:
214
+ msg = "Backing off %s(...) for %.1fs (%s)"
215
+ log_args = [details["target"].__name__, details["wait"]] # ty:ignore[unresolved-attribute]
216
+
217
+ exc = details.get("exception")
218
+ if exc is not None:
219
+ exc_fmt = traceback.format_exception_only(type(exc), exc)[-1]
220
+ log_args.append(exc_fmt.rstrip("\n"))
221
+ else:
222
+ log_args.append(details["value"])
223
+ logger.log(log_level, msg, *log_args)
224
+
225
+
226
+ # Default giveup handler
227
+ def _log_giveup(details: Details, logger: _AnyLogger, log_level: int) -> None:
228
+ msg = "Giving up %s(...) after %d tries (%s)"
229
+ log_args = [details["target"].__name__, details["tries"]] # ty:ignore[unresolved-attribute]
230
+
231
+ exc = details.get("exception")
232
+ if exc is not None:
233
+ exc_fmt = traceback.format_exception_only(type(exc), exc)[-1]
234
+ log_args.append(exc_fmt.rstrip("\n"))
235
+ else:
236
+ log_args.append(details["value"])
237
+
238
+ logger.log(log_level, msg, *log_args)
239
+
240
+
241
+ # Default backoff handler for retry_context/aretry_context (no wrapped
242
+ # callable, so no name to log; the exception is read from `details`
243
+ # directly since it's no longer the active exception by this point).
244
+ def _log_backoff_context(
245
+ details: ContextDetails,
246
+ logger: _AnyLogger,
247
+ log_level: int,
248
+ ) -> None:
249
+ logger.log(
250
+ log_level,
251
+ "Backing off retry_context(...) for %.1fs (%s)",
252
+ details["wait"],
253
+ details.get("exception"),
254
+ )
255
+
256
+
257
+ # Default giveup handler for retry_context/aretry_context.
258
+ def _log_giveup_context(
259
+ details: ContextDetails,
260
+ logger: logging.Logger | logging.LoggerAdapter[Any],
261
+ log_level: int,
262
+ ) -> None:
263
+ logger.log(
264
+ log_level,
265
+ "Giving up retry_context(...) after %d tries (%s)",
266
+ details["tries"],
267
+ details.get("exception"),
268
+ )