diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a4a4ee3..2979e7b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 }} diff --git a/backoff/__init__.py b/backoff/__init__.py index 2cc6f44..e0677bb 100644 --- a/backoff/__init__.py +++ b/backoff/__init__.py @@ -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", diff --git a/backoff/_async.py b/backoff/_async.py index 094c9f8..86022c0 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -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 @@ -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( @@ -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, diff --git a/backoff/_common.py b/backoff/_common.py index 6a41f91..8632de0 100644 --- a/backoff/_common.py +++ b/backoff/_common.py @@ -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 @@ -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") @@ -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 @@ -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: @@ -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): diff --git a/backoff/_decorator.py b/backoff/_decorator.py index a6235eb..44833fe 100644 --- a/backoff/_decorator.py +++ b/backoff/_decorator.py @@ -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, @@ -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 @@ -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): diff --git a/backoff/_sync.py b/backoff/_sync.py index dab9474..be6122d 100644 --- a/backoff/_sync.py +++ b/backoff/_sync.py @@ -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 @@ -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, @@ -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() diff --git a/backoff/types.py b/backoff/types.py index 4fb197d..e02ec97 100644 --- a/backoff/types.py +++ b/backoff/types.py @@ -1,5 +1,6 @@ -from ._typing import Details +from ._typing import ContextDetails, Details __all__ = [ + "ContextDetails", "Details", ] diff --git a/docs/api/reference.md b/docs/api/reference.md index 2d104e4..667e1dd 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -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 diff --git a/docs/user-guide/context-manager.md b/docs/user-guide/context-manager.md index 47703f3..4f3bdd3 100644 --- a/docs/user-guide/context-manager.md +++ b/docs/user-guide/context-manager.md @@ -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: + ... +``` diff --git a/pyproject.toml b/pyproject.toml index 6b087c6..100a9ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -155,6 +148,7 @@ commands = [ ], [ "mypy", + "--python-version={py_dot_ver}", "--strict", "--disallow-any-decorated", "--follow-imports=silent", @@ -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", @@ -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", @@ -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", diff --git a/tests/test_types.py b/tests/test_types.py index dbbe1b5..bdb0fc4 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -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]