diff --git a/backoff/_async.py b/backoff/_async.py index f677424..4645a28 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -84,7 +84,7 @@ async def _call_handlers( def retry_predicate( - target: Callable[P, T], + target: Callable[P, Coroutine[Any, Any, T]], wait_gen: _WaitGenerator, predicate: _Predicate[T], *, @@ -96,7 +96,7 @@ def retry_predicate( on_backoff: Iterable[_Handler], on_giveup: Iterable[_Handler], wait_gen_kwargs: dict[str, Any], -) -> Callable[P, T]: +) -> Callable[P, Coroutine[Any, Any, T]]: on_try = _ensure_coroutines(on_try) on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) @@ -158,7 +158,7 @@ async def retry(*args: P.args, **kwargs: P.kwargs) -> T: return ret - return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] + return retry def _adapt_context_handlers( @@ -181,7 +181,7 @@ async def adapted(details: ContextDetails) -> None: def retry_exception( - target: Callable[P, T], + target: Callable[P, Coroutine[Any, Any, T]], wait_gen: _WaitGenerator, exception: _MaybeTuple[type[Exception]], *, @@ -195,7 +195,7 @@ def retry_exception( on_giveup: Iterable[_Handler], raise_on_giveup: bool, wait_gen_kwargs: dict[str, Any], -) -> Callable[P, T]: +) -> Callable[P, Coroutine[Any, Any, T]]: on_try = _ensure_coroutines(on_try) on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) @@ -228,11 +228,11 @@ async def retry( wait_gen_kwargs=wait_gen_kwargs, ): with attempt: - ret = await target(*args, **kwargs) # type: ignore[misc] # ty:ignore[invalid-await] + ret = await target(*args, **kwargs) return ret - return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] + return retry async def _dispatch_handlers( diff --git a/backoff/_decorator.py b/backoff/_decorator.py index 77889a8..b8ad327 100644 --- a/backoff/_decorator.py +++ b/backoff/_decorator.py @@ -40,6 +40,7 @@ T = TypeVar("T") P = ParamSpec("P") + CallableT = TypeVar("CallableT", bound=Callable[..., Any]) def on_predicate( @@ -57,7 +58,7 @@ def on_predicate( backoff_log_level: int = logging.INFO, giveup_log_level: int = logging.ERROR, **wait_gen_kwargs: Any, -) -> Callable[[Callable[P, T]], Callable[P, T]]: +) -> Callable[[CallableT], CallableT]: """Returns decorator for backoff and retry triggered by predicate. Args: @@ -125,11 +126,21 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: ) if inspect.iscoroutinefunction(target): - retry = _async.retry_predicate - else: - retry = _sync.retry_predicate - - return retry( + return _async.retry_predicate( # type: ignore[return-value] # ty:ignore[invalid-return-type] + target, + wait_gen, + predicate, + max_tries=max_tries, + max_time=max_time, + jitter=jitter, + on_try=on_try, + on_success=on_success, + on_backoff=on_backoff, + on_giveup=on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ) + + return _sync.retry_predicate( target, wait_gen, predicate, @@ -144,7 +155,7 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: ) # Return a function which decorates a target with a retry loop. - return decorate + return decorate # type: ignore[return-value] def on_exception( @@ -164,7 +175,7 @@ def on_exception( backoff_log_level: int = logging.INFO, giveup_log_level: int = logging.ERROR, **wait_gen_kwargs: Any, -) -> Callable[[Callable[P, T]], Callable[P, T]]: +) -> Callable[[CallableT], CallableT]: """Returns decorator for backoff and retry triggered by exception. Args: @@ -234,11 +245,23 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: ) if inspect.iscoroutinefunction(target): - retry = _async.retry_exception - else: - retry = _sync.retry_exception - - return retry( + return _async.retry_exception( # type: ignore[return-value] # ty:ignore[invalid-return-type] + target, + wait_gen, + exception, + max_tries=max_tries, + max_time=max_time, + jitter=jitter, + giveup=giveup, + on_try=on_try, + on_success=on_success, + on_backoff=on_backoff, + on_giveup=on_giveup, + raise_on_giveup=raise_on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ) + + return _sync.retry_exception( target, wait_gen, exception, @@ -255,7 +278,7 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: ) # Return a function which decorates a target with a retry loop. - return decorate + return decorate # type: ignore[return-value] def retry_context( diff --git a/backoff/_typing.py b/backoff/_typing.py index 499b64c..95a49af 100644 --- a/backoff/_typing.py +++ b/backoff/_typing.py @@ -48,7 +48,6 @@ class ContextDetails(_BaseContextDetails, _ContextCallDetails, total=False): T = TypeVar("T") -_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) # ruff:ignore[unused-private-type-var] _Handler = Union[ Callable[[Details], None], Callable[[Details], Coroutine[Any, Any, None]], diff --git a/pyproject.toml b/pyproject.toml index 5378468..8c01c31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,6 +230,10 @@ warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true +[[tool.mypy.overrides]] +module = "tests.test_typing" +disallow_any_decorated = true + [tool.ruff.lint.per-file-ignores] "**/doccmd_*.py" = [ "redefined-while-unused", diff --git a/tests/test_typing.py b/tests/test_typing.py index 646a558..7f58173 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -1,3 +1,13 @@ +import asyncio +import sys +from collections.abc import Callable, Coroutine +from typing import Any + +if sys.version_info >= (3, 11): + from typing import assert_type +else: + from typing_extensions import assert_type + import backoff # No pyunit tests are defined here yet, but the following decorator calls will @@ -10,7 +20,7 @@ jitter=None, max_tries=3, ) -def foo(): +def foo() -> None: raise ValueError() @@ -20,7 +30,7 @@ def foo(): interval=1, max_tries=3, ) -def bar(): +def bar() -> None: raise ValueError() @@ -30,5 +40,23 @@ def bar(): value=lambda r: int(r.headers.get("Retry-After")), jitter=None, ) -def baz(): +def baz() -> None: pass + + +# Regression test for https://github.com/python-backoff/backoff/issues/200: +# decorating an annotated async function must preserve its parameter and +# awaited result types, without reconstructing it as `Callable[P, +# Coroutine[Any, Any, T]]` (which mypy's `disallow_any_decorated` flags as +# leaking `Any`, since an async function is already typed that way). +@backoff.on_exception(backoff.expo, ValueError) +async def fetch(x: int) -> str: + return str(x) + + +_typed_fetch: Callable[[int], Coroutine[Any, Any, str]] = fetch + + +async def _use_fetch() -> None: + result = await asyncio.create_task(fetch(1)) + assert_type(result, str)