From 6b170daa5cc08d684e63cb925f12ff1f31cdb4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 14 Jul 2026 23:37:10 -0600 Subject: [PATCH] feat: Add type annotations to decorator functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- CHANGELOG.md | 4 + backoff/_async.py | 137 +++++++++++++++------- backoff/_common.py | 83 +++++++++---- backoff/_decorator.py | 20 +++- backoff/_sync.py | 115 ++++++++++++------ backoff/_typing.py | 21 ++-- backoff/_wait_gen.py | 20 ++-- pyproject.toml | 23 ++++ tests/common.py | 30 ++++- tests/test_backoff.py | 225 +++++++++++++++++++----------------- tests/test_backoff_async.py | 164 +++++++++++++------------- tests/test_integration.py | 5 +- tests/test_jitter.py | 2 +- tests/test_package.py | 4 +- tests/test_wait_gen.py | 28 ++--- 15 files changed, 541 insertions(+), 340 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f79f10..85cf915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add type annotations to decorator functions [#182](https://github.com/python-backoff/backoff/pull/182) + ### Changed - Python 3.9+ is required [#152](https://github.com/python-backoff/backoff/pull/152) diff --git a/backoff/_async.py b/backoff/_async.py index e8ce49c..eaad59a 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -1,52 +1,100 @@ +from __future__ import annotations + import asyncio import functools import inspect import time +from typing import TYPE_CHECKING, Any, Callable, TypeVar from backoff._common import _init_wait_gen, _maybe_call, _next_wait - -def _ensure_coroutine(coro_or_func): +if TYPE_CHECKING: + import sys + from collections.abc import Coroutine, Iterable + + from backoff._typing import ( + Details, + _BaseDetails, + _CallDetails, + _Handler, + _Jitterer, + _MaybeCallable, + _MaybeSequence, + _Predicate, + _WaitGenerator, + ) + + if sys.version_info >= (3, 10): + from typing import ParamSpec + else: + from typing_extensions import ParamSpec + + if sys.version_info >= (3, 11): + from typing import Unpack + else: + from typing_extensions import Unpack + + T = TypeVar("T") + P = ParamSpec("P") + +_AsyncHandler = Callable[["Details"], "Coroutine[Any, Any, None]"] + + +def _ensure_coroutine( + coro_or_func: Callable[..., Any], +) -> Callable[..., Coroutine[Any, Any, Any]]: if inspect.iscoroutinefunction(coro_or_func): return coro_or_func @functools.wraps(coro_or_func) - async def f(*args, **kwargs): # ruff:ignore[unused-async] + async def f(*args: Any, **kwargs: Any) -> Any: # ruff:ignore[unused-async] return coro_or_func(*args, **kwargs) return f -def _ensure_coroutines(coros_or_funcs): +def _ensure_coroutines( + coros_or_funcs: Iterable[Callable[..., Any]], +) -> list[Callable[..., Coroutine[Any, Any, Any]]]: return [_ensure_coroutine(f) for f in coros_or_funcs] -async def _call_handlers(handlers, *, target, args, kwargs, tries, elapsed, **extra): - details = { +async def _call_handlers( + handlers: Iterable[_AsyncHandler], + *, + target: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + tries: int, + elapsed: float, + **extra: Unpack[_CallDetails], +) -> None: + details: Details = { "target": target, "args": args, "kwargs": kwargs, "tries": tries, "elapsed": elapsed, } + # pyrefly: ignore [no-matching-overload] details.update(extra) for handler in handlers: await handler(details) def retry_predicate( - target, - wait_gen, - predicate, + target: Callable[P, T], + wait_gen: _WaitGenerator, + predicate: _Predicate[T], *, - max_tries, - max_time, - jitter, - on_success, - on_backoff, - on_giveup, - wait_gen_kwargs, -): + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + on_success: Iterable[_Handler], + on_backoff: Iterable[_Handler], + on_giveup: Iterable[_Handler], + wait_gen_kwargs: dict[str, Any], +) -> Callable[P, T]: on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) on_giveup = _ensure_coroutines(on_giveup) @@ -58,10 +106,10 @@ def retry_predicate( assert inspect.iscoroutinefunction(target) @functools.wraps(target) - async def retry(*args, **kwargs): + async def retry(*args: P.args, **kwargs: P.kwargs) -> T: # update variables from outer function args - max_tries_value = _maybe_call(max_tries) - max_time_value = _maybe_call(max_time) + max_tries_value: int | None = _maybe_call(max_tries) + max_time_value: float | None = _maybe_call(max_time) tries = 0 start = time.monotonic() @@ -69,7 +117,7 @@ async def retry(*args, **kwargs): while True: tries += 1 elapsed = time.monotonic() - start - details = { + details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, @@ -112,24 +160,24 @@ async def retry(*args, **kwargs): return ret - return retry + return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] def retry_exception( - target, - wait_gen, - exception, + target: Callable[P, T], + wait_gen: _WaitGenerator, + exception: _MaybeSequence[type[Exception]], *, - max_tries, - max_time, - jitter, - giveup, - on_success, - on_backoff, - on_giveup, - raise_on_giveup, - wait_gen_kwargs, -): + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + giveup: _Predicate[Exception], + on_success: Iterable[_Handler], + on_backoff: Iterable[_Handler], + on_giveup: Iterable[_Handler], + raise_on_giveup: bool, + wait_gen_kwargs: dict[str, Any], +) -> Callable[P, T]: on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) on_giveup = _ensure_coroutines(on_giveup) @@ -140,9 +188,12 @@ def retry_exception( assert not inspect.iscoroutinefunction(jitter) @functools.wraps(target) - async def retry(*args, **kwargs): - max_tries_value = _maybe_call(max_tries) - max_time_value = _maybe_call(max_time) + async def retry( + *args: P.args, + **kwargs: P.kwargs, + ) -> T: + max_tries_value: int | None = _maybe_call(max_tries) + max_time_value: float | None = _maybe_call(max_time) tries = 0 start = time.monotonic() @@ -150,7 +201,7 @@ async def retry(*args, **kwargs): while True: tries += 1 elapsed = time.monotonic() - start - details = { + details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, @@ -159,8 +210,8 @@ async def retry(*args, **kwargs): } try: - ret = await target(*args, **kwargs) - except exception as e: + ret = await target(*args, **kwargs) # type: ignore[misc] # ty:ignore[invalid-await] + except exception as e: # type: ignore[misc] # ty:ignore[invalid-exception-caught] giveup_result = await giveup(e) max_tries_exceeded = tries == max_tries_value max_time_exceeded = ( @@ -171,7 +222,7 @@ async def retry(*args, **kwargs): await _call_handlers(on_giveup, **details, exception=e) if raise_on_giveup: raise - return None + return None # type: ignore[return-value] # ty:ignore[invalid-return-type] try: seconds = _next_wait(wait, e, jitter, elapsed, max_time_value) @@ -196,4 +247,4 @@ async def retry(*args, **kwargs): return ret - return retry + return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] diff --git a/backoff/_common.py b/backoff/_common.py index 65647d3..b344184 100644 --- a/backoff/_common.py +++ b/backoff/_common.py @@ -5,31 +5,53 @@ import sys import traceback import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterable + from typing import Protocol + + from backoff._typing import ( + Details, + _Handler, + _Jitterer, + _MaybeCallable, + _WaitGenerator, + ) + + class _DefaultHandler(Protocol): + def __call__( + self, + details: Details, + *, + logger: logging.Logger | logging.LoggerAdapter, + log_level: int, + ) -> None: ... - from backoff._typing import _Jitterer # Use module-specific logger with a default null handler. _logger = logging.getLogger("backoff") _logger.addHandler(logging.NullHandler()) # pragma: no cover _logger.setLevel(logging.INFO) +T = TypeVar("T") + # Evaluate arg that can be either a fixed value or a callable. -def _maybe_call(f, *args, **kwargs): +def _maybe_call(f: _MaybeCallable[T] | None, *args: Any, **kwargs: Any) -> T | None: if callable(f): try: - return f(*args, **kwargs) + return f(*args, **kwargs) # ty:ignore[call-top-callable, invalid-return-type] except TypeError: - return f + return f # type: ignore[return-value] # ty:ignore[invalid-return-type] else: return f -def _init_wait_gen(wait_gen, wait_gen_kwargs): +def _init_wait_gen( + wait_gen: _WaitGenerator, + wait_gen_kwargs: dict[str, Any], +) -> Generator[Any, Any, None]: kwargs = {k: _maybe_call(v) for k, v in wait_gen_kwargs.items()} initialized = wait_gen(**kwargs) initialized.send(None) # Initialize with an empty send @@ -38,10 +60,10 @@ def _init_wait_gen(wait_gen, wait_gen_kwargs): def _next_wait( wait: Generator[float, int | None, None], - send_value: int | None, - jitter: _Jitterer, + send_value: Any, + jitter: _Jitterer | None, elapsed: float, - max_time: float, + max_time: float | None, ) -> float: value = wait.send(send_value) try: @@ -55,7 +77,7 @@ def _next_wait( stacklevel=2, ) - seconds = value + jitter() # type: ignore[call-arg] # ty:ignore[missing-argument] + seconds = value + jitter() # type: ignore[call-arg, misc] # ty:ignore[missing-argument, call-non-callable] # don't sleep longer than remaining allotted max_time if max_time is not None: @@ -64,7 +86,9 @@ def _next_wait( return seconds -def _prepare_logger(logger): +def _prepare_logger( + logger: str | logging.Logger | logging.LoggerAdapter | None, +) -> logging.Logger | logging.LoggerAdapter | None: if isinstance(logger, str): logger = logging.getLogger(logger) return logger @@ -73,18 +97,19 @@ def _prepare_logger(logger): # Configure handler list with user specified handler and optionally # with a default handler bound to the specified logger. def _config_handlers( - user_handlers, + user_handlers: _Handler | Iterable[_Handler] | None, *, - default_handler=None, - logger=None, - log_level=None, -): - handlers = [] + default_handler: _DefaultHandler | None = None, + logger: logging.Logger | logging.LoggerAdapter | None = None, + log_level: int | None = None, +) -> list[_Handler]: + handlers: list[_Handler] = [] if logger is not None: assert log_level is not None, "Log level is not specified" + assert default_handler is not None, "Default handler is not specified" # bind the specified logger to the default log handler log_handler = functools.partial( - default_handler, # ty:ignore[invalid-argument-type] + default_handler, logger=logger, log_level=log_level, ) @@ -97,18 +122,24 @@ def _config_handlers( # or a single handler. either way append them to the list. if hasattr(user_handlers, "__iter__"): # add all handlers in the iterable - handlers += list(user_handlers) + # pyrefly: ignore [bad-argument-type] + handlers += list(user_handlers) # ty:ignore[invalid-argument-type] else: # append a single handler + # pyrefly: ignore [bad-argument-type] handlers.append(user_handlers) return handlers # Default backoff handler -def _log_backoff(details, logger, log_level): +def _log_backoff( + details: Details, + logger: logging.Logger | logging.LoggerAdapter, + log_level: int, +) -> None: msg = "Backing off %s(...) for %.1fs (%s)" - log_args = [details["target"].__name__, details["wait"]] + log_args = [details["target"].__name__, details["wait"]] # ty:ignore[unresolved-attribute] exc_typ, exc, _ = sys.exc_info() if exc is not None: @@ -120,9 +151,13 @@ def _log_backoff(details, logger, log_level): # Default giveup handler -def _log_giveup(details, logger, log_level): +def _log_giveup( + details: Details, + logger: logging.Logger | logging.LoggerAdapter, + log_level: int, +) -> None: msg = "Giving up %s(...) after %d tries (%s)" - log_args = [details["target"].__name__, details["tries"]] + log_args = [details["target"].__name__, details["tries"]] # ty:ignore[unresolved-attribute] exc_typ, exc, _ = sys.exc_info() if exc is not None: diff --git a/backoff/_decorator.py b/backoff/_decorator.py index d8b4c75..e36f4a3 100644 --- a/backoff/_decorator.py +++ b/backoff/_decorator.py @@ -3,7 +3,7 @@ import inspect import logging import operator -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, TypeVar from backoff import _async, _sync from backoff._common import ( @@ -15,10 +15,10 @@ from backoff._jitter import full_jitter if TYPE_CHECKING: + import sys from collections.abc import Iterable from backoff._typing import ( - _CallableT, _Handler, _Jitterer, _MaybeCallable, @@ -28,6 +28,14 @@ _WaitGenerator, ) + if sys.version_info >= (3, 10): + from typing import ParamSpec + else: + from typing_extensions import ParamSpec + + T = TypeVar("T") + P = ParamSpec("P") + def on_predicate( wait_gen: _WaitGenerator, @@ -43,7 +51,7 @@ def on_predicate( backoff_log_level: int = logging.INFO, giveup_log_level: int = logging.ERROR, **wait_gen_kwargs: Any, -) -> Callable[[_CallableT], _CallableT]: +) -> Callable[[Callable[P, T]], Callable[P, T]]: """Returns decorator for backoff and retry triggered by predicate. Args: @@ -88,7 +96,7 @@ def on_predicate( This is useful for runtime configuration. """ - def decorate(target): + def decorate(target: Callable[P, T]) -> Callable[P, T]: nonlocal logger, on_success, on_backoff, on_giveup logger = _prepare_logger(logger) @@ -144,7 +152,7 @@ def on_exception( backoff_log_level: int = logging.INFO, giveup_log_level: int = logging.ERROR, **wait_gen_kwargs: Any, -) -> Callable[[_CallableT], _CallableT]: +) -> Callable[[Callable[P, T]], Callable[P, T]]: """Returns decorator for backoff and retry triggered by exception. Args: @@ -191,7 +199,7 @@ def on_exception( This is useful for runtime configuration. """ - def decorate(target): + def decorate(target: Callable[P, T]) -> Callable[P, T]: nonlocal logger, on_success, on_backoff, on_giveup logger = _prepare_logger(logger) diff --git a/backoff/_sync.py b/backoff/_sync.py index b42774f..3894585 100644 --- a/backoff/_sync.py +++ b/backoff/_sync.py @@ -1,39 +1,80 @@ +from __future__ import annotations + import functools import time +from typing import TYPE_CHECKING, Any, Callable, TypeVar from backoff._common import _init_wait_gen, _maybe_call, _next_wait - -def _call_handlers(hdlrs, target, args, kwargs, tries, elapsed, **extra): - details = { +if TYPE_CHECKING: + import sys + from collections.abc import Iterable + + from backoff._typing import ( + Details, + _BaseDetails, + _CallDetails, + _Handler, + _Jitterer, + _MaybeCallable, + _MaybeSequence, + _Predicate, + _WaitGenerator, + ) + + if sys.version_info >= (3, 10): + from typing import ParamSpec + else: + from typing_extensions import ParamSpec + + if sys.version_info >= (3, 11): + from typing import Unpack + else: + from typing_extensions import Unpack + + T = TypeVar("T") + P = ParamSpec("P") + + +def _call_handlers( + hdlrs: Iterable[_Handler], + target: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + tries: int, + elapsed: float, + **extra: Unpack[_CallDetails], +) -> None: + details: Details = { "target": target, "args": args, "kwargs": kwargs, "tries": tries, "elapsed": elapsed, } + # pyrefly: ignore [no-matching-overload] details.update(extra) for hdlr in hdlrs: hdlr(details) def retry_predicate( - target, - wait_gen, - predicate, + target: Callable[P, T], + wait_gen: _WaitGenerator, + predicate: _Predicate[T], *, - max_tries, - max_time, - jitter, - on_success, - on_backoff, - on_giveup, - wait_gen_kwargs, -): + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + on_success: Iterable[_Handler], + on_backoff: Iterable[_Handler], + on_giveup: Iterable[_Handler], + wait_gen_kwargs: dict[str, Any], +) -> Callable[P, T]: @functools.wraps(target) - def retry(*args, **kwargs): - max_tries_value = _maybe_call(max_tries) - max_time_value = _maybe_call(max_time) + def retry(*args: P.args, **kwargs: P.kwargs) -> T: + max_tries_value: int | None = _maybe_call(max_tries) + max_time_value: float | None = _maybe_call(max_time) tries = 0 start = time.monotonic() @@ -41,7 +82,7 @@ def retry(*args, **kwargs): while True: tries += 1 elapsed = time.monotonic() - start - details = { + details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, @@ -79,24 +120,24 @@ def retry(*args, **kwargs): def retry_exception( - target, - wait_gen, - exception, + target: Callable[P, T], + wait_gen: _WaitGenerator, + exception: _MaybeSequence[type[Exception]], *, - max_tries, - max_time, - jitter, - giveup, - on_success, - on_backoff, - on_giveup, - raise_on_giveup, - wait_gen_kwargs, -): + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + giveup: _Predicate[Exception], + on_success: Iterable[_Handler], + on_backoff: Iterable[_Handler], + on_giveup: Iterable[_Handler], + raise_on_giveup: bool, + wait_gen_kwargs: dict[str, Any], +) -> Callable[P, T]: @functools.wraps(target) - def retry(*args, **kwargs): - max_tries_value = _maybe_call(max_tries) - max_time_value = _maybe_call(max_time) + def retry(*args: P.args, **kwargs: P.kwargs) -> T: # type: ignore[return] # ty:ignore[invalid-return-type] + max_tries_value: int | None = _maybe_call(max_tries) + max_time_value: float | None = _maybe_call(max_time) tries = 0 start = time.monotonic() @@ -104,7 +145,7 @@ def retry(*args, **kwargs): while True: tries += 1 elapsed = time.monotonic() - start - details = { + details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, @@ -114,7 +155,7 @@ def retry(*args, **kwargs): try: ret = target(*args, **kwargs) - except exception as e: + except exception as e: # type: ignore[misc] # ty:ignore[invalid-exception-caught] max_tries_exceeded = tries == max_tries_value max_time_exceeded = ( max_time_value is not None and elapsed >= max_time_value @@ -124,7 +165,7 @@ def retry(*args, **kwargs): _call_handlers(on_giveup, **details, exception=e) if raise_on_giveup: raise - return None + break try: seconds = _next_wait(wait, e, jitter, elapsed, max_time_value) diff --git a/backoff/_typing.py b/backoff/_typing.py index 621352c..ec59ef0 100644 --- a/backoff/_typing.py +++ b/backoff/_typing.py @@ -3,7 +3,6 @@ import logging from collections.abc import Coroutine, Generator, Sequence from typing import ( - TYPE_CHECKING, Any, Callable, TypedDict, @@ -11,24 +10,25 @@ Union, ) -if TYPE_CHECKING: - from types import FunctionType - -class _Details(TypedDict): - target: FunctionType +class _BaseDetails(TypedDict): + target: Callable[..., Any] args: tuple[Any, ...] kwargs: dict[str, Any] tries: int elapsed: float -class Details(_Details, total=False): +class _CallDetails(TypedDict, total=False): wait: float # present in the on_backoff handler case for either decorator value: Any # present in the on_predicate decorator case exception: Exception # present in the on_exception decorator case +class Details(_BaseDetails, _CallDetails, total=False): + pass + + T = TypeVar("T") _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) # ruff:ignore[unused-private-type-var] @@ -40,5 +40,8 @@ class Details(_Details, total=False): _MaybeCallable = Union[T, Callable[[], T]] _MaybeLogger = Union[str, logging.Logger, logging.LoggerAdapter, None] _MaybeSequence = Union[T, Sequence[T]] -_Predicate = Union[Callable[[T], bool], Callable[[T], Coroutine[Any, Any, bool]]] -_WaitGenerator = Callable[..., Generator[Union[float, None], Union[int, None], None]] +_Predicate = Union[ + Callable[[T], bool], + Callable[[T], Coroutine[Any, Any, bool]], +] +_WaitGenerator = Callable[..., Generator[Union[float, None], None, None]] diff --git a/backoff/_wait_gen.py b/backoff/_wait_gen.py index 1bac7bf..48e555d 100644 --- a/backoff/_wait_gen.py +++ b/backoff/_wait_gen.py @@ -12,7 +12,7 @@ def expo( base: float = 2, factor: float = 1, max_value: float | None = None, -) -> Generator[float | None, Any, None]: +) -> Generator[float, Any, None]: """Generator for exponential decay. Args: @@ -23,7 +23,7 @@ def expo( of max_value will forever after be yielded. """ # Advance past initial .send() call - yield # type: ignore[misc] + yield 0 base_n: float = 1 while True: a = factor * base_n @@ -38,7 +38,7 @@ def decay( initial_value: float = 1, decay_factor: float = 1, min_value: float | None = None, -) -> Generator[float | None, Any, None]: +) -> Generator[float, Any, None]: """Generator for exponential decay[1]: Args: @@ -51,7 +51,7 @@ def decay( [1] https://en.wikipedia.org/wiki/Exponential_decay """ # Advance past initial .send() call - yield # type: ignore[misc] + yield 0 t = 0 while True: a = initial_value * math.e ** (-t * decay_factor) @@ -62,7 +62,7 @@ def decay( yield min_value -def fibo(max_value: int | None = None) -> Generator[int | None, None, None]: +def fibo(max_value: int | None = None) -> Generator[int, Any, None]: """Generator for fibonaccial decay. Args: @@ -71,7 +71,7 @@ def fibo(max_value: int | None = None) -> Generator[int | None, None, None]: of max_value will forever after be yielded. """ # Advance past initial .send() call - yield # type: ignore[misc] + yield 0 a = 1 b = 1 @@ -85,14 +85,14 @@ def fibo(max_value: int | None = None) -> Generator[int | None, None, None]: def constant( interval: float | Iterable[float] = 1, -) -> Generator[int | float | None, int | None, None]: +) -> Generator[int | float, Any, None]: """Generator for constant intervals. Args: interval: A constant value to yield or an iterable of such values. """ # Advance past initial .send() call - yield # type: ignore[misc] + yield 0 itr = ( itertools.repeat(interval) @@ -107,7 +107,7 @@ def constant( def runtime( *, value: Callable[[Any], float], -) -> Generator[float | None, int | None, None]: +) -> Generator[float, Any, None]: """Generator that is based on parsing the return value or thrown exception of the decorated method @@ -116,6 +116,6 @@ def runtime( function's return value or thrown exception and determines how long to wait """ - ret_or_exc = yield # type: ignore[misc] + ret_or_exc = yield 0 while True: ret_or_exc = yield value(ret_or_exc) diff --git a/pyproject.toml b/pyproject.toml index fcc77c8..9443632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ lint = [ ] test = [ "coverage>=7.2.7", + "dirty-equals>=0.11", "importlib-metadata>=8.5.0", "packaging>=25.0", "pytest>=7.1.2", @@ -62,7 +63,9 @@ test = [ typing = [ { include-group = "test" }, "mypy>=0.942", + "pyrefly>=1.1.1", "ty>=0.0.59", + "typing-extensions>=4.1; python_version < '3.10'" ] [tool.hatch.build.targets.sdist] @@ -120,6 +123,13 @@ commands = [ { replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = ["--output-format=github"], extend = true }, { replace = "posargs", default = [ "backoff", "tests", "docs" ], extend = true }, ], + [ + "ruff", + "format", + "--check", + { replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = ["--output-format=github"], extend = true }, + { replace = "posargs", default = [ "backoff", "tests", "docs" ], extend = true }, + ], ] [tool.tox.env.typing] @@ -143,6 +153,15 @@ commands = [ "tests", ], extend = true }, ], + [ + "pyrefly", + "check", + { replace = "if", condition = "env.GITHUB_ACTIONS == 'true'", then = [ "--output-format=github" ], else = [], extend = true }, + { replace = "posargs", default = [ + "backoff", + "tests", + ], extend = true }, + ], ] [tool.tox.env.docs] @@ -203,6 +222,7 @@ ignore = [ ] [tool.mypy] +check_untyped_defs = true follow_untyped_imports = true warn_unreachable = true warn_unused_configs = true @@ -232,6 +252,9 @@ source = [ "backoff", ] +[tool.pyrefly] +preset = "legacy" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/common.py b/tests/common.py index 542663f..5313381 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,12 +1,34 @@ +from __future__ import annotations + import collections import functools +from typing import TYPE_CHECKING, Callable, TypeVar + +if TYPE_CHECKING: + import sys + from collections.abc import Callable + + from backoff._typing import Details + + if sys.version_info >= (3, 10): + from typing import ParamSpec + else: + from typing_extensions import ParamSpec + + T = TypeVar("T") + P = ParamSpec("P") # create event handler which log their invocations to a dict -def _log_hdlrs(): +def _log_hdlrs() -> tuple[ + collections.defaultdict[str, list[Details]], + Callable[[Details], None], + Callable[[Details], None], + Callable[[Details], None], +]: log = collections.defaultdict(list) - def log_hdlr(event, details): + def log_hdlr(event: str, details: Details): log[event].append(details) log_success = functools.partial(log_hdlr, "success") @@ -18,6 +40,6 @@ def log_hdlr(event, details): # decorator that that saves the target as # an attribute of the decorated function -def _save_target(f): - f._target = f +def _save_target(f: Callable[P, T]) -> Callable[P, T]: + f._target = f # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return f diff --git a/tests/test_backoff.py b/tests/test_backoff.py index 4e61151..ab92c64 100644 --- a/tests/test_backoff.py +++ b/tests/test_backoff.py @@ -1,49 +1,55 @@ # ruff: file-ignore[float-equality-comparison] +from __future__ import annotations + import logging import re import sys import threading import unittest.mock +from typing import TYPE_CHECKING import pytest +from dirty_equals import IsFloat, IsInstance import backoff -from backoff._typing import Details from tests.common import _save_target +if TYPE_CHECKING: + from backoff._typing import Details + -def test_on_predicate(monkeypatch): +def test_on_predicate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_predicate(backoff.expo) - def return_true(log, n): + def return_true(log: list[bool], n): val = len(log) == n - 1 log.append(val) return val - log = [] + log: list[bool] = [] ret = return_true(log, 3) assert ret is True assert len(log) == 3 -def test_on_predicate_max_tries(monkeypatch): +def test_on_predicate_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_predicate(backoff.expo, jitter=None, max_tries=3) - def return_true(log, n): + def return_true(log: list[bool], n): val = len(log) == n log.append(val) return val - log = [] + log: list[bool] = [] ret = return_true(log, 10) assert ret is False assert len(log) == 3 -def test_on_predicate_max_time(monkeypatch): +def test_on_predicate_max_time(monkeypatch: pytest.MonkeyPatch) -> None: nows = [ 10.000005, 9, @@ -62,18 +68,18 @@ def giveup(details): assert details["elapsed"] == 10.000005 @backoff.on_predicate(backoff.expo, jitter=None, max_time=10, on_giveup=giveup) - def return_true(log, n): + def return_true(log: list[bool], n): val = len(log) == n log.append(val) return val - log = [] + log: list[bool] = [] ret = return_true(log, 10) assert ret is False assert len(log) == 3 -def test_on_predicate_max_time_callable(monkeypatch): +def test_on_predicate_max_time_callable(monkeypatch: pytest.MonkeyPatch) -> None: nows = [ 10.000005, 9, @@ -97,38 +103,39 @@ def lookup_max_time(): @backoff.on_predicate( backoff.expo, jitter=None, max_time=lookup_max_time, on_giveup=giveup ) - def return_true(log, n): + def return_true(log: list[bool], n): val = len(log) == n log.append(val) return val - log = [] + log: list[bool] = [] ret = return_true(log, 10) assert ret is False assert len(log) == 3 -def test_on_exception(monkeypatch): +def test_on_exception(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_exception(backoff.expo, KeyError) - def keyerror_then_true(log, n): + def keyerror_then_true(log: list[Exception], n): if len(log) == n: return True e = KeyError() log.append(e) raise e - log = [] + log: list[Exception] = [] assert keyerror_then_true(log, 3) is True assert len(log) == 3 -def test_on_exception_tuple(monkeypatch): +def test_on_exception_tuple(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_exception(backoff.expo, (KeyError, ValueError)) - def keyerror_valueerror_then_true(log): + def keyerror_valueerror_then_true(log: list[Exception]): + e: Exception if len(log) == 2: return True if len(log) == 0: @@ -138,55 +145,55 @@ def keyerror_valueerror_then_true(log): log.append(e) raise e - log = [] + log: list[Exception] = [] assert keyerror_valueerror_then_true(log) is True assert len(log) == 2 assert isinstance(log[0], KeyError) assert isinstance(log[1], ValueError) -def test_on_exception_max_tries(monkeypatch): +def test_on_exception_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_exception(backoff.expo, KeyError, jitter=None, max_tries=3) - def keyerror_then_true(log, n, foo=None): + def keyerror_then_true(log: list[Exception], n, foo=None): if len(log) == n: return True e = KeyError() log.append(e) raise e - log = [] + log: list[Exception] = [] with pytest.raises(KeyError): keyerror_then_true(log, 10, foo="bar") assert len(log) == 3 -def test_on_exception_max_tries_callable(monkeypatch): +def test_on_exception_max_tries_callable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) @backoff.on_exception(backoff.expo, KeyError, jitter=None, max_tries=lambda: 3) - def keyerror_then_true(log, n, foo=None): + def keyerror_then_true(log: list[Exception], n, foo=None): if len(log) == n: return True e = KeyError() log.append(e) raise e - log = [] + log: list[Exception] = [] with pytest.raises(KeyError): keyerror_then_true(log, 10, foo="bar") assert len(log) == 3 -def test_on_exception_constant_iterable(monkeypatch): +def test_on_exception_constant_iterable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - backoffs = [] - giveups = [] - successes = [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] def on_backoff(details: Details): nonlocal backoffs @@ -228,10 +235,12 @@ def endless_exceptions(): assert len(successes) == 0 -def test_on_exception_success_random_jitter(monkeypatch): +def test_on_exception_success_random_jitter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - backoffs, giveups, successes = [], [], [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.expo, @@ -260,10 +269,12 @@ def succeeder(*args, **kwargs): assert details["wait"] >= 0.5 * 2**i -def test_on_exception_success_full_jitter(monkeypatch): +def test_on_exception_success_full_jitter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - backoffs, giveups, successes = [], [], [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.expo, @@ -292,8 +303,10 @@ def succeeder(*args, **kwargs): assert details["wait"] <= 0.5 * 2**i -def test_on_exception_success(): - backoffs, giveups, successes = [], [], [] +def test_on_exception_success() -> None: + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.constant, @@ -319,32 +332,31 @@ def succeeder(*args, **kwargs): for i in range(2): details = backoffs[i] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "wait": 0, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } details = successes[0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), } @pytest.mark.parametrize("raise_on_giveup", [True, False]) -def test_on_exception_giveup(raise_on_giveup): - backoffs, giveups, successes = [], [], [] +def test_on_exception_giveup(raise_on_giveup: bool) -> None: + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.constant, @@ -373,19 +385,17 @@ def exceptor(*args, **kwargs): assert len(giveups) == 1 details = giveups[0] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": exceptor._target, + "target": exceptor._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } -def test_on_exception_giveup_predicate(monkeypatch): +def test_on_exception_giveup_predicate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) def on_baz(e): @@ -403,8 +413,10 @@ def foo_bar_baz(): assert not vals -def test_on_predicate_success(): - backoffs, giveups, successes = [], [], [] +def test_on_predicate_success() -> None: + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_predicate( backoff.constant, @@ -429,31 +441,31 @@ def success(*args, **kwargs): for i in range(2): details = backoffs[i] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "value": False, "wait": 0, + "elapsed": IsFloat(), } details = successes[0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": True, + "elapsed": IsFloat(), } -def test_on_predicate_giveup(): - backoffs, giveups, successes = [], [], [] +def test_on_predicate_giveup() -> None: + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_predicate( backoff.constant, @@ -476,23 +488,22 @@ def emptiness(*args, **kwargs): assert len(giveups) == 1 details = giveups[0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": emptiness._target, + "target": emptiness._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": None, + "elapsed": IsFloat(), } -def test_on_predicate_iterable_handlers(): +def test_on_predicate_iterable_handlers() -> None: class Logger: def __init__(self): - self.backoffs = [] - self.giveups = [] - self.successes = [] + self.backoffs: list[Details] = [] + self.giveups: list[Details] = [] + self.successes: list[Details] = [] loggers = [Logger() for _ in range(3)] @@ -516,24 +527,25 @@ def emptiness(*args, **kwargs): assert len(logger.backoffs) == 2 assert len(logger.giveups) == 1 - details = dict(logger.giveups[0]) - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) + details = logger.giveups[0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": emptiness._target, + "target": emptiness._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": None, + "elapsed": IsFloat(), } # To maintain backward compatibility, # on_predicate should support 0-argument jitter function. -def test_on_exception_success_0_arg_jitter(monkeypatch): +def test_on_exception_success_0_arg_jitter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - backoffs, giveups, successes = [], [], [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.constant, @@ -541,7 +553,7 @@ def test_on_exception_success_0_arg_jitter(monkeypatch): on_success=successes.append, on_backoff=backoffs.append, on_giveup=giveups.append, - jitter=lambda: 0.0, # ty:ignore[invalid-argument-type] + jitter=lambda: 0.0, # type:ignore[arg-type,misc] # ty:ignore[invalid-argument-type] interval=0, ) @_save_target @@ -562,42 +574,41 @@ def succeeder(*args, **kwargs): for i in range(2): details = backoffs[i] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "wait": 0, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } details = successes[0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), } # To maintain backward compatibility, # on_predicate should support 0-argument jitter function. -def test_on_predicate_success_0_arg_jitter(monkeypatch): +def test_on_predicate_success_0_arg_jitter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - backoffs, giveups, successes = [], [], [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_predicate( backoff.constant, on_success=successes.append, on_backoff=backoffs.append, on_giveup=giveups.append, - jitter=lambda: 0.0, # ty:ignore[invalid-argument-type] + jitter=lambda: 0.0, # type:ignore[arg-type,misc] # ty:ignore[invalid-argument-type] interval=0, ) @_save_target @@ -617,33 +628,31 @@ def success(*args, **kwargs): for i in range(2): details = backoffs[i] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "value": False, "wait": 0, + "elapsed": IsFloat(), } details = successes[0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": True, + "elapsed": IsFloat(), } -def test_on_exception_callable_max_tries(monkeypatch): +def test_on_exception_callable_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - log = [] + log: list[bool] = [] @backoff.on_exception(backoff.constant, ValueError, max_tries=lambda: 3) def exceptor(): @@ -656,7 +665,9 @@ def exceptor(): assert len(log) == 3 -def test_on_exception_callable_max_tries_reads_every_time(monkeypatch): +def test_on_exception_callable_max_tries_reads_every_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("time.sleep", lambda x: None) lookups = [] @@ -697,21 +708,21 @@ def exceptor(): exceptor() -def test_on_predicate_in_thread(monkeypatch): +def test_on_predicate_in_thread(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - result = [] + result: list[Exception | str] = [] def check(): try: @backoff.on_predicate(backoff.expo) - def return_true(log, n): + def return_true(log: list[bool], n): val = len(log) == n - 1 log.append(val) return val - log = [] + log: list[bool] = [] ret = return_true(log, 3) assert ret is True assert len(log) == 3 @@ -729,13 +740,13 @@ def return_true(log, n): assert result[0] == "success" -def test_on_predicate_constant_iterable(monkeypatch): +def test_on_predicate_constant_iterable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) waits = [1, 2, 3, 6, 9] - backoffs = [] - giveups = [] - successes = [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_predicate( backoff.constant, @@ -758,23 +769,23 @@ def falsey(): assert len(successes) == 0 -def test_on_exception_in_thread(monkeypatch): +def test_on_exception_in_thread(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("time.sleep", lambda x: None) - result = [] + result: list[Exception | str] = [] def check(): try: @backoff.on_exception(backoff.expo, KeyError) - def keyerror_then_true(log, n): + def keyerror_then_true(log: list[Exception], n): if len(log) == n: return True e = KeyError() log.append(e) raise e - log = [] + log: list[Exception] = [] assert keyerror_then_true(log, 3) is True assert len(log) == 3 diff --git a/tests/test_backoff_async.py b/tests/test_backoff_async.py index 1bb6d57..c881dda 100644 --- a/tests/test_backoff_async.py +++ b/tests/test_backoff_async.py @@ -1,17 +1,22 @@ -import asyncio # Python 3.5 code and syntax is allowed in this file +import asyncio +from typing import TYPE_CHECKING import pytest +from dirty_equals import IsFloat, IsInstance import backoff from tests.common import _log_hdlrs, _save_target +if TYPE_CHECKING: + from backoff._typing import Details + async def _await_none(x): return None @pytest.mark.asyncio -async def test_on_predicate(monkeypatch): +async def test_on_predicate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_predicate(backoff.expo) @@ -20,14 +25,14 @@ async def return_true(log, n): log.append(val) return val - log = [] + log: list[bool] = [] ret = await return_true(log, 3) assert ret is True assert len(log) == 3 @pytest.mark.asyncio -async def test_on_predicate_max_tries(monkeypatch): +async def test_on_predicate_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_predicate(backoff.expo, jitter=None, max_tries=3) @@ -36,14 +41,14 @@ async def return_true(log, n): log.append(val) return val - log = [] + log: list[bool] = [] ret = await return_true(log, 10) assert ret is False assert len(log) == 3 @pytest.mark.asyncio -async def test_on_predicate_max_tries_callable(monkeypatch): +async def test_on_predicate_max_tries_callable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_predicate(backoff.expo, jitter=None, max_tries=lambda: 3) @@ -52,14 +57,14 @@ async def return_true(log, n): log.append(val) return val - log = [] + log: list[bool] = [] ret = await return_true(log, 10) assert ret is False assert len(log) == 3 @pytest.mark.asyncio -async def test_on_exception(monkeypatch): +async def test_on_exception(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_exception(backoff.expo, KeyError) @@ -70,17 +75,18 @@ async def keyerror_then_true(log, n): log.append(e) raise e - log = [] + log: list[Exception] = [] assert (await keyerror_then_true(log, 3)) is True assert len(log) == 3 @pytest.mark.asyncio -async def test_on_exception_tuple(monkeypatch): +async def test_on_exception_tuple(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_exception(backoff.expo, (KeyError, ValueError)) - async def keyerror_valueerror_then_true(log): + async def keyerror_valueerror_then_true(log: list[Exception]): + e: Exception if len(log) == 2: return True if len(log) == 0: @@ -90,7 +96,7 @@ async def keyerror_valueerror_then_true(log): log.append(e) raise e - log = [] + log: list[Exception] = [] assert (await keyerror_valueerror_then_true(log)) is True assert len(log) == 2 assert isinstance(log[0], KeyError) @@ -98,7 +104,7 @@ async def keyerror_valueerror_then_true(log): @pytest.mark.asyncio -async def test_on_exception_max_tries(monkeypatch): +async def test_on_exception_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_exception(backoff.expo, KeyError, jitter=None, max_tries=3) @@ -109,7 +115,7 @@ async def keyerror_then_true(log, n, foo=None): log.append(e) raise e - log = [] + log: list[Exception] = [] with pytest.raises(KeyError): await keyerror_then_true(log, 10, foo="bar") @@ -117,7 +123,7 @@ async def keyerror_then_true(log, n, foo=None): @pytest.mark.asyncio -async def test_on_exception_max_tries_callable(monkeypatch): +async def test_on_exception_max_tries_callable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) @backoff.on_exception(backoff.expo, KeyError, jitter=None, max_tries=lambda: 3) @@ -128,7 +134,7 @@ async def keyerror_then_true(log, n, foo=None): log.append(e) raise e - log = [] + log: list[Exception] = [] with pytest.raises(KeyError): await keyerror_then_true(log, 10, foo="bar") @@ -136,12 +142,12 @@ async def keyerror_then_true(log, n, foo=None): @pytest.mark.asyncio -async def test_on_exception_constant_iterable(monkeypatch): +async def test_on_exception_constant_iterable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) - backoffs = [] - giveups = [] - successes = [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_exception( backoff.constant, @@ -163,7 +169,9 @@ async def endless_exceptions(): @pytest.mark.asyncio -async def test_on_exception_success_random_jitter(monkeypatch): +async def test_on_exception_success_random_jitter( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) log, log_success, log_backoff, log_giveup = _log_hdlrs() @@ -196,7 +204,9 @@ async def succeeder(*args, **kwargs): @pytest.mark.asyncio -async def test_on_exception_success_full_jitter(monkeypatch): +async def test_on_exception_success_full_jitter( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) log, log_success, log_backoff, log_giveup = _log_hdlrs() @@ -229,7 +239,7 @@ async def succeeder(*args, **kwargs): @pytest.mark.asyncio -async def test_on_exception_success(): +async def test_on_exception_success() -> None: log, log_success, log_backoff, log_giveup = _log_hdlrs() @backoff.on_exception( @@ -256,32 +266,29 @@ async def succeeder(*args, **kwargs): for i in range(2): details = log["backoff"][i] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "wait": 0, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } details = log["success"][0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), } @pytest.mark.asyncio @pytest.mark.parametrize("raise_on_giveup", [True, False]) -async def test_on_exception_giveup(raise_on_giveup): +async def test_on_exception_giveup(raise_on_giveup: bool) -> None: log, log_success, log_backoff, log_giveup = _log_hdlrs() @backoff.on_exception( @@ -311,20 +318,18 @@ async def exceptor(*args, **kwargs): assert len(log["giveup"]) == 1 details = log["giveup"][0] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": exceptor._target, + "target": exceptor._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } @pytest.mark.asyncio -async def test_on_exception_giveup_predicate(monkeypatch): +async def test_on_exception_giveup_predicate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) def on_baz(e): @@ -343,7 +348,7 @@ async def foo_bar_baz(): @pytest.mark.asyncio -async def test_on_exception_giveup_coro(monkeypatch): +async def test_on_exception_giveup_coro(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) async def on_baz(e: Exception) -> bool: @@ -362,7 +367,7 @@ async def foo_bar_baz(): @pytest.mark.asyncio -async def test_on_predicate_success(): +async def test_on_predicate_success() -> None: log, log_success, log_backoff, log_giveup = _log_hdlrs() @backoff.on_predicate( @@ -387,31 +392,29 @@ async def success(*args, **kwargs): for i in range(2): details = log["backoff"][i] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "value": False, "wait": 0, + "elapsed": IsFloat(), } details = log["success"][0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": True, + "elapsed": IsFloat(), } @pytest.mark.asyncio -async def test_on_predicate_giveup(): +async def test_on_predicate_giveup() -> None: log, log_success, log_backoff, log_giveup = _log_hdlrs() @backoff.on_predicate( @@ -435,19 +438,18 @@ async def emptiness(*args, **kwargs): assert len(log["giveup"]) == 1 details = log["giveup"][0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": emptiness._target, + "target": emptiness._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": None, + "elapsed": IsFloat(), } @pytest.mark.asyncio -async def test_on_predicate_iterable_handlers(): +async def test_on_predicate_iterable_handlers() -> None: hdlrs = [_log_hdlrs() for _ in range(3)] @backoff.on_predicate( @@ -470,26 +472,25 @@ async def emptiness(*args, **kwargs): assert len(hdlrs[i][0]["backoff"]) == 2 assert len(hdlrs[i][0]["giveup"]) == 1 - details = dict(hdlrs[i][0]["giveup"][0]) - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) + details = hdlrs[i][0]["giveup"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": emptiness._target, + "target": emptiness._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": None, + "elapsed": IsFloat(), } @pytest.mark.asyncio -async def test_on_predicate_constant_iterable(monkeypatch): +async def test_on_predicate_constant_iterable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) waits = [1, 2, 3, 6, 9] - backoffs = [] - giveups = [] - successes = [] + backoffs: list[Details] = [] + giveups: list[Details] = [] + successes: list[Details] = [] @backoff.on_predicate( backoff.constant, @@ -515,7 +516,9 @@ async def falsey(): # To maintain backward compatibility, # on_predicate should support 0-argument jitter function. @pytest.mark.asyncio -async def test_on_exception_success_0_arg_jitter(monkeypatch): +async def test_on_exception_success_0_arg_jitter( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) log, log_success, log_backoff, log_giveup = _log_hdlrs() @@ -526,7 +529,7 @@ async def test_on_exception_success_0_arg_jitter(monkeypatch): on_success=log_success, on_backoff=log_backoff, on_giveup=log_giveup, - jitter=lambda: 0.0, # ty:ignore[invalid-argument-type] + jitter=lambda: 0.0, # type:ignore[arg-type,misc] # ty:ignore[invalid-argument-type] interval=0, ) @_save_target @@ -547,33 +550,32 @@ async def succeeder(*args, **kwargs): for i in range(2): details = log["backoff"][i] - elapsed = details.pop("elapsed") - exception = details.pop("exception") - assert isinstance(elapsed, float) - assert isinstance(exception, ValueError) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "wait": 0, + "elapsed": IsFloat(), + "exception": IsInstance(ValueError), } details = log["success"][0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": succeeder._target, + "target": succeeder._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, + "elapsed": IsFloat(), } # To maintain backward compatibility, # on_predicate should support 0-argument jitter function. @pytest.mark.asyncio -async def test_on_predicate_success_0_arg_jitter(monkeypatch): +async def test_on_predicate_success_0_arg_jitter( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) log, log_success, log_backoff, log_giveup = _log_hdlrs() @@ -583,7 +585,7 @@ async def test_on_predicate_success_0_arg_jitter(monkeypatch): on_success=log_success, on_backoff=log_backoff, on_giveup=log_giveup, - jitter=lambda: 0.0, # ty:ignore[invalid-argument-type] + jitter=lambda: 0.0, # type:ignore[arg-type,misc] # ty:ignore[invalid-argument-type] interval=0, ) @_save_target @@ -603,31 +605,29 @@ async def success(*args, **kwargs): for i in range(2): details = log["backoff"][i] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": i + 1, "value": False, "wait": 0, + "elapsed": IsFloat(), } details = log["success"][0] - elapsed = details.pop("elapsed") - assert isinstance(elapsed, float) assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, - "target": success._target, + "target": success._target, # type:ignore[attr-defined] # ty:ignore[unresolved-attribute] "tries": 3, "value": True, + "elapsed": IsFloat(), } @pytest.mark.asyncio -async def test_on_exception_callable_max_tries(monkeypatch): +async def test_on_exception_callable_max_tries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) def lookup_max_tries(): @@ -647,7 +647,9 @@ async def exceptor(): @pytest.mark.asyncio -async def test_on_exception_callable_max_tries_reads_every_time(monkeypatch): +async def test_on_exception_callable_max_tries_reads_every_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr("asyncio.sleep", _await_none) lookups = [] @@ -670,7 +672,7 @@ async def exceptor(): @pytest.mark.asyncio -async def test_on_exception_callable_gen_kwargs(): +async def test_on_exception_callable_gen_kwargs() -> None: def lookup_foo(): return "foo" @@ -690,7 +692,7 @@ async def exceptor(): @pytest.mark.asyncio -async def test_on_exception_coro_cancelling(): +async def test_on_exception_coro_cancelling() -> None: sleep_started_event = asyncio.Event() @backoff.on_predicate(backoff.expo) diff --git a/tests/test_integration.py b/tests/test_integration.py index 56b98a9..fd1c828 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,6 +4,7 @@ backoff patterns. """ +import pytest import requests import responses from requests import HTTPError @@ -12,7 +13,7 @@ @responses.activate -def test_on_predicate_runtime(monkeypatch): +def test_on_predicate_runtime(monkeypatch: pytest.MonkeyPatch) -> None: log = [] def sleep(seconds): @@ -43,7 +44,7 @@ def get_url(): @responses.activate -def test_on_exception_runtime(monkeypatch): +def test_on_exception_runtime(monkeypatch: pytest.MonkeyPatch) -> None: log = [] def sleep(seconds): diff --git a/tests/test_jitter.py b/tests/test_jitter.py index da103cb..c145119 100644 --- a/tests/test_jitter.py +++ b/tests/test_jitter.py @@ -1,7 +1,7 @@ import backoff -def test_full_jitter(): +def test_full_jitter() -> None: for input in range(100): for _i in range(100): jitter = backoff.full_jitter(input) diff --git a/tests/test_package.py b/tests/test_package.py index 41c1fbc..4618d5c 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -12,13 +12,13 @@ import tomli as tomllib -def test_version(): +def test_version() -> None: assert version("python-backoff") == backoff.__version__, ( f"Version in __init__.py ({backoff.__version__}) does not match version in pyproject.toml ({version('python-backoff')})" ) -def test_python_classifiers(): +def test_python_classifiers() -> None: with open("pyproject.toml", "rb") as f: data = tomllib.load(f) diff --git a/tests/test_wait_gen.py b/tests/test_wait_gen.py index 95e64ad..d2fc238 100644 --- a/tests/test_wait_gen.py +++ b/tests/test_wait_gen.py @@ -5,63 +5,63 @@ import backoff -def test_decay(): +def test_decay() -> None: gen = backoff.decay() gen.send(None) for i in range(10): assert math.e**-i == next(gen) -def test_decay_init100(): +def test_decay_init100() -> None: gen = backoff.decay(initial_value=100) gen.send(None) for i in range(10): assert 100 * math.e**-i == next(gen) -def test_decay_init100_decay3(): +def test_decay_init100_decay3() -> None: gen = backoff.decay(initial_value=100, decay_factor=3) gen.send(None) for i in range(10): assert 100 * math.e ** (-i * 3) == next(gen) -def test_decay_init100_decay3_min5(): +def test_decay_init100_decay3_min5() -> None: gen = backoff.decay(initial_value=100, decay_factor=3, min_value=5) gen.send(None) for i in range(10): assert max(100 * math.e ** (-i * 3), 5) == next(gen) -def test_expo(): +def test_expo() -> None: gen = backoff.expo() gen.send(None) for i in range(9): assert 2**i == next(gen) -def test_expo_base3(): +def test_expo_base3() -> None: gen = backoff.expo(base=3) gen.send(None) for i in range(9): assert 3**i == next(gen) -def test_expo_factor3(): +def test_expo_factor3() -> None: gen = backoff.expo(factor=3) gen.send(None) for i in range(9): assert 3 * 2**i == next(gen) -def test_expo_base3_factor5(): +def test_expo_base3_factor5() -> None: gen = backoff.expo(base=3, factor=5) gen.send(None) for i in range(9): assert 5 * 3**i == next(gen) -def test_expo_max_value(): +def test_expo_max_value() -> None: gen = backoff.expo(max_value=2**4) gen.send(None) expected = [1, 2, 4, 8, 16, 16, 16] @@ -69,7 +69,7 @@ def test_expo_max_value(): assert expect == next(gen) -def test_expo_max_value_factor(): +def test_expo_max_value_factor() -> None: gen = backoff.expo(factor=3, max_value=2**4) gen.send(None) expected = [3 * 1, 3 * 2, 3 * 4, 16, 16, 16, 16] @@ -77,7 +77,7 @@ def test_expo_max_value_factor(): assert expect == next(gen) -def test_fibo(): +def test_fibo() -> None: gen = backoff.fibo() gen.send(None) expected = [1, 1, 2, 3, 5, 8, 13] @@ -85,7 +85,7 @@ def test_fibo(): assert expect == next(gen) -def test_fibo_max_value(): +def test_fibo_max_value() -> None: gen = backoff.fibo(max_value=8) gen.send(None) expected = [1, 1, 2, 3, 5, 8, 8, 8] @@ -93,7 +93,7 @@ def test_fibo_max_value(): assert expect == next(gen) -def test_constant(): +def test_constant() -> None: gen = backoff.constant(interval=3) gen.send(None) for _i in range(9): @@ -107,7 +107,7 @@ def test_constant(): assert next(gen, None) is None -def test_runtime(): +def test_runtime() -> None: gen = backoff.runtime(value=lambda x: x) gen.send(None) for i in range(20):