Skip to content
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ jobs:
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Type checks
env:
TOXENV: typing-${{ matrix.python-version }}
run: uvx --with tox-uv tox
run: uvx --with tox-uv tox -e typing
- name: Run tests
env:
TOXENV: ${{ matrix.python-version }}
Expand Down
2 changes: 2 additions & 0 deletions backoff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
https://github.com/python-backoff/backoff
"""

from backoff._common import Attempt
from backoff._decorator import aretry_context, on_exception, on_predicate, retry_context
from backoff._jitter import full_jitter, random_jitter
from backoff._wait_gen import constant, decay, expo, fibo, runtime

__all__ = [
"Attempt",
"aretry_context",
"constant",
"decay",
Expand Down
6 changes: 3 additions & 3 deletions backoff/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeVar

from backoff._common import _Attempt, _RetryState
from backoff._common import Attempt, _RetryState

if TYPE_CHECKING:
import sys
Expand Down Expand Up @@ -252,7 +252,7 @@ async def aretry_context(
on_giveup: Iterable[_ContextHandler],
raise_on_giveup: bool,
wait_gen_kwargs: dict[str, Any],
) -> AsyncGenerator[_Attempt, None]:
) -> AsyncGenerator[Attempt, None]:
giveup = _ensure_coroutine(giveup)

state = _RetryState(
Expand All @@ -263,7 +263,7 @@ async def aretry_context(
)
while True:
state.start_attempt()
attempt = _Attempt(exception)
attempt = Attempt(exception)
await _dispatch_handlers(
handlers=on_try,
tries=state.tries,
Expand Down
21 changes: 9 additions & 12 deletions backoff/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import logging
import time
import traceback
from typing import TYPE_CHECKING, Any, TypeVar
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar

if TYPE_CHECKING:
import sys
Expand All @@ -26,6 +27,8 @@
else:
from typing_extensions import Self

ExceptionTypes: TypeAlias = type[Exception] | tuple[type[Exception], ...]


# Use module-specific logger with a default null handler.
_logger = logging.getLogger("backoff")
Expand Down Expand Up @@ -122,7 +125,8 @@ def next_wait(self, send_value: Any, jitter: _Jitterer | None) -> float:
return _next_wait(self.wait, send_value, jitter, self.elapsed, self.max_time)


class _Attempt:
@dataclass(slots=True)
class Attempt:
"""A single attempt yielded by `retry_context`/`aretry_context`.

Used as `with attempt: ...`. Exceptions matching `exception_types` are
Expand All @@ -131,16 +135,10 @@ class _Attempt:
exception at all, is left for the `with` block's normal exit behavior.
"""

__slots__ = (
"exception",
"exception_types",
)
exception_types: ExceptionTypes
exception: BaseException | None = field(init=False)

def __init__(
self,
exception_types: type[Exception] | tuple[type[Exception], ...],
) -> None:
self.exception_types = exception_types
def __post_init__(self) -> None:
self.exception: BaseException | None = None

def __enter__(self) -> Self:
Expand All @@ -153,7 +151,6 @@ def __exit__(
tb: object,
) -> bool:
if exc_type is None:
# self.outcome = _Success()
return False

if not issubclass(exc_type, self.exception_types):
Expand Down
6 changes: 3 additions & 3 deletions backoff/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable, Generator, Iterable

from backoff._common import _Attempt
from backoff._common import Attempt
from backoff._typing import (
_AnyLoggerOrName,
_CallableT,
Expand Down Expand Up @@ -301,7 +301,7 @@ def retry_context(
backoff_log_level: int = logging.INFO,
giveup_log_level: int = logging.ERROR,
**wait_gen_kwargs: Any,
) -> Generator[_Attempt, None, None]:
) -> Generator[Attempt, None, None]:
"""Returns a generator of retry attempts, for direct use with a `for` loop.

Unlike `on_exception`, this doesn't wrap a whole function; it lets a
Expand Down Expand Up @@ -400,7 +400,7 @@ def aretry_context(
backoff_log_level: int = logging.INFO,
giveup_log_level: int = logging.ERROR,
**wait_gen_kwargs: Any,
) -> AsyncGenerator[_Attempt, None]:
) -> AsyncGenerator[Attempt, None]:
"""Async counterpart to `retry_context`, for use with `async for`.

async for attempt in backoff.aretry_context(ValueError, backoff.expo):
Expand Down
6 changes: 3 additions & 3 deletions backoff/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import time
from typing import TYPE_CHECKING, Any, TypeVar

from backoff._common import _Attempt, _dispatch_handlers, _RetryState
from backoff._common import Attempt, _dispatch_handlers, _RetryState

if TYPE_CHECKING:
import sys
Expand Down Expand Up @@ -188,7 +188,7 @@ def retry_context(
on_giveup: Iterable[_ContextHandler],
raise_on_giveup: bool,
wait_gen_kwargs: dict[str, Any],
) -> Generator[_Attempt, None, None]:
) -> Generator[Attempt, None, None]:
state = _RetryState(
wait_gen,
wait_gen_kwargs,
Expand All @@ -197,7 +197,7 @@ def retry_context(
)
while True:
state.start_attempt()
attempt = _Attempt(exception)
attempt = Attempt(exception)
_dispatch_handlers(handlers=on_try, tries=state.tries, elapsed=state.elapsed)
yield attempt
elapsed = state.record_elapsed()
Expand Down
3 changes: 2 additions & 1 deletion backoff/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from ._typing import Details
from ._typing import ContextDetails, Details

__all__ = [
"ContextDetails",
"Details",
]
6 changes: 6 additions & 0 deletions docs/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Complete API documentation for the backoff module.
show_root_heading: true
show_source: true

::: backoff.Attempt
options:
show_root_heading: true
show_source: true
summary: true

## Wait Generators

::: backoff.expo
Expand Down
33 changes: 33 additions & 0 deletions docs/user-guide/context-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,36 @@ for attempt in backoff.retry_context():
else:
print(f"Got it (choice={choice})")
```

`attempt` is an instance of `backoff.Attempt`, so a helper that
accepts one (e.g. for logging) can be typed against it directly:

```python
from backoff import Attempt


def log_attempt(attempt: Attempt) -> None:
print(f"exception so far: {attempt.exception}")


for attempt in backoff.retry_context():
with attempt:
log_attempt(attempt)
# ...
```

Handlers passed as `on_try`/`on_success`/`on_backoff`/`on_giveup` receive a
`dict` typed as `backoff.types.ContextDetails`:

```python
from backoff.types import ContextDetails


def log_backoff(details: ContextDetails) -> None:
print(f"retrying in {details['wait']}s after {details.get('exception')}")


for attempt in backoff.retry_context(on_backoff=log_backoff):
with attempt:
...
```
13 changes: 5 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,13 @@ commands = [
]

[tool.tox.env.typing]
base = [ "tool.tox.env_base.typing" ]
base_python_file = [ ".python-version" ]
description = "run type checking on the default Python version"

[tool.tox.env_base.typing]
factors = [
{ prefix = "3.", start = 10 },
]
description = "run type checking on Python {py_dot_ver}"
dependency_groups = [ "typing" ]
labels = [ "check" ]
commands = [
[
"mypy",
"--python-version={py_dot_ver}",
"--show-error-codes",
{ replace = "posargs", default = [
"backoff",
Expand All @@ -155,6 +148,7 @@ commands = [
],
[
"mypy",
"--python-version={py_dot_ver}",
"--strict",
"--disallow-any-decorated",
"--follow-imports=silent",
Expand All @@ -163,6 +157,7 @@ commands = [
[
"ty",
"check",
"--python-version={py_dot_ver}",
{ replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = [ "--output-format=github" ], else = [], extend = true },
{ replace = "posargs", default = [
"backoff",
Expand All @@ -172,6 +167,7 @@ commands = [
[
"pyrefly",
"check",
"--python-version={py_dot_ver}",
{ replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = [ "--output-format=github" ], else = [], extend = true },
{ replace = "posargs", default = [
"backoff",
Expand All @@ -182,6 +178,7 @@ commands = [
"pyrefly",
"coverage",
"check",
"--python-version={py_dot_ver}",
{ replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = [ "--output-format=github" ], else = [], extend = true },
{ replace = "posargs", default = [
"backoff",
Expand Down
3 changes: 2 additions & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from backoff.types import Details
from backoff.types import ContextDetails, Details

assert Details # type: ignore[truthy-function]
assert ContextDetails # type: ignore[truthy-function]