]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
⚡️ Reduce memory usage in dependencies (#16049)
authorSebastián Ramírez <tiangolo@gmail.com>
Fri, 24 Jul 2026 21:07:51 +0000 (23:07 +0200)
committerGitHub <noreply@github.com>
Fri, 24 Jul 2026 21:07:51 +0000 (21:07 +0000)
fastapi/dependencies/models.py
fastapi/dependencies/utils.py
fastapi/openapi/utils.py
fastapi/routing.py
tests/test_dependency_models.py [new file with mode: 0644]

index 25ffb0d2da81071cd220b8d7bccd79b5cb795a23..3ae682cb396099d196d2db11bc26e3e07b345a30 100644 (file)
@@ -2,7 +2,7 @@ import inspect
 import sys
 from collections.abc import Callable
 from dataclasses import dataclass, field
-from functools import cached_property, partial
+from functools import lru_cache, partial
 from typing import Any, Literal
 
 from fastapi._compat import ModelField
@@ -28,7 +28,7 @@ def _impartial(func: Callable[..., Any]) -> Callable[..., Any]:
     return func
 
 
-@dataclass
+@dataclass(slots=True)
 class Dependant:
     path_params: list[ModelField] = field(default_factory=list)
     query_params: list[ModelField] = field(default_factory=list)
@@ -50,144 +50,188 @@ class Dependant:
     path: str | None = None
     scope: Literal["function", "request"] | None = None
 
-    @cached_property
-    def oauth_scopes(self) -> list[str]:
-        scopes = self.parent_oauth_scopes.copy() if self.parent_oauth_scopes else []
-        # This doesn't use a set to preserve order, just in case
-        for scope in self.own_oauth_scopes or []:
-            if scope not in scopes:
-                scopes.append(scope)
-        return scopes
-
-    @cached_property
-    def cache_key(self) -> DependencyCacheKey:
-        scopes_for_cache = (
-            tuple(sorted(set(self.oauth_scopes or []))) if self._uses_scopes else ()
-        )
-        return (
-            self.call,
-            scopes_for_cache,
-            self.computed_scope or "",
+
+_UsesScopesCache = dict[int, tuple[Dependant, bool]]
+
+
+class _CallIdentity:
+    __slots__ = ("call",)
+
+    def __init__(self, call: Callable[..., Any]) -> None:
+        self.call = call
+
+    def __hash__(self) -> int:
+        return id(self.call)
+
+    def __eq__(self, other: object) -> bool:
+        return isinstance(other, _CallIdentity) and self.call is other.call
+
+
+def _get_oauth_scopes(*, dependant: Dependant) -> list[str]:
+    scopes = (
+        dependant.parent_oauth_scopes.copy() if dependant.parent_oauth_scopes else []
+    )
+    # This doesn't use a set to preserve order, just in case
+    for scope in dependant.own_oauth_scopes or []:
+        if scope not in scopes:
+            scopes.append(scope)
+    return scopes
+
+
+def _get_cache_key(
+    *,
+    dependant: Dependant,
+    uses_scopes_cache: _UsesScopesCache | None = None,
+) -> DependencyCacheKey:
+    scopes_for_cache = (
+        tuple(sorted(set(_get_oauth_scopes(dependant=dependant))))
+        if _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
+        else ()
+    )
+    return (
+        dependant.call,
+        scopes_for_cache,
+        _get_computed_scope(dependant=dependant) or "",
+    )
+
+
+def _uses_scopes(
+    *, dependant: Dependant, cache: _UsesScopesCache | None = None
+) -> bool:
+    if cache is None:
+        cache = {}
+    cache_key = id(dependant)
+    cached = cache.get(cache_key)
+    if cached is not None and cached[0] is dependant:
+        return cached[1]
+    if dependant.own_oauth_scopes:
+        result = True
+    elif dependant.security_scopes_param_name is not None:
+        result = True
+    elif _is_security_scheme(dependant=dependant):
+        result = True
+    else:
+        result = any(
+            _uses_scopes(dependant=sub_dep, cache=cache)
+            for sub_dep in dependant.dependencies
         )
+    cache[cache_key] = (dependant, result)
+    return result
 
-    @cached_property
-    def _uses_scopes(self) -> bool:
-        if self.own_oauth_scopes:
-            return True
-        if self.security_scopes_param_name is not None:
-            return True
-        if self._is_security_scheme:
-            return True
-        for sub_dep in self.dependencies:
-            if sub_dep._uses_scopes:
-                return True
-        return False
 
-    @cached_property
-    def _is_security_scheme(self) -> bool:
-        if self.call is None:
-            return False  # pragma: no cover
-        unwrapped = _unwrapped_call(self.call)
-        return isinstance(unwrapped, SecurityBase)
-
-    # Mainly to get the type of SecurityBase, but it's the same self.call
-    @cached_property
-    def _security_scheme(self) -> SecurityBase:
-        unwrapped = _unwrapped_call(self.call)
-        assert isinstance(unwrapped, SecurityBase)
-        return unwrapped
-
-    @cached_property
-    def _security_dependencies(self) -> list["Dependant"]:
-        security_deps = [dep for dep in self.dependencies if dep._is_security_scheme]
-        return security_deps
-
-    @cached_property
-    def is_gen_callable(self) -> bool:
-        if self.call is None:
-            return False  # pragma: no cover
-        if inspect.isgeneratorfunction(
-            _impartial(self.call)
-        ) or inspect.isgeneratorfunction(_unwrapped_call(self.call)):
-            return True
-        if inspect.isclass(_unwrapped_call(self.call)):
-            return False
-        dunder_call = getattr(_impartial(self.call), "__call__", None)  # noqa: B004
-        if dunder_call is None:
-            return False  # pragma: no cover
-        if inspect.isgeneratorfunction(
-            _impartial(dunder_call)
-        ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_call)):
-            return True
-        dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None)  # noqa: B004
-        if dunder_unwrapped_call is None:
-            return False  # pragma: no cover
-        if inspect.isgeneratorfunction(
-            _impartial(dunder_unwrapped_call)
-        ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_unwrapped_call)):
-            return True
-        return False
+def _is_security_scheme(*, dependant: Dependant) -> bool:
+    if dependant.call is None:
+        return False  # pragma: no cover
+    unwrapped = _unwrapped_call(dependant.call)
+    return isinstance(unwrapped, SecurityBase)
+
+
+def _get_security_scheme(*, dependant: Dependant) -> SecurityBase:
+    # Mainly to get the type of SecurityBase, but it's the same dependant.call
+    unwrapped = _unwrapped_call(dependant.call)
+    assert isinstance(unwrapped, SecurityBase)
+    return unwrapped
 
-    @cached_property
-    def is_async_gen_callable(self) -> bool:
-        if self.call is None:
-            return False  # pragma: no cover
-        if inspect.isasyncgenfunction(
-            _impartial(self.call)
-        ) or inspect.isasyncgenfunction(_unwrapped_call(self.call)):
-            return True
-        if inspect.isclass(_unwrapped_call(self.call)):
-            return False
-        dunder_call = getattr(_impartial(self.call), "__call__", None)  # noqa: B004
-        if dunder_call is None:
-            return False  # pragma: no cover
-        if inspect.isasyncgenfunction(
-            _impartial(dunder_call)
-        ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_call)):
-            return True
-        dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None)  # noqa: B004
-        if dunder_unwrapped_call is None:
-            return False  # pragma: no cover
-        if inspect.isasyncgenfunction(
-            _impartial(dunder_unwrapped_call)
-        ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_unwrapped_call)):
-            return True
-        return False
 
-    @cached_property
-    def is_coroutine_callable(self) -> bool:
-        if self.call is None:
-            return False  # pragma: no cover
-        if inspect.isroutine(_impartial(self.call)) and iscoroutinefunction(
-            _impartial(self.call)
-        ):
-            return True
-        if inspect.isroutine(_unwrapped_call(self.call)) and iscoroutinefunction(
-            _unwrapped_call(self.call)
-        ):
-            return True
-        if inspect.isclass(_unwrapped_call(self.call)):
-            return False
-        dunder_call = getattr(_impartial(self.call), "__call__", None)  # noqa: B004
-        if dunder_call is None:
-            return False  # pragma: no cover
-        if iscoroutinefunction(_impartial(dunder_call)) or iscoroutinefunction(
-            _unwrapped_call(dunder_call)
-        ):
-            return True
-        dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None)  # noqa: B004
-        if dunder_unwrapped_call is None:
-            return False  # pragma: no cover
-        if iscoroutinefunction(
-            _impartial(dunder_unwrapped_call)
-        ) or iscoroutinefunction(_unwrapped_call(dunder_unwrapped_call)):
-            return True
+def _get_security_dependencies(*, dependant: Dependant) -> list[Dependant]:
+    return [dep for dep in dependant.dependencies if _is_security_scheme(dependant=dep)]
+
+
+@lru_cache(maxsize=1024)
+def _is_gen_callable_cached(call_identity: _CallIdentity) -> bool:
+    call = call_identity.call
+    if inspect.isgeneratorfunction(_impartial(call)) or inspect.isgeneratorfunction(
+        _unwrapped_call(call)
+    ):
+        return True
+    if inspect.isclass(_unwrapped_call(call)):
+        return False
+    dunder_call = getattr(_impartial(call), "__call__", None)  # noqa: B004
+    if dunder_call is None:
+        return False  # pragma: no cover
+    if inspect.isgeneratorfunction(
+        _impartial(dunder_call)
+    ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_call)):
+        return True
+    dunder_unwrapped_call = getattr(_unwrapped_call(call), "__call__", None)  # noqa: B004
+    if dunder_unwrapped_call is None:
+        return False  # pragma: no cover
+    return inspect.isgeneratorfunction(
+        _impartial(dunder_unwrapped_call)
+    ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_unwrapped_call))
+
+
+def _is_gen_callable(call: Callable[..., Any] | None) -> bool:
+    if call is None:
+        return False  # pragma: no cover
+    return _is_gen_callable_cached(_CallIdentity(call))
+
+
+@lru_cache(maxsize=1024)
+def _is_async_gen_callable_cached(call_identity: _CallIdentity) -> bool:
+    call = call_identity.call
+    if inspect.isasyncgenfunction(_impartial(call)) or inspect.isasyncgenfunction(
+        _unwrapped_call(call)
+    ):
+        return True
+    if inspect.isclass(_unwrapped_call(call)):
         return False
+    dunder_call = getattr(_impartial(call), "__call__", None)  # noqa: B004
+    if dunder_call is None:
+        return False  # pragma: no cover
+    if inspect.isasyncgenfunction(
+        _impartial(dunder_call)
+    ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_call)):
+        return True
+    dunder_unwrapped_call = getattr(_unwrapped_call(call), "__call__", None)  # noqa: B004
+    if dunder_unwrapped_call is None:
+        return False  # pragma: no cover
+    return inspect.isasyncgenfunction(
+        _impartial(dunder_unwrapped_call)
+    ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_unwrapped_call))
+
+
+def _is_async_gen_callable(call: Callable[..., Any] | None) -> bool:
+    if call is None:
+        return False  # pragma: no cover
+    return _is_async_gen_callable_cached(_CallIdentity(call))
+
+
+@lru_cache(maxsize=1024)
+def _is_coroutine_callable_cached(call_identity: _CallIdentity) -> bool:
+    call = call_identity.call
+    if inspect.isroutine(_impartial(call)) and iscoroutinefunction(_impartial(call)):
+        return True
+    if inspect.isroutine(_unwrapped_call(call)) and iscoroutinefunction(
+        _unwrapped_call(call)
+    ):
+        return True
+    if inspect.isclass(_unwrapped_call(call)):
+        return False
+    dunder_call = getattr(_impartial(call), "__call__", None)  # noqa: B004
+    if dunder_call is None:
+        return False  # pragma: no cover
+    if iscoroutinefunction(_impartial(dunder_call)) or iscoroutinefunction(
+        _unwrapped_call(dunder_call)
+    ):
+        return True
+    dunder_unwrapped_call = getattr(_unwrapped_call(call), "__call__", None)  # noqa: B004
+    if dunder_unwrapped_call is None:
+        return False  # pragma: no cover
+    return iscoroutinefunction(
+        _impartial(dunder_unwrapped_call)
+    ) or iscoroutinefunction(_unwrapped_call(dunder_unwrapped_call))
+
+
+def _is_coroutine_callable(call: Callable[..., Any] | None) -> bool:
+    if call is None:
+        return False  # pragma: no cover
+    return _is_coroutine_callable_cached(_CallIdentity(call))
+
 
-    @cached_property
-    def computed_scope(self) -> str | None:
-        if self.scope:
-            return self.scope
-        if self.is_gen_callable or self.is_async_gen_callable:
-            return "request"
-        return None
+def _get_computed_scope(*, dependant: Dependant) -> str | None:
+    if dependant.scope:
+        return dependant.scope
+    if _is_gen_callable(dependant.call) or _is_async_gen_callable(dependant.call):
+        return "request"
+    return None
index 40dffba64bed832416a002df7e89d504b365e436..31863801584ccd375023c6301a33498a2ccbf9e7 100644 (file)
@@ -54,7 +54,16 @@ from fastapi.concurrency import (
     asynccontextmanager,
     contextmanager_in_threadpool,
 )
-from fastapi.dependencies.models import Dependant
+from fastapi.dependencies.models import (
+    Dependant,
+    _get_cache_key,
+    _get_computed_scope,
+    _get_oauth_scopes,
+    _is_async_gen_callable,
+    _is_coroutine_callable,
+    _is_gen_callable,
+    _UsesScopesCache,
+)
 from fastapi.exceptions import DependencyScopeError
 from fastapi.logger import logger
 from fastapi.security.oauth2 import SecurityScopes
@@ -141,12 +150,20 @@ def get_flat_dependant(
     skip_repeats: bool = False,
     visited: list[DependencyCacheKey] | None = None,
     parent_oauth_scopes: list[str] | None = None,
+    _uses_scopes_cache: _UsesScopesCache | None = None,
 ) -> Dependant:
     if visited is None:
         visited = []
-    visited.append(dependant.cache_key)
+    if _uses_scopes_cache is None:
+        _uses_scopes_cache = {}
+    visited.append(
+        _get_cache_key(
+            dependant=dependant,
+            uses_scopes_cache=_uses_scopes_cache,
+        )
+    )
     use_parent_oauth_scopes = (parent_oauth_scopes or []) + (
-        dependant.oauth_scopes or []
+        _get_oauth_scopes(dependant=dependant)
     )
 
     flat_dependant = Dependant(
@@ -170,13 +187,21 @@ def get_flat_dependant(
         scope=dependant.scope,
     )
     for sub_dependant in dependant.dependencies:
-        if skip_repeats and sub_dependant.cache_key in visited:
+        if (
+            skip_repeats
+            and _get_cache_key(
+                dependant=sub_dependant,
+                uses_scopes_cache=_uses_scopes_cache,
+            )
+            in visited
+        ):
             continue
         flat_sub = get_flat_dependant(
             sub_dependant,
             skip_repeats=skip_repeats,
             visited=visited,
-            parent_oauth_scopes=flat_dependant.oauth_scopes,
+            parent_oauth_scopes=_get_oauth_scopes(dependant=flat_dependant),
+            _uses_scopes_cache=_uses_scopes_cache,
         )
         flat_dependant.dependencies.append(flat_sub)
         flat_dependant.path_params.extend(flat_sub.path_params)
@@ -317,8 +342,11 @@ def get_dependant(
         if param_details.depends is not None:
             assert param_details.depends.dependency
             if (
-                (dependant.is_gen_callable or dependant.is_async_gen_callable)
-                and dependant.computed_scope == "request"
+                (
+                    _is_gen_callable(dependant.call)
+                    or _is_async_gen_callable(dependant.call)
+                )
+                and _get_computed_scope(dependant=dependant) == "request"
                 and param_details.depends.scope == "function"
             ):
                 assert dependant.call
@@ -579,9 +607,9 @@ async def _solve_generator(
     *, dependant: Dependant, stack: AsyncExitStack, sub_values: dict[str, Any]
 ) -> Any:
     assert dependant.call
-    if dependant.is_async_gen_callable:
+    if _is_async_gen_callable(dependant.call):
         cm = asynccontextmanager(dependant.call)(**sub_values)
-    elif dependant.is_gen_callable:
+    elif _is_gen_callable(dependant.call):
         cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values))
     return await stack.enter_async_context(cm)
 
@@ -608,6 +636,7 @@ async def solve_dependencies(
     # people might be monkey patching this function (although that's not supported)
     async_exit_stack: AsyncExitStack,
     embed_body_fields: bool,
+    _uses_scopes_cache: _UsesScopesCache | None = None,
 ) -> SolvedDependency:
     request_astack = request.scope.get("fastapi_inner_astack")
     assert isinstance(request_astack, AsyncExitStack), (
@@ -625,6 +654,8 @@ async def solve_dependencies(
         response.status_code = None  # type: ignore
     if dependency_cache is None:
         dependency_cache = {}
+    if _uses_scopes_cache is None:
+        _uses_scopes_cache = {}
     for sub_dependant in dependant.dependencies:
         sub_dependant.call = cast(Callable[..., Any], sub_dependant.call)
         call = sub_dependant.call
@@ -642,7 +673,7 @@ async def solve_dependencies(
                 path=use_path,
                 call=call,
                 name=sub_dependant.name,
-                parent_oauth_scopes=sub_dependant.oauth_scopes,
+                parent_oauth_scopes=_get_oauth_scopes(dependant=sub_dependant),
                 scope=sub_dependant.scope,
             )
 
@@ -656,15 +687,20 @@ async def solve_dependencies(
             dependency_cache=dependency_cache,
             async_exit_stack=async_exit_stack,
             embed_body_fields=embed_body_fields,
+            _uses_scopes_cache=_uses_scopes_cache,
         )
         background_tasks = solved_result.background_tasks
         if solved_result.errors:
             errors.extend(solved_result.errors)
             continue
-        if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache:
-            solved = dependency_cache[sub_dependant.cache_key]
-        elif (
-            use_sub_dependant.is_gen_callable or use_sub_dependant.is_async_gen_callable
+        sub_dependant_cache_key = _get_cache_key(
+            dependant=sub_dependant,
+            uses_scopes_cache=_uses_scopes_cache,
+        )
+        if sub_dependant.use_cache and sub_dependant_cache_key in dependency_cache:
+            solved = dependency_cache[sub_dependant_cache_key]
+        elif _is_gen_callable(use_sub_dependant.call) or _is_async_gen_callable(
+            use_sub_dependant.call
         ):
             use_astack = request_astack
             if sub_dependant.scope == "function":
@@ -674,14 +710,14 @@ async def solve_dependencies(
                 stack=use_astack,
                 sub_values=solved_result.values,
             )
-        elif use_sub_dependant.is_coroutine_callable:
+        elif _is_coroutine_callable(use_sub_dependant.call):
             solved = await call(**solved_result.values)
         else:
             solved = await run_in_threadpool(call, **solved_result.values)
         if sub_dependant.name is not None:
             values[sub_dependant.name] = solved
-        if sub_dependant.cache_key not in dependency_cache:
-            dependency_cache[sub_dependant.cache_key] = solved
+        if sub_dependant_cache_key not in dependency_cache:
+            dependency_cache[sub_dependant_cache_key] = solved
     path_values, path_errors = request_params_to_args(
         dependant.path_params, request.path_params
     )
@@ -724,7 +760,7 @@ async def solve_dependencies(
         values[dependant.response_param_name] = response
     if dependant.security_scopes_param_name:
         values[dependant.security_scopes_param_name] = SecurityScopes(
-            scopes=dependant.oauth_scopes
+            scopes=_get_oauth_scopes(dependant=dependant)
         )
     return SolvedDependency(
         values=values,
index 2e0aca118788b22d5556cd2e6f9df7d95423bce1..54c5b78a5549b56c08b0791a4c76db929406eb3e 100644 (file)
@@ -15,7 +15,12 @@ from fastapi._compat import (
     lenient_issubclass,
 )
 from fastapi.datastructures import DefaultPlaceholder, _Unset
-from fastapi.dependencies.models import Dependant
+from fastapi.dependencies.models import (
+    Dependant,
+    _get_oauth_scopes,
+    _get_security_dependencies,
+    _get_security_scheme,
+)
 from fastapi.dependencies.utils import (
     _get_flat_fields_from_params,
     get_flat_dependant,
@@ -84,18 +89,19 @@ def get_openapi_security_definitions(
     security_definitions = {}
     # Use a dict to merge scopes for same security scheme
     operation_security_dict: dict[str, list[str]] = {}
-    for security_dependency in flat_dependant._security_dependencies:
+    for security_dependency in _get_security_dependencies(dependant=flat_dependant):
+        security_scheme = _get_security_scheme(dependant=security_dependency)
         security_definition = jsonable_encoder(
-            security_dependency._security_scheme.model,
+            security_scheme.model,
             by_alias=True,
             exclude_none=True,
         )
-        security_name = security_dependency._security_scheme.scheme_name
+        security_name = security_scheme.scheme_name
         security_definitions[security_name] = security_definition
         # Merge scopes for the same security scheme
         if security_name not in operation_security_dict:
             operation_security_dict[security_name] = []
-        for scope in security_dependency.oauth_scopes or []:
+        for scope in _get_oauth_scopes(dependant=security_dependency):
             if scope not in operation_security_dict[security_name]:
                 operation_security_dict[security_name].append(scope)
     operation_security = [
index 78207778a2c65a872e00ecf1a220300e0ebdbf1c..f3ff6c172e6e9524321ab4f28da8aa718663ffcb 100644 (file)
@@ -48,7 +48,12 @@ from fastapi._compat import (
     lenient_issubclass,
 )
 from fastapi.datastructures import Default, DefaultPlaceholder
-from fastapi.dependencies.models import Dependant
+from fastapi.dependencies.models import (
+    Dependant,
+    _is_async_gen_callable,
+    _is_coroutine_callable,
+    _is_gen_callable,
+)
 from fastapi.dependencies.utils import (
     _should_embed_body_fields,
     get_body_field,
@@ -384,7 +389,7 @@ def get_request_handler(
     is_json_stream: bool = False,
 ) -> Callable[[Request], Coroutine[Any, Any, Response]]:
     assert dependant.call is not None, "dependant.call must be a function"
-    is_coroutine = dependant.is_coroutine_callable
+    is_coroutine = _is_coroutine_callable(dependant.call)
     is_body_form = body_field and isinstance(body_field.field_info, params.Form)
     if isinstance(response_class, DefaultPlaceholder):
         actual_response_class: type[Response] = response_class.value
@@ -543,7 +548,7 @@ def get_request_handler(
                             data_str=_serialize_data(item).decode("utf-8")
                         )
 
-                if dependant.is_async_gen_callable:
+                if _is_async_gen_callable(dependant.call):
                     sse_aiter: AsyncIterator[Any] = gen.__aiter__()
                 else:
                     sse_aiter = iterate_in_threadpool(gen)
@@ -641,7 +646,7 @@ def get_request_handler(
                 def _serialize_item(item: Any) -> bytes:
                     return _serialize_data(item) + b"\n"
 
-                if dependant.is_async_gen_callable:
+                if _is_async_gen_callable(dependant.call):
 
                     async def _async_stream_jsonl() -> AsyncIterator[bytes]:
                         async for item in gen:
@@ -667,10 +672,12 @@ def get_request_handler(
                     background=solved_result.background_tasks,
                 )
                 response.headers.raw.extend(solved_result.response.headers.raw)
-            elif dependant.is_async_gen_callable or dependant.is_gen_callable:
+            elif _is_async_gen_callable(dependant.call) or _is_gen_callable(
+                dependant.call
+            ):
                 # Raw streaming with explicit response_class (e.g. StreamingResponse)
                 gen = dependant.call(**solved_result.values)
-                if dependant.is_async_gen_callable:
+                if _is_async_gen_callable(dependant.call):
 
                     async def _async_stream_raw(
                         async_gen: AsyncIterator[Any],
@@ -1097,8 +1104,8 @@ def _populate_api_route_state(
         embed_body_fields=route._embed_body_fields,
     )
     # Detect generator endpoints that should stream as JSONL or SSE
-    is_generator = (
-        route.dependant.is_async_gen_callable or route.dependant.is_gen_callable
+    is_generator = _is_async_gen_callable(route.dependant.call) or _is_gen_callable(
+        route.dependant.call
     )
     route.is_sse_stream = is_generator and lenient_issubclass(
         response_class, EventSourceResponse
diff --git a/tests/test_dependency_models.py b/tests/test_dependency_models.py
new file mode 100644 (file)
index 0000000..a2a36ea
--- /dev/null
@@ -0,0 +1,187 @@
+from collections.abc import AsyncGenerator, Generator
+from typing import Any
+
+from fastapi.dependencies.models import (
+    Dependant,
+    _get_cache_key,
+    _get_computed_scope,
+    _get_oauth_scopes,
+    _get_security_dependencies,
+    _get_security_scheme,
+    _is_async_gen_callable,
+    _is_async_gen_callable_cached,
+    _is_coroutine_callable,
+    _is_coroutine_callable_cached,
+    _is_gen_callable,
+    _is_gen_callable_cached,
+    _is_security_scheme,
+    _uses_scopes,
+)
+from fastapi.security import APIKeyHeader
+
+
+def sync_dependency() -> None:
+    pass  # pragma: no cover
+
+
+async def async_dependency() -> None:
+    pass  # pragma: no cover
+
+
+def generator_dependency() -> Generator[None, None, None]:
+    yield  # pragma: no cover
+
+
+async def async_generator_dependency() -> AsyncGenerator[None, None]:
+    yield  # pragma: no cover
+
+
+class UnhashableCallable:
+    __hash__ = None
+
+    async def __call__(self) -> None:
+        pass  # pragma: no cover
+
+
+class UnhashableGeneratorCallable:
+    __hash__ = None
+
+    def __call__(self) -> Generator[None, None, None]:
+        yield  # pragma: no cover
+
+
+class UnhashableAsyncGeneratorCallable:
+    __hash__ = None
+
+    async def __call__(self) -> AsyncGenerator[None, None]:
+        yield  # pragma: no cover
+
+
+class EqualCallable:
+    def __eq__(self, other: object) -> bool:
+        return isinstance(other, EqualCallable)
+
+    def __hash__(self) -> int:
+        return 1  # pragma: no cover
+
+
+class EqualAsyncCallable(EqualCallable):
+    async def __call__(self) -> None:
+        pass  # pragma: no cover
+
+
+class EqualSyncCallable(EqualCallable):
+    def __call__(self) -> None:
+        pass  # pragma: no cover
+
+
+def test_callable_classification_is_shared_by_call() -> None:
+    _is_gen_callable_cached.cache_clear()
+    _is_async_gen_callable_cached.cache_clear()
+    _is_coroutine_callable_cached.cache_clear()
+
+    for _ in range(2):
+        assert not _is_gen_callable(async_dependency)
+        assert not _is_async_gen_callable(async_dependency)
+        assert _is_coroutine_callable(async_dependency)
+
+    for cached_function in (
+        _is_gen_callable_cached,
+        _is_async_gen_callable_cached,
+        _is_coroutine_callable_cached,
+    ):
+        cache_info = cached_function.cache_info()
+        assert cache_info.hits == 1
+        assert cache_info.misses == 1
+        assert cache_info.maxsize == 1024
+
+
+def test_unhashable_callable_classification() -> None:
+    assert _is_coroutine_callable(UnhashableCallable())
+    assert _is_gen_callable(UnhashableGeneratorCallable())
+    assert _is_async_gen_callable(UnhashableAsyncGeneratorCallable())
+
+
+def test_equal_callable_instances_are_cached_by_identity() -> None:
+    async_callable = EqualAsyncCallable()
+    sync_callable = EqualSyncCallable()
+
+    assert async_callable == sync_callable
+    assert _is_coroutine_callable(async_callable)
+    assert not _is_coroutine_callable(sync_callable)
+
+
+def test_callable_classification() -> None:
+    assert not _is_gen_callable(sync_dependency)
+    assert not _is_async_gen_callable(sync_dependency)
+    assert not _is_coroutine_callable(sync_dependency)
+    assert _is_gen_callable(generator_dependency)
+    assert _is_async_gen_callable(async_generator_dependency)
+
+
+def test_derived_values_are_not_stored_on_dependant() -> None:
+    dependant = Dependant(call=async_dependency)
+    uses_scopes_cache = {}
+
+    assert _get_oauth_scopes(dependant=dependant) == []
+    assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
+    assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
+    assert _get_security_dependencies(dependant=dependant) == []
+    assert _get_computed_scope(dependant=dependant) is None
+    assert _get_cache_key(dependant=dependant) == (async_dependency, (), "")
+
+    assert not hasattr(dependant, "__dict__")
+
+
+def test_security_scheme_helpers() -> None:
+    security_scheme = APIKeyHeader(name="key")
+    security_dependant = Dependant(call=security_scheme)
+    dependant = Dependant(dependencies=[security_dependant])
+
+    assert _is_security_scheme(dependant=security_dependant)
+    assert _get_security_scheme(dependant=security_dependant) is security_scheme
+    assert _get_security_dependencies(dependant=dependant) == [security_dependant]
+    assert _uses_scopes(dependant=dependant)
+
+
+def test_derived_values_follow_dependency_state() -> None:
+    child = Dependant(call=sync_dependency)
+    dependant = Dependant(
+        call=sync_dependency,
+        dependencies=[child],
+        own_oauth_scopes=[],
+        parent_oauth_scopes=["parent"],
+    )
+
+    assert _get_cache_key(dependant=dependant) == (sync_dependency, (), "")
+
+    child.security_scopes_param_name = "scopes"
+    dependant.own_oauth_scopes = ["own", "parent"]
+
+    assert _uses_scopes(dependant=dependant)
+    assert _get_oauth_scopes(dependant=dependant) == ["parent", "own"]
+    assert _get_cache_key(dependant=dependant) == (
+        sync_dependency,
+        ("own", "parent"),
+        "",
+    )
+
+
+def test_explicit_and_generator_scopes() -> None:
+    assert (
+        _get_computed_scope(dependant=Dependant(call=sync_dependency, scope="function"))
+        == "function"
+    )
+    assert (
+        _get_computed_scope(dependant=Dependant(call=generator_dependency)) == "request"
+    )
+
+
+def test_callable_return_annotations_are_not_used() -> None:
+    class CallableWithUnhashableReturn:
+        def __call__(self) -> Any:
+            return None  # pragma: no cover
+
+        __hash__ = None
+
+    assert not _is_coroutine_callable(CallableWithUnhashableReturn())