Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,8 @@ repos:
rev: v0.3.1
hooks:
- id: pyaphid
- repo: https://github.com/asottile/yesqa
rev: v1.5.0
hooks:
- id: yesqa
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.20"
rev: "v0.16.5"
hooks:
- id: ruff
args:
Expand All @@ -27,7 +23,7 @@ repos:
- id: prettier
additional_dependencies: [prettier@latest, prettier-plugin-toml@latest]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v2.1.0"
rev: "v2.3.1"
hooks:
- id: mypy
exclude: ^tests/.*
Expand Down
2 changes: 1 addition & 1 deletion pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "fastapi-deferred-init"
description = "Faster FastAPI start-up time for Projects with many nested routers"
keywords = ["fastapi", "speed", "router", "startup", "optimization"]
authors = [{ name = "Jan Vollmer", email = "jan@vllmr.dev" }]
dependencies = ["fastapi>=0.140.7"]
dependencies = ["fastapi>=0.141.1"]
requires-python = ">=3.10"
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion src/fastapi_deferred_init/patch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .routing import _populate_api_route_state, DeferringAPIRoute, DeferringAPIRouter
from .routing import DeferringAPIRoute, DeferringAPIRouter, _populate_api_route_state


def apply_patch() -> None:
Expand Down
116 changes: 70 additions & 46 deletions src/fastapi_deferred_init/routing.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
from fastapi.dependencies.models import Dependant
import inspect
from collections.abc import Callable, Sequence
from enum import Enum, IntEnum
from functools import cached_property
from typing import Any, Callable, Union, cast
from collections.abc import Sequence

from typing import Any, cast

from fastapi.sse import (
EventSourceResponse,
ServerSentEvent,
)
from fastapi.routing import _is_async_gen_callable, _is_gen_callable
from fastapi import params, routing # type: ignore[attr-defined]
from fastapi._compat import ModelField, lenient_issubclass
from fastapi.datastructures import Default, DefaultPlaceholder
from fastapi.dependencies.models import Dependant
from fastapi.dependencies.utils import (
_should_embed_body_fields,
_get_body_field,
_get_flat_body_params,
_should_embed_body_fields,
get_dependant,
get_parameterless_sub_dependant,
get_typed_return_annotation,
get_stream_item_type,
_get_flat_body_params,
get_typed_return_annotation,
)
from fastapi.responses import JSONResponse, Response
from fastapi.routing import _is_async_gen_callable, _is_gen_callable
from fastapi.sse import (
EventSourceResponse,
ServerSentEvent,
)
from fastapi.types import IncEx
from fastapi.utils import (
create_model_field,
generate_unique_id,
is_body_allowed_for_status_code,
)

from fastapi import params, routing # type: ignore[attr-defined]


def _add_cache_attribute(
instance: routing._APIRouteLike, name: str, func: Callable[[Any], Any]
Expand All @@ -54,7 +54,7 @@ def _populate_api_route_state(
path: str,
endpoint: Callable[..., Any],
*,
response_model: Any = Default(None),
response_model: Any = Default(None), # noqa: B008
status_code: int | None = None,
tags: list[str | Enum] | None = None,
dependencies: Sequence[params.Depends] | None = None,
Expand All @@ -73,40 +73,20 @@ def _populate_api_route_state(
response_model_exclude_defaults: bool = False,
response_model_exclude_none: bool = False,
include_in_schema: bool = True,
response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse),
response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), # noqa: B008
dependency_overrides_provider: Any | None = None,
callbacks: list[routing.BaseRoute] | None = None,
openapi_extra: dict[str, Any] | None = None,
generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default(
generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default( # noqa: B008
generate_unique_id
),
strict_content_type: bool | DefaultPlaceholder = Default(True),
strict_content_type: bool | DefaultPlaceholder = Default(True), # noqa: B008
stream_item_type: Any | None = None,
) -> None:
route.path = path
route.endpoint = endpoint
route.stream_item_type = None
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response):
response_model = None
else:
stream_item = get_stream_item_type(return_annotation)
if stream_item is not None:
# Extract item type for JSONL or SSE streaming when
# response_class is DefaultPlaceholder (JSONL) or
# EventSourceResponse (SSE).
# ServerSentEvent is excluded: it's a transport
# wrapper, not a data model, so it shouldn't feed
# into validation or OpenAPI schema generation.
if (
isinstance(response_class, DefaultPlaceholder)
or lenient_issubclass(response_class, EventSourceResponse)
) and not lenient_issubclass(stream_item, ServerSentEvent):
route.stream_item_type = stream_item
response_model = None
else:
response_model = return_annotation
route.response_model = response_model
route.stream_item_type = stream_item_type

route.summary = summary
route.response_description = response_description
route.deprecated = deprecated
Expand Down Expand Up @@ -144,13 +124,12 @@ def _populate_api_route_state(
if isinstance(status_code, IntEnum):
status_code = int(status_code)
route.status_code = status_code
if route.response_model:
assert is_body_allowed_for_status_code(status_code), (
f"Status code {status_code} must not have a response body"
)

def _response_field(self):
if self.response_model:
assert is_body_allowed_for_status_code(status_code), (
f"Status code {status_code} must not have a response body"
)
response_name = "Response_" + self.unique_id
return create_model_field(
name=response_name,
Expand Down Expand Up @@ -180,8 +159,8 @@ def _stream_item_field(self) -> ModelField | None:
# truncate description text to the content preceding the first "form feed"
route.description = route.description.split("\f")[0].strip()

def _response_fields(self) -> dict[Union[int, str], ModelField]:
response_fields: dict[Union[int, str], ModelField] = {}
def _response_fields(self) -> dict[int | str, ModelField]:
response_fields: dict[int | str, ModelField] = {}
for additional_status_code, response in self.responses.items():
assert isinstance(response, dict), "An additional response must be a dict"
model = response.get("model")
Expand Down Expand Up @@ -254,6 +233,51 @@ def _is_json_stream(self) -> bool:

_add_cache_attribute(route, "is_json_stream", _is_json_stream)

def _resolved_response_model_stream_item(self):
nonlocal response_model, stream_item_type
resolved_model = response_model
resolved_item_type = stream_item_type
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response):
resolved_model = None
else:
stream_item = get_stream_item_type(return_annotation)
if stream_item is not None and self.is_generator:
# Extract item type for JSONL or SSE streaming for
# generator endpoints when response_class is
# DefaultPlaceholder (JSONL) or EventSourceResponse (SSE).
# ServerSentEvent is excluded: it's a transport
# wrapper, not a data model, so it shouldn't feed
# into validation or OpenAPI schema generation.
if (
isinstance(response_class, DefaultPlaceholder)
or lenient_issubclass(response_class, EventSourceResponse)
) and not lenient_issubclass(stream_item, ServerSentEvent):
resolved_item_type = stream_item
resolved_model = None
else:
resolved_model = return_annotation

return resolved_model, resolved_item_type

_add_cache_attribute(
route,
"_resolved_response_model_stream_item",
_resolved_response_model_stream_item,
)

def _response_model(self):

return self._resolved_response_model_stream_item[0]

_add_cache_attribute(route, "response_model", _response_model)

def _stream_item_type(self):
return self._resolved_response_model_stream_item[1]

_add_cache_attribute(route, "stream_item_type", _stream_item_type)


class DeferringAPIRoute(routing.APIRoute):
def __init__(
Expand Down
8 changes: 4 additions & 4 deletions tests/data/template.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends

dependency0 = lambda: 1 # noqa: E731
dependency0 = lambda: 1

dependency = lambda: 0 # noqa: E731
dependency = lambda: 0


def dependency1(sub_dependant: int = Depends(dependency0)):
Expand All @@ -13,15 +13,15 @@ def dependency1(sub_dependant: int = Depends(dependency0)):


@router1.get("/get1")
def get1(dependency=Depends(dependency)):
def get1(dependency=Depends(dependency)): # noqa: B008
return {"1": 1}


router2 = APIRouter(prefix="/prefix2")


@router2.get("/get2")
def get2(dependency=Depends(dependency)):
def get2(dependency=Depends(dependency)): # noqa: B008
return {"2": 2}


Expand Down
15 changes: 8 additions & 7 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import os

import pytest
from fastapi.testclient import TestClient
from pydantic import BaseModel

from fastapi import FastAPI, APIRouter
from fastapi import routing
from fastapi.testclient import TestClient
from fastapi import APIRouter, FastAPI, routing
from fastapi_deferred_init.routing import (
_populate_api_route_state,
DeferringAPIRoute,
DeferringAPIRouter,
_populate_api_route_state,
)

from .data.gen_code_ast import create_code
Expand Down Expand Up @@ -107,10 +106,10 @@ def test_fastapi_openapi_schema(monkeypatch):
"fastapi/tests/test_openapi_examples.py",
)

from fastapi_clone.tests.test_additional_properties import ( # noqa # type: ignore
from fastapi_clone.tests.test_additional_properties import ( # type: ignore
test_openapi_schema as fastapi_test_openapi_schema_additional,
)
from fastapi_clone.tests.test_openapi_examples import ( # noqa # type: ignore
from fastapi_clone.tests.test_openapi_examples import ( # type: ignore
test_openapi_schema as fastapi_test_openapi_schema,
)

Expand All @@ -125,9 +124,11 @@ def test_fastapi_sse(monkeypatch):
"fastapi/tests/test_sse.py",
)

from fastapi_clone.tests.test_sse import (
client_fixture as fastapi_client_fixture,
)
from fastapi_clone.tests.test_sse import ( # type: ignore
test_raw_data_sent_without_json_encoding as fastapi_test_raw_data_sent_without_json_encoding,
client_fixture as fastapi_client_fixture,
)

for client in fastapi_client_fixture.__wrapped__():
Expand Down