From f2df8d0aae136f150b098670cf9e6eec0f1d38b6 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 17:38:16 +0200 Subject: [PATCH 1/6] ci: run on pull requests targeting any base branch `on.pull_request.branches` filters on the *base* branch, not the head. With it pinned to `main`, only a pull request merging directly into `main` ran CI -- every stacked pull request, whose base is the branch below it in the stack, got no test, lint or mypy run at all and showed nothing but the Mergify checks. Drop the filter so CI runs for every pull request regardless of base. Change-Id: I1c211a106a57b9f4986bec4b76c0680c1a0f1b61 --- .github/workflows/ci.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6bc31665..c8b7017c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,10 @@ name: Continuous Integration permissions: read-all on: + # No `branches:` filter: that matches the *base* branch, so restricting it to + # `main` silently skipped CI on every stacked pull request, whose base is the + # branch below it in the stack rather than `main`. pull_request: - branches: - - main concurrency: # yamllint disable-line rule:line-length From ae08acf20ff5f05bb730560a8913aba0a070fa25 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 15:50:47 +0200 Subject: [PATCH 2/6] fix: annotate wait_base.__radd__ as taking the int seed from sum() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `__radd__` was annotated `other: wait_base`, which is the one type it can never receive: `wait_base.__add__` always succeeds, so Python never falls back to the reflected operator for two wait strategies. The only caller is `sum()`, which seeds its accumulator with the int 0 — hence the `type: ignore[comparison-overlap]` on `other == 0`, and the dead `return self` branch behind it that `--warn-unreachable` flags. Annotate the parameter as `int` and return `NotImplemented` for a non-zero left operand, so `5 + wait_fixed(1)` raises `TypeError` at the addition rather than building a `wait_combine` that explodes later when called. With the signature corrected, mypy infers `sum([...])` over wait strategies on its own, so four `type: ignore[list-item]` comments in `test_wait_arbitrary_sum` are no longer needed. Change-Id: Iad1bcef4412d6afc1a6f1335970cfbfcc4a34fcb --- .../notes/wait-radd-int-6f1a3c9d84b2e057.yaml | 8 ++++++++ tenacity/wait.py | 12 +++++++----- tests/test_tenacity.py | 8 ++++---- 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/wait-radd-int-6f1a3c9d84b2e057.yaml diff --git a/releasenotes/notes/wait-radd-int-6f1a3c9d84b2e057.yaml b/releasenotes/notes/wait-radd-int-6f1a3c9d84b2e057.yaml new file mode 100644 index 00000000..50adb97d --- /dev/null +++ b/releasenotes/notes/wait-radd-int-6f1a3c9d84b2e057.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + `wait_base.__radd__` is now annotated as taking the `int` seed that + `sum()` supplies, so `sum()` over a list of wait strategies type checks + without `type: ignore` on the list items. A non-zero left operand now + returns `NotImplemented`, raising `TypeError` at the point of the + addition instead of building a wait strategy that fails when called. diff --git a/tenacity/wait.py b/tenacity/wait.py index 18fb6ea7..3053a5c1 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -36,11 +36,13 @@ def __call__(self, retry_state: "RetryCallState") -> float: def __add__(self, other: "wait_base") -> "wait_combine": return wait_combine(self, other) - def __radd__(self, other: "wait_base") -> "wait_combine | wait_base": - # make it possible to use multiple waits with the built-in sum function - if other == 0: # type: ignore[comparison-overlap] - return self - return self.__add__(other) + def __radd__(self, other: int) -> "wait_base": + # `sum()` seeds its accumulator with the int 0, so tolerate that to + # make summing waits work. Any other left operand is a `wait_base`, + # whose `__add__` never defers to us. + if other != 0: + return NotImplemented + return self WaitBaseT = wait_base | typing.Callable[["RetryCallState"], float | int] diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index eec9e9b7..8cfd34e5 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -490,10 +490,10 @@ def test_wait_arbitrary_sum(self) -> None: r = Retrying( wait=sum( # type: ignore[arg-type] [ - tenacity.wait_fixed(1), # type: ignore[list-item] - tenacity.wait_random(0, 3), # type: ignore[list-item] - tenacity.wait_fixed(5), # type: ignore[list-item] - tenacity.wait_none(), # type: ignore[list-item] + tenacity.wait_fixed(1), + tenacity.wait_random(0, 3), + tenacity.wait_fixed(5), + tenacity.wait_none(), ] ) ) From 18974987e666a30dceb669cd4a6ac9ee47cb5e9e Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 15:51:17 +0200 Subject: [PATCH 3/6] chore(mypy): enable the strictness options `strict` leaves off `strict = true` is a curated subset, not every check mypy has. These five error codes and three flags are all off under `strict` and all report zero errors on the current tree, so turning them on costs nothing today and catches regressions from here on: - `warn_unreachable` -- dead branches, which are usually a wrong annotation rather than dead code (see the `wait_base.__radd__` fix below this commit) - `disallow_any_unimported` -- silent `Any` leaking in from untyped deps - `extra_checks` -- unsafely overlapping operator signatures, among others - `ignore-without-code` -- keeps `type: ignore` comments specific - `redundant-expr`, `truthy-iterable`, `unused-awaitable`, `exhaustive-match` Deliberately left off: `disallow_any_expr` (655 errors -- unusable for a decorator library) and `disallow_any_decorated` (63 errors, all inherent to `@retry` wrapping untyped test helpers). Change-Id: I9eee43a749050d2bcf0aa0abbedf8725301eba18 --- pyproject.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6c01067c..0a4209fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,4 +117,15 @@ strict = true files = ["tenacity", "tests"] show_error_codes = true exclude = ["tenacity/_version\\.py"] +# `strict` is not "everything" -- these are checks it leaves off. +warn_unreachable = true +disallow_any_unimported = true +extra_checks = true +enable_error_code = [ + "exhaustive-match", + "ignore-without-code", + "redundant-expr", + "truthy-iterable", + "unused-awaitable", +] From 7229e61ebfa7d3db5534ea52a195e496e9773838 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 15:53:27 +0200 Subject: [PATCH 4/6] refactor: drop always-true truthiness checks and enable truthy-bool `truthy-bool` reports objects tested for truthiness that implement neither `__bool__` nor `__len__`, and so can only ever be true. Three sites: `BaseRetrying._run_wait` and `AsyncRetrying._run_wait` both guarded the wait call with `if self.wait:`. `wait` is typed `WaitBaseT` and defaults to a `wait_none()` instance, so it is never falsy and the `sleep = 0.0` branch has been dead since 17aefd9 -- a leftover from when the surrounding code still used `if self.after is not None:` style guards. Call `self.wait` unconditionally. `if tornado:` guarded the optional import in two places. mypy only ever sees the `try` branch, so it resolves the name to the module and reads the test as always-true. Compute `_HAS_TORNADO` once and branch on that instead; this also keeps `tornado.gen` fully typed, which annotating the name as `ModuleType | None` would have thrown away. Change-Id: Icb9981f6797707e070dc2423015f2fbf6c94e4c4 --- pyproject.toml | 1 + tenacity/__init__.py | 16 ++++++++-------- tenacity/asyncio/__init__.py | 9 +++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a4209fe..cec48a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ enable_error_code = [ "exhaustive-match", "ignore-without-code", "redundant-expr", + "truthy-bool", "truthy-iterable", "unused-awaitable", ] diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 21d7f507..4e1bbb2a 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -88,6 +88,11 @@ except ImportError: tornado = None # type: ignore[assignment] +# mypy resolves `tornado` to the module (it only ever sees the `try` branch), +# so testing the module object for truthiness reads as an always-true check. +# Keep the availability answer in a plain bool instead. +_HAS_TORNADO = tornado is not None + if t.TYPE_CHECKING: if sys.version_info >= (3, 11): from typing import Self @@ -403,12 +408,7 @@ def _run_retry(self, retry_state: "RetryCallState") -> None: self.iter_state.retry_run_result = self.retry(retry_state) def _run_wait(self, retry_state: "RetryCallState") -> None: - if self.wait: - sleep = self.wait(retry_state) - else: - sleep = 0.0 - - retry_state.upcoming_sleep = sleep + retry_state.upcoming_sleep = self.wait(retry_state) def _run_stop(self, retry_state: "RetryCallState") -> None: self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start @@ -770,7 +770,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: ): r = AsyncRetrying(*dargs, **dkw) elif ( - tornado + _HAS_TORNADO and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f) ): @@ -785,7 +785,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: from tenacity.asyncio import AsyncRetrying # noqa: E402 -if tornado: +if _HAS_TORNADO: from tenacity.tornadoweb import TornadoRetrying diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 6291b02f..a91ca577 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -142,12 +142,9 @@ async def _run_retry(self, retry_state: "RetryCallState") -> None: # type: igno ) async def _run_wait(self, retry_state: "RetryCallState") -> None: # type: ignore[override] - if self.wait: - sleep = await _utils.wrap_to_async_func(self.wait)(retry_state) - else: - sleep = 0.0 - - retry_state.upcoming_sleep = sleep + retry_state.upcoming_sleep = await _utils.wrap_to_async_func(self.wait)( + retry_state + ) async def _run_stop(self, retry_state: "RetryCallState") -> None: # type: ignore[override] self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start From 227f834b2057fe1623fe8647a6967576bad3d243 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 15:54:43 +0200 Subject: [PATCH 5/6] refactor: declare BaseAction's REPR_FIELDS and NAME as ClassVar `mutable-override` rejects narrowing a mutable attribute in a subclass: `RetryAction.REPR_FIELDS = ("sleep",)` inferred `tuple[str]` against the base's `Sequence[str]`, and `NAME = "retry"` inferred `str` against `str | None`. Both are unsound in general -- code holding a `BaseAction` could assign a longer sequence or `None` through the base type. `BaseAction`'s docstring already calls these class variables, so mark them `ClassVar` and repeat the base annotation on the override. This documents the extension point for subclasses outside tenacity too, which hit the same error when they type check strictly. Change-Id: I012caeaad2f93327c69467776a53e87c573b6403 --- pyproject.toml | 1 + tenacity/__init__.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cec48a4e..518a86e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,7 @@ extra_checks = true enable_error_code = [ "exhaustive-match", "ignore-without-code", + "mutable-override", "redundant-expr", "truthy-bool", "truthy-iterable", diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 4e1bbb2a..e993396c 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -153,8 +153,8 @@ class BaseAction: - NAME: for identification in retry object methods and callbacks """ - REPR_FIELDS: t.Sequence[str] = () - NAME: str | None = None + REPR_FIELDS: t.ClassVar[t.Sequence[str]] = () + NAME: t.ClassVar[str | None] = None def __repr__(self) -> str: state_str = ", ".join( @@ -167,8 +167,8 @@ def __str__(self) -> str: class RetryAction(BaseAction): - REPR_FIELDS = ("sleep",) - NAME = "retry" + REPR_FIELDS: t.ClassVar[t.Sequence[str]] = ("sleep",) + NAME: t.ClassVar[str | None] = "retry" def __init__(self, sleep: t.SupportsFloat) -> None: self.sleep = float(sleep) From dbf34914f7a03dd889480012dbf17b1a48e7f858 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 15:56:26 +0200 Subject: [PATCH 6/6] test: fix possibly-undefined and deprecated call sites Two more error codes mypy leaves off under `strict`, both with real hits in the test suite. `possibly-undefined`: `test_retry_state` bound `retry_state` only inside an `except ExtractCallState` block, then used it unconditionally. Had the retry stopped raising, the test would have failed with `NameError` instead of a useful assertion. Use `assertRaises` as a context manager, which both asserts the exception is raised and binds the state unconditionally. `deprecated`: `asyncio.iscoroutinefunction` is deprecated since 3.14 and removed in 3.16, and was emitting a `DeprecationWarning` on every test run. The line right below it already asserts the same property via `inspect.iscoroutinefunction`, which is the documented replacement, so drop the deprecated call rather than pin the suite to an API that is going away. Change-Id: I135cf5364f3e471d954c878f2599be7441104971 --- pyproject.toml | 2 ++ tests/test_asyncio.py | 1 - tests/test_tenacity.py | 10 ++++------ 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 518a86e8..dba836ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,9 +122,11 @@ warn_unreachable = true disallow_any_unimported = true extra_checks = true enable_error_code = [ + "deprecated", "exhaustive-match", "ignore-without-code", "mutable-override", + "possibly-undefined", "redundant-expr", "truthy-bool", "truthy-iterable", diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 96560b07..c9d58027 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -85,7 +85,6 @@ async def test_retry(self) -> None: @asynctest async def test_iscoroutinefunction(self) -> None: - assert asyncio.iscoroutinefunction(_retryable_coroutine) assert inspect.iscoroutinefunction(_retryable_coroutine) @asynctest diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 8cfd34e5..d5dd8f17 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -692,10 +692,9 @@ def waitfunc(retry_state: RetryCallState) -> float: def returnval() -> int: return 123 - try: + with self.assertRaises(ExtractCallState) as caught: retrying(returnval) - except ExtractCallState as err: - retry_state = err.args[0] + retry_state = caught.exception.args[0] self.assertIs(retry_state.fn, returnval) self.assertEqual(retry_state.args, ()) self.assertEqual(retry_state.kwargs, {}) @@ -706,10 +705,9 @@ def returnval() -> int: def dying() -> None: raise Exception("Broken") - try: + with self.assertRaises(ExtractCallState) as caught: retrying(dying) - except ExtractCallState as err: - retry_state = err.args[0] + retry_state = caught.exception.args[0] self.assertIs(retry_state.fn, dying) self.assertEqual(retry_state.args, ()) self.assertEqual(retry_state.kwargs, {})