Skip to content

Fix static typing of @retry decorated instance methods - #668

Merged
mergify[bot] merged 1 commit into
jd:mainfrom
syzayd:fix/532-retry-decorated-method-typing
Aug 5, 2026
Merged

Fix static typing of @retry decorated instance methods#668
mergify[bot] merged 1 commit into
jd:mainfrom
syzayd:fix/532-retry-decorated-method-typing

Conversation

@syzayd

@syzayd syzayd commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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) treat instance.method the same as the unbound Class.method, so an ordinary call like instance.method(value=1) looks like it is missing self, and the return type resolves to Unknown/Any instead of the real return type. That matches the reporter's symptoms exactly: "No parameter named url" (keyword arguments no longer line up once self is expected positionally) and a return type that shows up as Unknown | Awaitable[Unknown].

This reproduces with a plain synchronous method too, no third party decorator involved:

class Foo:
    @retry(stop=stop_after_attempt(3))
    def double(self, value: int) -> int:
        return value * 2

f = Foo()
reveal_type(f.double)   # same type as Foo.double, self still required

This is purely a static analysis bug. At runtime the wrapper produced by wraps() is a real functools.wraps-decorated function, and real functions always bind self correctly, 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-captured ParamSpec with 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's mypy --strict gate also covers tests/, this test doubles as the regression check: I verified by temporarily reverting the tenacity/__init__.py change that mypy fails on this exact test with:

Missing positional argument "self" in call to "__call__" of "_RetryDecorated"  [call-arg]

With the fix restored:

$ mypy tenacity tests
Success: no issues found in 19 source files

$ pytest -q
174 passed, 1 warning, 12 subtests passed in 2.87s

$ ruff check .
All checks passed!

$ ruff format --check .
20 files already formatted

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 RUF036 violations across tenacity/__init__.py, tenacity/asyncio/__init__.py, and tenacity/retry.py, none of them on lines this PR touches. ruff is not version-pinned in pyproject.toml, and the last green run on main (same base commit as this branch) was on 2026-08-01, so a newer ruff release appears to have started enforcing RUF036 ("None not at the end of a type union") in between. That means main would 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 None to the end of each flagged union (ruff check --fix --unsafe-fixes ., then ruff format .). Pure mechanical reordering, no behavior change - verified with a full mypy + pytest + ruff run after. Happy to drop that commit and rebase once main is fixed separately, if you'd rather keep it out of this PR.

self,
sleep: t.Callable[
[int | float], None | t.Awaitable[None]
[int | float], t.Awaitable[None] | None

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like noise/churn here?

@syzayd syzayd Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair call, reverted. That commit was fixing an unrelated pre-existing RUF036 lint failure (ruff isn't pinned, a newer release started flagging 14 pre-existing type-union orderings repo-wide, unrelated to this PR's diff). Split it out into #669 so this PR stays focused on the #532 fix.

jd
jd previously approved these changes Aug 5, 2026
_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.
@syzayd
syzayd force-pushed the fix/532-retry-decorated-method-typing branch from 6b1e0fc to 895ce25 Compare August 5, 2026 11:32
@mergify
mergify Bot dismissed jd’s stale review August 5, 2026 11:33

Pull request has been modified.

@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-08-05 11:33 UTC · Rule: default · triggered by rule autoqueue
  • Checks skipped · PR is already up-to-date
  • 🚫 Left the queue2026-08-05 11:33 UTC · at 895ce2583b58b83e44ae6ef1a6b0a26b42f773a3

This pull request spent 6 seconds in the queue, including 2 seconds running CI.

Required conditions to merge

Reason

Pull request #668 has been dequeued

queue conditions no longer match. Blocked by:

  • #approved-reviews-by >= 1

  • author = dependabot[bot]

  • author = jd

  • any of:

    • #approved-reviews-by >= 1
    • author = dependabot[bot]
    • author = jd

Hint

You 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.
If you do update this pull request, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a @mergifyio queue comment.

Requeued — the merge queue status continues in this comment ↓.

@syzayd

syzayd commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #670/#671 landed - picks up the ruff/mypy pins so CI is green again. This is ready to merge whenever you get a chance.

@mergify mergify Bot removed the queued label Aug 5, 2026
@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

This pull request spent 19 seconds in the queue, including 2 seconds running CI.

Required conditions to merge

@mergify mergify Bot added the queued label Aug 5, 2026
@mergify
mergify Bot merged commit a2af454 into jd:main Aug 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pyright/pylance typing error with tenacity "Unknown | Awaitable[Unknown]"

2 participants