Browse Source

️ Cache callable inspection result

pull/13974/head
Martynov Maxim 12 months ago
parent
commit
30e8b439fe
No known key found for this signature in database GPG Key ID: 9C23E39F5BBC88CC
  1. 153
      fastapi/dependencies/utils.py
  2. 54
      fastapi/routing.py
  3. 126
      tests/test_callable_info.py

153
fastapi/dependencies/utils.py

@ -18,6 +18,7 @@ from typing import (
Union, Union,
cast, cast,
) )
from weakref import WeakKeyDictionary
import anyio import anyio
from fastapi import params from fastapi import params
@ -50,10 +51,7 @@ from fastapi._compat import (
) )
from fastapi._compat.shared import annotation_is_pydantic_v1 from fastapi._compat.shared import annotation_is_pydantic_v1
from fastapi.background import BackgroundTasks from fastapi.background import BackgroundTasks
from fastapi.concurrency import ( from fastapi.concurrency import asynccontextmanager, contextmanager_in_threadpool
asynccontextmanager,
contextmanager_in_threadpool,
)
from fastapi.dependencies.models import Dependant, SecurityRequirement from fastapi.dependencies.models import Dependant, SecurityRequirement
from fastapi.logger import logger from fastapi.logger import logger
from fastapi.security.base import SecurityBase 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: def ensure_multipart_is_installed() -> None:
try: try:
from python_multipart import __version__ from python_multipart import __version__
@ -125,7 +150,12 @@ def ensure_multipart_is_installed() -> None:
raise RuntimeError(multipart_not_installed_error) from 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), ( assert callable(depends.dependency), (
"A parameter-less dependency must have a callable 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: if isinstance(depends, params.Security) and depends.scopes:
use_security_scopes.extend(depends.scopes) use_security_scopes.extend(depends.scopes)
return get_dependant( 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() 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 return typed_signature
@ -219,17 +261,6 @@ def get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
return annotation 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( def get_dependant(
*, *,
path: str, path: str,
@ -237,6 +268,7 @@ def get_dependant(
name: Optional[str] = None, name: Optional[str] = None,
security_scopes: Optional[List[str]] = None, security_scopes: Optional[List[str]] = None,
use_cache: bool = True, use_cache: bool = True,
callable_info_cache: Optional[CallableInfoCache] = None,
) -> Dependant: ) -> Dependant:
dependant = Dependant( dependant = Dependant(
call=call, call=call,
@ -246,8 +278,14 @@ def get_dependant(
use_cache=use_cache, use_cache=use_cache,
) )
path_param_names = get_path_param_names(path) path_param_names = get_path_param_names(path)
endpoint_signature = get_typed_signature(call) callable_info_cache = prepare_callable_info_cache(
signature_params = endpoint_signature.parameters 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): if isinstance(call, SecurityBase):
use_scopes: List[str] = [] use_scopes: List[str] = []
if isinstance(call, (OAuth2, OpenIdConnect)): if isinstance(call, (OAuth2, OpenIdConnect)):
@ -276,6 +314,7 @@ def get_dependant(
name=param_name, name=param_name,
security_scopes=use_security_scopes, security_scopes=use_security_scopes,
use_cache=param_details.depends.use_cache, use_cache=param_details.depends.use_cache,
callable_info_cache=callable_info_cache,
) )
dependant.dependencies.append(sub_dependant) dependant.dependencies.append(sub_dependant)
continue continue
@ -532,6 +571,43 @@ def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None:
dependant.cookie_params.append(field) 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: def is_coroutine_callable(call: Callable[..., Any]) -> bool:
if inspect.isroutine(call): if inspect.isroutine(call):
return iscoroutinefunction(call) return iscoroutinefunction(call)
@ -556,11 +632,15 @@ def is_gen_callable(call: Callable[..., Any]) -> bool:
async def solve_generator( 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: ) -> Any:
if is_gen_callable(call): if callable_info.is_gen_callable:
cm = contextmanager_in_threadpool(contextmanager(call)(**sub_values)) 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) cm = asynccontextmanager(call)(**sub_values)
return await stack.enter_async_context(cm) return await stack.enter_async_context(cm)
@ -583,11 +663,13 @@ async def solve_dependencies(
response: Optional[Response] = None, response: Optional[Response] = None,
dependency_overrides_provider: Optional[Any] = None, dependency_overrides_provider: Optional[Any] = None,
dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None, dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None,
callable_info_cache: Optional[CallableInfoCache] = None,
async_exit_stack: AsyncExitStack, async_exit_stack: AsyncExitStack,
embed_body_fields: bool, embed_body_fields: bool,
) -> SolvedDependency: ) -> SolvedDependency:
values: Dict[str, Any] = {} values: Dict[str, Any] = {}
errors: List[Any] = [] errors: List[Any] = []
callable_info_cache = prepare_callable_info_cache(callable_info_cache)
if response is None: if response is None:
response = Response() response = Response()
del response.headers["content-length"] del response.headers["content-length"]
@ -616,6 +698,8 @@ async def solve_dependencies(
call=call, call=call,
name=sub_dependant.name, name=sub_dependant.name,
security_scopes=sub_dependant.security_scopes, security_scopes=sub_dependant.security_scopes,
use_cache=sub_dependant.use_cache,
callable_info_cache=callable_info_cache,
) )
solved_result = await solve_dependencies( solved_result = await solve_dependencies(
@ -626,6 +710,7 @@ async def solve_dependencies(
response=response, response=response,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
dependency_cache=dependency_cache, dependency_cache=dependency_cache,
callable_info_cache=callable_info_cache,
async_exit_stack=async_exit_stack, async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
) )
@ -635,14 +720,22 @@ async def solve_dependencies(
continue continue
if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache:
solved = dependency_cache[sub_dependant.cache_key] 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: 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: if sub_dependant.name is not None:
values[sub_dependant.name] = solved values[sub_dependant.name] = solved
if sub_dependant.cache_key not in dependency_cache: if sub_dependant.cache_key not in dependency_cache:

54
fastapi/routing.py

@ -3,7 +3,6 @@ import email.message
import functools import functools
import inspect import inspect
import json import json
import sys
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from enum import Enum, IntEnum from enum import Enum, IntEnum
from typing import ( from typing import (
@ -37,12 +36,14 @@ from fastapi._compat import (
from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.datastructures import Default, DefaultPlaceholder
from fastapi.dependencies.models import Dependant from fastapi.dependencies.models import Dependant
from fastapi.dependencies.utils import ( from fastapi.dependencies.utils import (
CallableInfoCache,
_should_embed_body_fields, _should_embed_body_fields,
get_body_field, get_body_field,
get_cached_callable_info,
get_dependant, get_dependant,
get_flat_dependant, get_flat_dependant,
get_parameterless_sub_dependant, get_parameterless_sub_dependant,
get_typed_return_annotation, prepare_callable_info_cache,
solve_dependencies, solve_dependencies,
) )
from fastapi.encoders import jsonable_encoder 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 starlette.websockets import WebSocket
from typing_extensions import Annotated, deprecated 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 # Copy of starlette.routing.request_response modified to include the
# dependencies' AsyncExitStack # dependencies' AsyncExitStack
@ -305,9 +301,17 @@ def get_request_handler(
response_model_exclude_none: bool = False, response_model_exclude_none: bool = False,
dependency_overrides_provider: Optional[Any] = None, dependency_overrides_provider: Optional[Any] = None,
embed_body_fields: bool = False, embed_body_fields: bool = False,
callable_info_cache: Optional[CallableInfoCache] = None,
) -> Callable[[Request], Coroutine[Any, Any, Response]]: ) -> Callable[[Request], Coroutine[Any, Any, Response]]:
assert dependant.call is not None, "dependant.call must be a function" 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( is_body_form = body_field and isinstance(
body_field.field_info, (params.Form, temp_pydantic_v1_params.Form) body_field.field_info, (params.Form, temp_pydantic_v1_params.Form)
) )
@ -382,6 +386,7 @@ def get_request_handler(
dependant=dependant, dependant=dependant,
body=body, body=body,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
callable_info_cache=callable_info_cache,
async_exit_stack=async_exit_stack, async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
) )
@ -441,6 +446,7 @@ def get_websocket_app(
dependant: Dependant, dependant: Dependant,
dependency_overrides_provider: Optional[Any] = None, dependency_overrides_provider: Optional[Any] = None,
embed_body_fields: bool = False, embed_body_fields: bool = False,
callable_info_cache: Optional[CallableInfoCache] = None,
) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]:
async def app(websocket: WebSocket) -> None: async def app(websocket: WebSocket) -> None:
async_exit_stack = websocket.scope.get("fastapi_inner_astack") async_exit_stack = websocket.scope.get("fastapi_inner_astack")
@ -451,6 +457,7 @@ def get_websocket_app(
request=websocket, request=websocket,
dependant=dependant, dependant=dependant,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
callable_info_cache=callable_info_cache,
async_exit_stack=async_exit_stack, async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
) )
@ -476,14 +483,23 @@ class APIWebSocketRoute(routing.WebSocketRoute):
) -> None: ) -> None:
self.path = path self.path = path
self.endpoint = endpoint self.endpoint = endpoint
self.callable_info_cache = prepare_callable_info_cache()
self.name = get_name(endpoint) if name is None else name self.name = get_name(endpoint) if name is None else name
self.dependencies = list(dependencies or []) self.dependencies = list(dependencies or [])
self.path_regex, self.path_format, self.param_convertors = compile_path(path) 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]: for depends in self.dependencies[::-1]:
self.dependant.dependencies.insert( self.dependant.dependencies.insert(
0, 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._flat_dependant = get_flat_dependant(self.dependant)
self._embed_body_fields = _should_embed_body_fields( self._embed_body_fields = _should_embed_body_fields(
@ -494,6 +510,7 @@ class APIWebSocketRoute(routing.WebSocketRoute):
dependant=self.dependant, dependant=self.dependant,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
embed_body_fields=self._embed_body_fields, embed_body_fields=self._embed_body_fields,
callable_info_cache=self.callable_info_cache,
) )
) )
@ -541,8 +558,10 @@ class APIRoute(routing.Route):
) -> None: ) -> None:
self.path = path self.path = path
self.endpoint = endpoint self.endpoint = endpoint
self.callable_info_cache = prepare_callable_info_cache()
if isinstance(response_model, DefaultPlaceholder): 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): if lenient_issubclass(return_annotation, Response):
response_model = None response_model = None
else: else:
@ -630,11 +649,19 @@ class APIRoute(routing.Route):
self.response_fields = {} self.response_fields = {}
assert callable(endpoint), "An endpoint must be a callable" 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]: for depends in self.dependencies[::-1]:
self.dependant.dependencies.insert( self.dependant.dependencies.insert(
0, 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._flat_dependant = get_flat_dependant(self.dependant)
self._embed_body_fields = _should_embed_body_fields( 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, response_model_exclude_none=self.response_model_exclude_none,
dependency_overrides_provider=self.dependency_overrides_provider, dependency_overrides_provider=self.dependency_overrides_provider,
embed_body_fields=self._embed_body_fields, embed_body_fields=self._embed_body_fields,
callable_info_cache=self.callable_info_cache,
) )
def matches(self, scope: Scope) -> Tuple[Match, Scope]: def matches(self, scope: Scope) -> Tuple[Match, Scope]:

126
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()
Loading…
Cancel
Save