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