diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6477a2cba..9564c178c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -18,6 +18,7 @@ from typing import ( Union, cast, ) +from weakref import WeakKeyDictionary import anyio from fastapi import params @@ -50,10 +51,7 @@ from fastapi._compat import ( ) from fastapi._compat.shared import annotation_is_pydantic_v1 from fastapi.background import BackgroundTasks -from fastapi.concurrency import ( - asynccontextmanager, - contextmanager_in_threadpool, -) +from fastapi.concurrency import asynccontextmanager, contextmanager_in_threadpool from fastapi.dependencies.models import Dependant, SecurityRequirement from fastapi.logger import logger from fastapi.security.base import SecurityBase @@ -98,6 +96,33 @@ multipart_incorrect_install_error = ( ) +class CallableInfo: + __slots__ = ( + "typed_signature", + "is_gen_callable", + "is_async_gen_callable", + "is_coroutine_callable", + ) + + def __init__( + self, + typed_signature: inspect.Signature, + is_gen_callable: bool, + is_async_gen_callable: bool, + is_coroutine_callable: bool, + ) -> None: + self.typed_signature = typed_signature + self.is_gen_callable = is_gen_callable + self.is_async_gen_callable = is_async_gen_callable + self.is_coroutine_callable = is_coroutine_callable + + +if sys.version_info < (3, 9): + CallableInfoCache = WeakKeyDictionary +else: + CallableInfoCache = WeakKeyDictionary[Callable[..., Any], CallableInfo] + + def ensure_multipart_is_installed() -> None: try: from python_multipart import __version__ @@ -125,7 +150,12 @@ def ensure_multipart_is_installed() -> None: raise RuntimeError(multipart_not_installed_error) from None -def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> Dependant: +def get_parameterless_sub_dependant( + *, + depends: params.Depends, + path: str, + callable_info_cache: CallableInfoCache, +) -> Dependant: assert callable(depends.dependency), ( "A parameter-less dependency must have a callable dependency" ) @@ -133,7 +163,10 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De if isinstance(depends, params.Security) and depends.scopes: use_security_scopes.extend(depends.scopes) return get_dependant( - path=path, call=depends.dependency, security_scopes=use_security_scopes + path=path, + call=depends.dependency, + security_scopes=use_security_scopes, + callable_info_cache=callable_info_cache, ) @@ -206,7 +239,16 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: ) for param in signature.parameters.values() ] - typed_signature = inspect.Signature(typed_params) + return_annotation = signature.return_annotation + if return_annotation is inspect.Signature.empty: + return_annotation = None + else: + return_annotation = get_typed_annotation(return_annotation, globalns) + + typed_signature = inspect.Signature( + typed_params, + return_annotation=return_annotation, + ) return typed_signature @@ -219,17 +261,6 @@ def get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any: return annotation -def get_typed_return_annotation(call: Callable[..., Any]) -> Any: - signature = inspect.signature(call) - annotation = signature.return_annotation - - if annotation is inspect.Signature.empty: - return None - - globalns = getattr(call, "__globals__", {}) - return get_typed_annotation(annotation, globalns) - - def get_dependant( *, path: str, @@ -237,6 +268,7 @@ def get_dependant( name: Optional[str] = None, security_scopes: Optional[List[str]] = None, use_cache: bool = True, + callable_info_cache: Optional[CallableInfoCache] = None, ) -> Dependant: dependant = Dependant( call=call, @@ -246,8 +278,14 @@ def get_dependant( use_cache=use_cache, ) path_param_names = get_path_param_names(path) - endpoint_signature = get_typed_signature(call) - signature_params = endpoint_signature.parameters + callable_info_cache = prepare_callable_info_cache( + existing_cache=callable_info_cache, + ) + callable_info = get_cached_callable_info( + call=call, + callable_info_cache=callable_info_cache, + ) + signature_params = callable_info.typed_signature.parameters if isinstance(call, SecurityBase): use_scopes: List[str] = [] if isinstance(call, (OAuth2, OpenIdConnect)): @@ -276,6 +314,7 @@ def get_dependant( name=param_name, security_scopes=use_security_scopes, use_cache=param_details.depends.use_cache, + callable_info_cache=callable_info_cache, ) dependant.dependencies.append(sub_dependant) continue @@ -532,6 +571,43 @@ def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None: dependant.cookie_params.append(field) +def prepare_callable_info_cache( + existing_cache: Optional[CallableInfoCache] = None, +) -> CallableInfoCache: + if existing_cache is None: + existing_cache = WeakKeyDictionary() + return existing_cache + + +def get_cached_callable_info( + call: Callable[..., Any], + callable_info_cache: CallableInfoCache, +) -> CallableInfo: + try: + support_weakref = True + callable_info = callable_info_cache.get(call) + except TypeError: + support_weakref = False + callable_info = None + + if callable_info is not None: + return callable_info + + callable_info = inspect_callable(call) + if support_weakref: + callable_info_cache[call] = callable_info + return callable_info + + +def inspect_callable(call: Callable[..., Any]) -> CallableInfo: + return CallableInfo( + typed_signature=get_typed_signature(call), + is_gen_callable=is_gen_callable(call), + is_async_gen_callable=is_async_gen_callable(call), + is_coroutine_callable=is_coroutine_callable(call), + ) + + def is_coroutine_callable(call: Callable[..., Any]) -> bool: if inspect.isroutine(call): return iscoroutinefunction(call) @@ -556,11 +632,15 @@ def is_gen_callable(call: Callable[..., Any]) -> bool: async def solve_generator( - *, call: Callable[..., Any], stack: AsyncExitStack, sub_values: Dict[str, Any] + *, + call: Callable[..., Any], + callable_info: CallableInfo, + stack: AsyncExitStack, + sub_values: Dict[str, Any], ) -> Any: - if is_gen_callable(call): + if callable_info.is_gen_callable: cm = contextmanager_in_threadpool(contextmanager(call)(**sub_values)) - elif is_async_gen_callable(call): + elif callable_info.is_async_gen_callable: cm = asynccontextmanager(call)(**sub_values) return await stack.enter_async_context(cm) @@ -583,11 +663,13 @@ async def solve_dependencies( response: Optional[Response] = None, dependency_overrides_provider: Optional[Any] = None, dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None, + callable_info_cache: Optional[CallableInfoCache] = None, async_exit_stack: AsyncExitStack, embed_body_fields: bool, ) -> SolvedDependency: values: Dict[str, Any] = {} errors: List[Any] = [] + callable_info_cache = prepare_callable_info_cache(callable_info_cache) if response is None: response = Response() del response.headers["content-length"] @@ -616,6 +698,8 @@ async def solve_dependencies( call=call, name=sub_dependant.name, security_scopes=sub_dependant.security_scopes, + use_cache=sub_dependant.use_cache, + callable_info_cache=callable_info_cache, ) solved_result = await solve_dependencies( @@ -626,6 +710,7 @@ async def solve_dependencies( response=response, dependency_overrides_provider=dependency_overrides_provider, dependency_cache=dependency_cache, + callable_info_cache=callable_info_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, ) @@ -635,14 +720,22 @@ async def solve_dependencies( continue if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: solved = dependency_cache[sub_dependant.cache_key] - elif is_gen_callable(call) or is_async_gen_callable(call): - solved = await solve_generator( - call=call, stack=async_exit_stack, sub_values=solved_result.values - ) - elif is_coroutine_callable(call): - solved = await call(**solved_result.values) else: - solved = await run_in_threadpool(call, **solved_result.values) + callable_info = get_cached_callable_info( + call=call, + callable_info_cache=callable_info_cache, + ) + if callable_info.is_gen_callable or callable_info.is_async_gen_callable: + solved = await solve_generator( + call=call, + callable_info=callable_info, + stack=async_exit_stack, + sub_values=solved_result.values, + ) + elif callable_info.is_coroutine_callable: + 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: diff --git a/fastapi/routing.py b/fastapi/routing.py index 0b59d250a..f8dd7620c 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -3,7 +3,6 @@ import email.message import functools import inspect import json -import sys from contextlib import AsyncExitStack, asynccontextmanager from enum import Enum, IntEnum from typing import ( @@ -37,12 +36,14 @@ from fastapi._compat import ( from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( + CallableInfoCache, _should_embed_body_fields, get_body_field, + get_cached_callable_info, get_dependant, get_flat_dependant, get_parameterless_sub_dependant, - get_typed_return_annotation, + prepare_callable_info_cache, solve_dependencies, ) from fastapi.encoders import jsonable_encoder @@ -79,11 +80,6 @@ from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send from starlette.websockets import WebSocket from typing_extensions import Annotated, deprecated -if sys.version_info >= (3, 13): # pragma: no cover - from inspect import iscoroutinefunction -else: # pragma: no cover - from asyncio import iscoroutinefunction - # Copy of starlette.routing.request_response modified to include the # dependencies' AsyncExitStack @@ -305,9 +301,17 @@ def get_request_handler( response_model_exclude_none: bool = False, dependency_overrides_provider: Optional[Any] = None, embed_body_fields: bool = False, + callable_info_cache: Optional[CallableInfoCache] = None, ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" - is_coroutine = iscoroutinefunction(dependant.call) + # callables are not changing between requests, so we can cache them here + callable_info_cache = prepare_callable_info_cache(callable_info_cache) + callable_info = get_cached_callable_info( + call=dependant.call, + callable_info_cache=callable_info_cache, + ) + + is_coroutine = callable_info.is_coroutine_callable is_body_form = body_field and isinstance( body_field.field_info, (params.Form, temp_pydantic_v1_params.Form) ) @@ -382,6 +386,7 @@ def get_request_handler( dependant=dependant, body=body, dependency_overrides_provider=dependency_overrides_provider, + callable_info_cache=callable_info_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, ) @@ -441,6 +446,7 @@ def get_websocket_app( dependant: Dependant, dependency_overrides_provider: Optional[Any] = None, embed_body_fields: bool = False, + callable_info_cache: Optional[CallableInfoCache] = None, ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: async def app(websocket: WebSocket) -> None: async_exit_stack = websocket.scope.get("fastapi_inner_astack") @@ -451,6 +457,7 @@ def get_websocket_app( request=websocket, dependant=dependant, dependency_overrides_provider=dependency_overrides_provider, + callable_info_cache=callable_info_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, ) @@ -476,14 +483,23 @@ class APIWebSocketRoute(routing.WebSocketRoute): ) -> None: self.path = path self.endpoint = endpoint + self.callable_info_cache = prepare_callable_info_cache() self.name = get_name(endpoint) if name is None else name self.dependencies = list(dependencies or []) self.path_regex, self.path_format, self.param_convertors = compile_path(path) - self.dependant = get_dependant(path=self.path_format, call=self.endpoint) + self.dependant = get_dependant( + path=self.path_format, + call=self.endpoint, + callable_info_cache=self.callable_info_cache, + ) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant(depends=depends, path=self.path_format), + get_parameterless_sub_dependant( + depends=depends, + path=self.path_format, + callable_info_cache=self.callable_info_cache, + ), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -494,6 +510,7 @@ class APIWebSocketRoute(routing.WebSocketRoute): dependant=self.dependant, dependency_overrides_provider=dependency_overrides_provider, embed_body_fields=self._embed_body_fields, + callable_info_cache=self.callable_info_cache, ) ) @@ -541,8 +558,10 @@ class APIRoute(routing.Route): ) -> None: self.path = path self.endpoint = endpoint + self.callable_info_cache = prepare_callable_info_cache() if isinstance(response_model, DefaultPlaceholder): - return_annotation = get_typed_return_annotation(endpoint) + callable_info = get_cached_callable_info(endpoint, self.callable_info_cache) + return_annotation = callable_info.typed_signature.return_annotation if lenient_issubclass(return_annotation, Response): response_model = None else: @@ -630,11 +649,19 @@ class APIRoute(routing.Route): self.response_fields = {} assert callable(endpoint), "An endpoint must be a callable" - self.dependant = get_dependant(path=self.path_format, call=self.endpoint) + self.dependant = get_dependant( + path=self.path_format, + call=self.endpoint, + callable_info_cache=self.callable_info_cache, + ) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant(depends=depends, path=self.path_format), + get_parameterless_sub_dependant( + depends=depends, + path=self.path_format, + callable_info_cache=self.callable_info_cache, + ), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -662,6 +689,7 @@ class APIRoute(routing.Route): response_model_exclude_none=self.response_model_exclude_none, dependency_overrides_provider=self.dependency_overrides_provider, embed_body_fields=self._embed_body_fields, + callable_info_cache=self.callable_info_cache, ) def matches(self, scope: Scope) -> Tuple[Match, Scope]: diff --git a/tests/test_callable_info.py b/tests/test_callable_info.py new file mode 100644 index 000000000..560756199 --- /dev/null +++ b/tests/test_callable_info.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from typing import AsyncGenerator, Generator +from unittest.mock import call, patch + +from fastapi import Depends, FastAPI +from fastapi.dependencies.utils import inspect_callable +from fastapi.testclient import TestClient + + +def direct_dependency() -> str: + return "direct dependency" + + +async def async_dependency() -> str: + return "async dependency" + + +def sync_dependency() -> str: + return "sync dependency" + + +async def async_generator() -> AsyncGenerator[str, None]: + yield "async generator" + + +def sync_generator() -> Generator[str, None, None]: + yield "generator" + + +@dataclass +class class_dependency: + pass + + +# Nested dependency +async def async_nested_dependency( + via_sync_dependency: dict = Depends(sync_dependency), + via_async_dependency: str = Depends(async_dependency), + via_async_generator: str = Depends(async_generator), + via_sync_generator: str = Depends(sync_generator), + via_class_dependency: class_dependency = Depends(), +) -> dict: + return { + "via_sync_dependency": via_sync_dependency, + "via_async_dependency": via_async_dependency, + "via_async_generator": via_async_generator, + "via_sync_generator": via_sync_generator, + "via_class_dependency": via_class_dependency, + } + + +def test_get_callable_info(): + async_dependency_info = inspect_callable(async_dependency) + assert not async_dependency_info.is_gen_callable + assert not async_dependency_info.is_async_gen_callable + assert async_dependency_info.is_coroutine_callable + + sync_dependency_info = inspect_callable(sync_dependency) + assert not sync_dependency_info.is_gen_callable + assert not sync_dependency_info.is_async_gen_callable + assert not sync_dependency_info.is_coroutine_callable + + async_generator_info = inspect_callable(async_generator) + assert not async_generator_info.is_gen_callable + assert async_generator_info.is_async_gen_callable + assert not async_generator_info.is_coroutine_callable + + sync_generator_info = inspect_callable(sync_generator) + assert sync_generator_info.is_gen_callable + assert not sync_generator_info.is_async_gen_callable + assert not sync_generator_info.is_coroutine_callable + + class_dependency_info = inspect_callable(class_dependency) + assert not class_dependency_info.is_gen_callable + assert not class_dependency_info.is_async_gen_callable + assert not class_dependency_info.is_coroutine_callable + + +def test_callable_info_is_cached(): + with patch( + "fastapi.dependencies.utils.inspect_callable", + side_effect=inspect_callable, + ) as inspect_callable_mock: + app = FastAPI() + inspect_callable_mock.assert_not_called() + + @app.get("/items/{item_id}", dependencies=[Depends(direct_dependency)]) + async def endpoint( + item_id: str, + context: dict = Depends(async_nested_dependency), + ) -> dict: + return {"item_id": item_id, "context": context} + + # endpoint and direct dependency info is cached immediately. + # nested dependencies require additional resolution + inspect_callable_mock.assert_has_calls( + [ + call(endpoint), + call(async_nested_dependency), + call(sync_dependency), + call(async_dependency), + call(async_generator), + call(sync_generator), + call(class_dependency), + call(direct_dependency), + ], + ) + + # first call of endpoint + inspect_callable_mock.reset_mock() + client = TestClient(app) + response = client.get("/items/via-query") + assert response.status_code == 200, response.text + assert response.json() == { + "item_id": "via-query", + "context": { + "via_sync_dependency": "sync dependency", + "via_async_dependency": "async dependency", + "via_async_generator": "async generator", + "via_sync_generator": "generator", + "via_class_dependency": {}, + }, + } + + # inspection is performed only once + inspect_callable_mock.assert_not_called()