Skip to content

[fastapi] Handle callable class dependencies with __call__ method (FAST003)#23553

Merged
ntBre merged 7 commits intoastral-sh:mainfrom
stakeswky:fix/fast003-callable-class-dependency
Feb 27, 2026
Merged

[fastapi] Handle callable class dependencies with __call__ method (FAST003)#23553
ntBre merged 7 commits intoastral-sh:mainfrom
stakeswky:fix/fast003-callable-class-dependency

Conversation

@stakeswky
Copy link
Contributor

Fixes #23526

Problem

When a class with a __call__ method (but no __init__) is used as a FastAPI dependency, FAST003 emits a false positive:

class Query:
    def __call__(self, thing_id: int):
        pass

@app.get("/things/{thing_id}")
async def read_thing(query: Annotated[str, Depends(Query)]): ...
# FAST003: Parameter `thing_id` appears in route path, but not in `read_thing` signature

Root Cause

In from_dependency_name, the ClassDefinition branch checked for Pydantic base models and __init__, but returned None (not Some(Self::Unknown)) when neither was found. This caused the dependency to be silently skipped in the caller, leaving the path parameter unmatched.

Fix

Two changes:

  1. Fall back to __call__ when no __init__ is found. This correctly handles the callable instance pattern from FastAPI's docs, where an instance with __call__ is passed to Depends.

  2. Return Some(Self::Unknown) instead of None when neither __init__ nor __call__ exists, so we conservatively suppress the diagnostic rather than emitting a false positive.

Tests

Added four new test cases:

  • Callable class with __call__(self, thing_id) → no diagnostic ✓
  • Class with both __init__(self, thing_id) and __call__ → no diagnostic (uses __init__) ✓
  • Callable class where path param is NOT in __call__ → FAST003 emitted ✓
  • Empty class (no __init__, no __call__) → no diagnostic (Unknown) ✓

Copy link
Contributor

@ntBre ntBre left a comment

Choose a reason for hiding this comment

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

Thanks! I had a couple of small questions/suggestions and then realized I had a follow up question on the issue itself.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 25, 2026

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Copy link
Contributor Author

@stakeswky stakeswky left a comment

Choose a reason for hiding this comment

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

Thanks for the thorough review! I've reworked the approach based on your question on the issue.

After testing, the key insight is:

  • Depends(Query) — FastAPI calls the class constructor, so params come from __init__
  • Depends(Query()) — FastAPI calls the instance, so params come from __call__

The original fix was wrong to conflate both cases. The new approach:

  1. Splits from_depends_call to handle both Name and Call expressions
  2. from_dependency_name → class reference → uses __init__
  3. from_dependency_instance → class instance → uses __call__
  4. Shared class_params_from_method helper for both paths

The matches! nit no longer applies since the find() with OR is gone. The NoneUnknown change is kept — when a class has no matching method, returning Unknown suppresses false positives (we know there's a dependency, just can't analyze it).

@ntBre
Copy link
Contributor

ntBre commented Feb 25, 2026

I'm going to wait to confirm my understanding with the issue author before reviewing this again, but I think this aligns with my current understanding.

stakeswky and others added 3 commits February 26, 2026 14:40
When a class is used as a FastAPI dependency via `Depends(SomeClass)` and
the class has a `__call__` method but no `__init__`, the rule previously
returned `None` from `from_dependency_name`, causing the dependency to be
skipped entirely. This led to a false positive FAST003 diagnostic because
the path parameter was not found in the (empty) dependency parameter list.

Two changes:
1. Fall back to `__call__` when no `__init__` is found, so callable-class
   dependencies (as documented in FastAPI's advanced dependencies guide) are
   handled correctly.
2. Return `Some(Self::Unknown)` instead of `None` when neither `__init__`
   nor `__call__` is present, so we conservatively suppress the diagnostic
   rather than emitting a false positive.

Fixes astral-sh#23526
Rework the dependency resolution to correctly distinguish between:
- Depends(SomeClass) — class reference, FastAPI calls __init__
- Depends(SomeClass()) — instance, FastAPI calls __call__

Previously the code conflated both cases by checking __init__ OR __call__
in a single find(), which was both incorrect and misleading.

Now:
- from_dependency_name: handles Name refs, uses __init__ for classes
- from_dependency_instance: handles Call exprs, uses __call__ for classes
- class_params_from_method: shared helper for both paths

Also applies the matches!() nit from review and restores the original
'Skip self parameter' comment.
Per ntBre's feedback, the else branch should remain `return None`
as in the original code.
@stakeswky stakeswky force-pushed the fix/fast003-callable-class-dependency branch from c90a5c7 to ece6354 Compare February 26, 2026 06:40
@stakeswky
Copy link
Contributor Author

Addressed the review feedback:

  • Reverted the return NoneSome(Self::Unknown) change as suggested
  • Rebased onto latest main

Re the approach: the issue discussion confirmed that __init__ for Depends(Query) and __call__ for Depends(Query()) is the right split, which is what the current code does via from_dependency_name and from_dependency_instance respectively.

@stakeswky
Copy link
Contributor Author

Thanks for checking! Confirmed — the PR already handles the two cases separately:

  • Depends(Query) (class) → from_dependency_name → resolves via __init__ (existing behavior, unchanged)
  • Depends(Query()) (instance) → from_dependency_instance → resolves via __call__ (new behavior added by this PR)

So __call__ is only consulted when FastAPI receives an already-constructed instance. Also just pushed a snapshot format update to fix the failing cargo tests (the insta snapshot was in legacy format).

@stakeswky
Copy link
Contributor Author

Thanks for confirming on the issue! That aligns with the current implementation — the PR only checks __call__ for the Depends(Query()) pattern (call expression), while Depends(Query) (name reference) continues to use __init__ as before. The two paths are split into from_dependency_instance (for call expressions → __call__) and from_dependency_name (for name references → __init__). Let me know if you'd like any adjustments.

Copy link
Contributor

@ntBre ntBre left a comment

Choose a reason for hiding this comment

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

Thanks! There was a .snap.new file mistakenly added, so I pushed a commit removing that and also threw in a couple of non-Annotated test cases while I was here.

@ntBre ntBre changed the title fix(FAST003): handle callable class dependencies with __call__ method [fastapi] Handle callable class dependencies with __call__ method (FAST003) Feb 27, 2026
@ntBre ntBre added bug Something isn't working rule Implementing or modifying a lint rule labels Feb 27, 2026
@ntBre ntBre merged commit df7e826 into astral-sh:main Feb 27, 2026
44 checks passed
carljm added a commit that referenced this pull request Feb 27, 2026
* main:
  [ty] Take myself out of the reviewer pool for the next few days (#23618)
  [ty] Fix bug where ty would think that a `Callable` with a variadic positional parameter could be a subtype of a `Callable` with a positional-or-keyword parameter (#23610)
  [`ruff`] Add fix for `none-not-at-end-of-union` (`RUF036`)  (#22829)
  Bump cargo dist to 0.31 (#23614)
  [`pyflakes`] Fix false positive for names shadowing re-exports (`F811`) (#23356)
  [`fastapi`] Handle callable class dependencies with `__call__` method (`FAST003`) (#23553)
  [ty] Recurse into tuples and nested tuples when applying special-cased validation of `isinstance()` and `issubclass()` (#23607)
  Update typing conformance suite commit (#23606)
  [ty] Detect invalid uses of `@final` on non-methods (#23604)
  [ty] Move the type hierarchy request handlers to individual modules
  [ty] Wire up the type hierarchy implementation with the LSP
  [ty] Add routine for mapping from system path to vendored path
  [ty] Implement internal routines for providing the LSP "type hierarchy" feature
  [ty] Add some helper methods on `ClassLiteral`
  [ty] Move some module name helper routines to methods on `ModuleName`
  [ty] Bump version of `lsp-types`
  [ty] Refactor to support building constraint sets differently (#23600)
  [ty] Dataclass transform: neither frozen nor non-frozen (#23366)
  [ty] Add snapshot tests for advanced `invalid-assignment` scenarios (#23581)
  [ty] disallow negative narrowing on SubclassOf types (#23598)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using a callable as a FastAPI dependency leads to false positive FAST003 errors

2 participants