Fix static typing of @retry decorated instance methods - #668
Conversation
| self, | ||
| sleep: t.Callable[ | ||
| [int | float], None | t.Awaitable[None] | ||
| [int | float], t.Awaitable[None] | None |
There was a problem hiding this comment.
_RetryDecorated had no __get__, so mypy and pyright treated instance.method the same as the unbound Class.method: an ordinary call like instance.method(value=1) demanded an explicit self argument and the return type resolved to Unknown/Any. Add __get__ overloads so attribute access through an instance returns the bound form with the correct return type. The runtime object is a real function from functools.wraps and already binds correctly, so this is purely a static analysis fix.
6b1e0fc to
895ce25
Compare
Merge Queue Status
This pull request spent 6 seconds in the queue, including 2 seconds running CI. Required conditions to merge
ReasonPull request #668 has been dequeued queue conditions no longer match. Blocked by:
HintYou should look at the reason for the failure and decide if the pull request needs to be fixed or if you want to requeue it. Requeued — the merge queue status continues in this comment ↓. |
Merge Queue Status
This pull request spent 19 seconds in the queue, including 2 seconds running CI. Required conditions to merge
|
Fixes #532
What changed and why
_RetryDecorated, the Protocol tenacity uses as the static return type of@retry-decorated callables, did not implement__get__. Without a descriptor, type checkers (mypy, pyright/pylance) treatinstance.methodthe same as the unboundClass.method, so an ordinary call likeinstance.method(value=1)looks like it is missingself, and the return type resolves toUnknown/Anyinstead of the real return type. That matches the reporter's symptoms exactly: "No parameter named url" (keyword arguments no longer line up onceselfis expected positionally) and a return type that shows up asUnknown | Awaitable[Unknown].This reproduces with a plain synchronous method too, no third party decorator involved:
This is purely a static analysis bug. At runtime the wrapper produced by
wraps()is a realfunctools.wraps-decorated function, and real functions always bindselfcorrectly, so nothing was broken for callers who are not running a type checker.The fix adds
__get__overloads to_RetryDecorated: accessed via the class it keeps returning the full unbound signature, accessed via an instance it returns_RetryDecorated[..., R], i.e. self stripped, return type preserved.Concatenate-based self stripping is not expressible generically for an arbitrary already-capturedParamSpecwith today's typing spec, so...is used for the bound parameter list, matching the pragmatic approach used elsewhere for generic method decorator stubs.Test plan
Added
tests/test_tenacity.py::TestRetryTyping::test_retry_decorated_method_keeps_bound_signature, which decorates an instance method and calls it the normal bound way. Since this repo'smypy --strictgate also coverstests/, this test doubles as the regression check: I verified by temporarily reverting thetenacity/__init__.pychange that mypy fails on this exact test with:With the fix restored:
Also added a reno release note following the convention used for the comparable #519 typing fix.
Note on the extra commit
The first CI run on this PR failed lint on 14 pre-existing
RUF036violations acrosstenacity/__init__.py,tenacity/asyncio/__init__.py, andtenacity/retry.py, none of them on lines this PR touches.ruffis not version-pinned inpyproject.toml, and the last green run onmain(same base commit as this branch) was on 2026-08-01, so a newerruffrelease appears to have started enforcingRUF036("None not at the end of a type union") in between. That meansmainwould fail the same lint check if re-run today, independent of this PR.Rather than leave the PR red over something unrelated, the second commit reorders
Noneto the end of each flagged union (ruff check --fix --unsafe-fixes ., thenruff format .). Pure mechanical reordering, no behavior change - verified with a fullmypy+pytest+ruffrun after. Happy to drop that commit and rebase oncemainis fixed separately, if you'd rather keep it out of this PR.