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,
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:

54
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]:

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