Compare commits

...
10 Commits
Author SHA1 Message Date
Sebastián Ramírezandgithub-actions[bot] 773342f978 🔖 Release version 0.140.8 (#16088)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 10:29:51 +00:00
github-actions[bot] fd557905ea 📝 Update release notes
[skip ci]
2026-07-28 10:22:53 +00:00
ad03e117c0 🐛 Fix stream item type lost when using include_router() (#15077)
Co-authored-by: Alexander Rauhut <alexander.rauhut@adnova.gmbh>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 12:22:08 +02:00
Sebastián Ramírezandgithub-actions[bot] 98b12fe56f 🔖 Release version 0.140.7 (#16078)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 17:28:03 +00:00
github-actions[bot] e772894447 📝 Update release notes
[skip ci]
2026-07-27 17:26:20 +00:00
Sebastián Ramírez 24c2a9fdf9 ⬆️ Upgrade latest-changes to 0.7.1 (#16077) 2026-07-27 19:25:00 +02:00
github-actions[bot] add1d2685b 📝 Update release notes
[skip ci]
2026-07-27 17:17:14 +00:00
Sebastián Ramírez 7bcb78d10d ️ Avoid flattening dependencies for OpenAPI (#16076) 2026-07-27 17:16:40 +00:00
github-actions[bot] 87095aa581 📝 Update release notes
[skip ci]
2026-07-27 16:47:36 +00:00
Sebastián Ramírez 3d3c6913e8 👷 Add OpenAPI dependency benchmarks (#16075) 2026-07-27 18:46:54 +02:00
13 changed files with 383 additions and 113 deletions

No files matched your search

+1 -5
View File
@@ -40,11 +40,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
with:
limit-access-to-actor: true
- uses: tiangolo/latest-changes@c9b73efbc8992ef1a401e4235ea307a8ca8a724b # 0.6.1
- uses: tiangolo/latest-changes@8a940392f4c65274539453a5d5a76d9550203ac1 # 0.7.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
latest_changes_file: docs/en/docs/release-notes.md
latest_changes_header: '## Latest Changes'
end_regex: '^## '
debug_logs: true
label_header_prefix: '### '
+17
View File
@@ -7,6 +7,23 @@ hide:
## Latest Changes
## 0.140.8 (2026-07-28)
### Fixes
* 🐛 Fix stream item type lost when using `include_router()`. PR [#15077](https://github.com/fastapi/fastapi/pull/15077) by [@alex-raw](https://github.com/alex-raw).
## 0.140.7 (2026-07-27)
### Refactors
* ⚡️ Avoid flattening dependencies for OpenAPI. PR [#16076](https://github.com/fastapi/fastapi/pull/16076) by [@tiangolo](https://github.com/tiangolo).
### Internal
* ⬆️ Upgrade latest-changes to 0.7.1. PR [#16077](https://github.com/fastapi/fastapi/pull/16077) by [@tiangolo](https://github.com/tiangolo).
* 👷 Add OpenAPI dependency benchmarks. PR [#16075](https://github.com/fastapi/fastapi/pull/16075) by [@tiangolo](https://github.com/tiangolo).
## 0.140.6 (2026-07-27)
### Refactors
+1 -1
View File
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.140.6"
__version__ = "0.140.8"
from starlette import status as status
-4
View File
@@ -134,10 +134,6 @@ def _get_security_scheme(*, dependant: Dependant) -> SecurityBase:
return unwrapped
def _get_security_dependencies(*, dependant: Dependant) -> list[Dependant]:
return [dep for dep in dependant.dependencies if _is_security_scheme(dependant=dep)]
@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)
def _is_gen_callable_cached(call_identity: _CallIdentity) -> bool:
call = call_identity.call
-75
View File
@@ -144,81 +144,6 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
)
def get_flat_dependant(
dependant: Dependant,
*,
skip_repeats: bool = False,
visited: list[DependencyCacheKey] | None = None,
parent_oauth_scopes: list[str] | None = None,
_uses_scopes_cache: _UsesScopesCache | None = None,
) -> Dependant:
track_visited = (
skip_repeats or visited is not None or _uses_scopes_cache is not None
)
if track_visited:
if visited is None:
visited = []
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 []) + (
_get_oauth_scopes(dependant=dependant)
)
flat_dependant = Dependant(
path_params=dependant.path_params.copy(),
query_params=dependant.query_params.copy(),
header_params=dependant.header_params.copy(),
cookie_params=dependant.cookie_params.copy(),
body_params=dependant.body_params.copy(),
name=dependant.name,
call=dependant.call,
request_param_name=dependant.request_param_name,
websocket_param_name=dependant.websocket_param_name,
http_connection_param_name=dependant.http_connection_param_name,
response_param_name=dependant.response_param_name,
background_tasks_param_name=dependant.background_tasks_param_name,
security_scopes_param_name=dependant.security_scopes_param_name,
own_oauth_scopes=dependant.own_oauth_scopes,
parent_oauth_scopes=use_parent_oauth_scopes,
use_cache=dependant.use_cache,
path=dependant.path,
scope=dependant.scope,
)
for sub_dependant in dependant.dependencies:
if skip_repeats:
assert visited is not None
if (
_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=_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)
flat_dependant.query_params.extend(flat_sub.query_params)
flat_dependant.header_params.extend(flat_sub.header_params)
flat_dependant.cookie_params.extend(flat_sub.cookie_params)
flat_dependant.body_params.extend(flat_sub.body_params)
flat_dependant.dependencies.extend(flat_sub.dependencies)
return flat_dependant
def _get_flat_body_params(dependant: Dependant) -> list[ModelField]:
body_params: list[ModelField] = []
dependants = [dependant]
+70 -22
View File
@@ -3,6 +3,7 @@ import http.client
import inspect
import warnings
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, cast
from fastapi import routing
@@ -17,13 +18,14 @@ from fastapi._compat import (
from fastapi.datastructures import DefaultPlaceholder, _Unset
from fastapi.dependencies.models import (
Dependant,
_get_cache_key,
_get_oauth_scopes,
_get_security_dependencies,
_get_security_scheme,
_is_security_scheme,
_UsesScopesCache,
)
from fastapi.dependencies.utils import (
_get_flat_fields_from_params,
get_flat_dependant,
get_flat_params,
get_validation_alias,
)
@@ -34,7 +36,7 @@ from fastapi.openapi.models import OpenAPI
from fastapi.params import Body, ParamTypes
from fastapi.responses import Response
from fastapi.sse import _SSE_EVENT_SCHEMA
from fastapi.types import ModelNameMap
from fastapi.types import DependencyCacheKey, ModelNameMap
from fastapi.utils import (
deep_dict_update,
generate_operation_id_for_path,
@@ -83,13 +85,57 @@ status_code_ranges: dict[str, str] = {
}
def get_openapi_security_definitions(
flat_dependant: Dependant,
@dataclass
class _OpenAPIDependencyData:
path_params: list[ModelField] = field(default_factory=list)
query_params: list[ModelField] = field(default_factory=list)
header_params: list[ModelField] = field(default_factory=list)
cookie_params: list[ModelField] = field(default_factory=list)
security_dependencies: list[tuple[Dependant, list[str]]] = field(
default_factory=list
)
def _get_openapi_dependency_data(dependant: Dependant) -> _OpenAPIDependencyData:
dependency_data = _OpenAPIDependencyData()
visited: list[DependencyCacheKey] = []
uses_scopes_cache: _UsesScopesCache = {}
dependants: list[tuple[Dependant, list[str], bool]] = [(dependant, [], True)]
while dependants:
current_dependant, parent_oauth_scopes, is_root = dependants.pop()
cache_key = _get_cache_key(
dependant=current_dependant,
uses_scopes_cache=uses_scopes_cache,
)
if cache_key in visited:
continue
visited.append(cache_key)
dependency_data.path_params.extend(current_dependant.path_params)
dependency_data.query_params.extend(current_dependant.query_params)
dependency_data.header_params.extend(current_dependant.header_params)
dependency_data.cookie_params.extend(current_dependant.cookie_params)
oauth_scopes = parent_oauth_scopes.copy()
for scope in _get_oauth_scopes(dependant=current_dependant):
if scope not in oauth_scopes:
oauth_scopes.append(scope)
if not is_root and _is_security_scheme(dependant=current_dependant):
dependency_data.security_dependencies.append(
(current_dependant, oauth_scopes)
)
dependants.extend(
(sub_dependant, oauth_scopes, False)
for sub_dependant in reversed(current_dependant.dependencies)
)
return dependency_data
def _get_openapi_security_definitions(
security_dependencies: list[tuple[Dependant, list[str]]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
security_definitions = {}
# Use a dict to merge scopes for same security scheme
operation_security_dict: dict[str, list[str]] = {}
for security_dependency in _get_security_dependencies(dependant=flat_dependant):
for security_dependency, oauth_scopes in security_dependencies:
security_scheme = _get_security_scheme(dependant=security_dependency)
security_definition = jsonable_encoder(
security_scheme.model,
@@ -101,7 +147,7 @@ def get_openapi_security_definitions(
# Merge scopes for the same security scheme
if security_name not in operation_security_dict:
operation_security_dict[security_name] = []
for scope in _get_oauth_scopes(dependant=security_dependency):
for scope in oauth_scopes:
if scope not in operation_security_dict[security_name]:
operation_security_dict[security_name].append(scope)
operation_security = [
@@ -112,7 +158,7 @@ def get_openapi_security_definitions(
def _get_openapi_operation_parameters(
*,
flat_dependant: Dependant,
dependency_data: _OpenAPIDependencyData,
model_name_map: ModelNameMap,
field_mapping: dict[
tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any]
@@ -120,10 +166,10 @@ def _get_openapi_operation_parameters(
separate_input_output_schemas: bool = True,
) -> list[dict[str, Any]]:
parameters = []
path_params = _get_flat_fields_from_params(flat_dependant.path_params)
query_params = _get_flat_fields_from_params(flat_dependant.query_params)
header_params = _get_flat_fields_from_params(flat_dependant.header_params)
cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params)
path_params = _get_flat_fields_from_params(dependency_data.path_params)
query_params = _get_flat_fields_from_params(dependency_data.query_params)
header_params = _get_flat_fields_from_params(dependency_data.header_params)
cookie_params = _get_flat_fields_from_params(dependency_data.cookie_params)
parameter_groups = [
(ParamTypes.path, path_params),
(ParamTypes.query, query_params),
@@ -131,8 +177,8 @@ def _get_openapi_operation_parameters(
(ParamTypes.cookie, cookie_params),
]
default_convert_underscores = True
if len(flat_dependant.header_params) == 1:
first_field = flat_dependant.header_params[0]
if len(dependency_data.header_params) == 1:
first_field = dependency_data.header_params[0]
if lenient_issubclass(first_field.field_info.annotation, BaseModel):
default_convert_underscores = getattr(
first_field.field_info, "convert_underscores", True
@@ -283,14 +329,14 @@ def get_openapi_path(
assert current_response_class, "A response class is needed to generate OpenAPI"
route_response_media_type: str | None = current_response_class.media_type
if route.include_in_schema:
flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True)
dependency_data = _get_openapi_dependency_data(route.dependant)
all_route_params = [
field
for fields in (
flat_dependant.path_params,
flat_dependant.query_params,
flat_dependant.header_params,
flat_dependant.cookie_params,
dependency_data.path_params,
dependency_data.query_params,
dependency_data.header_params,
dependency_data.cookie_params,
)
for field in _get_flat_fields_from_params(fields)
]
@@ -299,15 +345,17 @@ def get_openapi_path(
route=route, method=method, operation_ids=operation_ids
)
parameters: list[dict[str, Any]] = []
security_definitions, operation_security = get_openapi_security_definitions(
flat_dependant=flat_dependant
security_definitions, operation_security = (
_get_openapi_security_definitions(
security_dependencies=dependency_data.security_dependencies
)
)
if operation_security:
operation.setdefault("security", []).extend(operation_security)
if security_definitions:
security_schemes.update(security_definitions)
operation_parameters = _get_openapi_operation_parameters(
flat_dependant=flat_dependant,
dependency_data=dependency_data,
model_name_map=model_name_map,
field_mapping=field_mapping,
separate_input_output_schemas=separate_input_output_schemas,
+3 -1
View File
@@ -982,10 +982,11 @@ def _populate_api_route_state(
generate_unique_id
),
strict_content_type: bool | DefaultPlaceholder = Default(True),
stream_item_type: Any | None = None,
) -> None:
route.path = path
route.endpoint = endpoint
route.stream_item_type = None
route.stream_item_type = stream_item_type
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response):
@@ -1464,6 +1465,7 @@ class _EffectiveRouteContext:
include_context.included_router.strict_content_type,
include_context.strict_content_type,
),
stream_item_type=route.stream_item_type,
)
return context
+33
View File
@@ -0,0 +1,33 @@
import sys
import pytest
from tests.benchmarks.utils import (
ROUTE_COUNT,
ROUTE_PATH_PREFIX,
create_openapi_app,
generate_openapi,
)
if "--codspeed" not in sys.argv:
pytest.skip(
"Benchmark tests are skipped by default; run with --codspeed.",
allow_module_level=True,
)
@pytest.mark.timeout(60)
def test_openapi_dependency_graph(benchmark) -> None:
app = create_openapi_app()
schema = benchmark(generate_openapi, app)
dynamic_paths = [
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
]
assert len(dynamic_paths) == ROUTE_COUNT
assert all(
any(
parameter["in"] == "query" and parameter["name"] == "query_value"
for parameter in schema["paths"][path]["get"]["parameters"]
)
for path in dynamic_paths
)
+51
View File
@@ -0,0 +1,51 @@
from collections.abc import Callable
from typing import Annotated, Any
from fastapi import Depends, FastAPI
LAST_DEPENDENCY_INDEX = 100
ROUTE_COUNT = 20
ROUTE_PATH_PREFIX = "/openapi-route-"
def create_openapi_app() -> FastAPI:
app = FastAPI()
dependencies: dict[int, Callable[..., Any]] = {}
def create_dependency(index: int) -> Callable[..., Any]:
if index == LAST_DEPENDENCY_INDEX:
def dependency(query_value: int = index) -> str:
return str(query_value)
dependency.__name__ = f"dependency_{index}"
return dependency
next_dependency = dependencies[index + 1]
async def dependency(
sub_dependency: Annotated[str, Depends(next_dependency)],
query_value: int = index,
) -> str:
return f"{query_value} -> {sub_dependency}"
dependency.__name__ = f"dependency_{index}"
return dependency
for index in reversed(range(LAST_DEPENDENCY_INDEX + 1)):
dependencies[index] = create_dependency(index)
async def endpoint(
value: Annotated[str, Depends(dependencies[0])],
) -> dict[str, str]:
return {"value": value}
for index in range(ROUTE_COUNT):
app.add_api_route(f"{ROUTE_PATH_PREFIX}{index}", endpoint, methods=["GET"])
return app
def generate_openapi(app: FastAPI) -> dict[str, Any]:
app.openapi_schema = None
return app.openapi()
+33
View File
@@ -0,0 +1,33 @@
import sys
import pytest
from tests.benchmarks.utils import (
ROUTE_COUNT,
ROUTE_PATH_PREFIX,
create_openapi_app,
generate_openapi,
)
if "--codspeed" not in sys.argv:
pytest.skip(
"Benchmark tests are skipped by default; run with --codspeed.",
allow_module_level=True,
)
@pytest.mark.timeout(60)
def test_openapi_dependency_graph(benchmark) -> None:
app = create_openapi_app()
schema = benchmark(generate_openapi, app)
dynamic_paths = [
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
]
assert len(dynamic_paths) == ROUTE_COUNT
assert all(
any(
parameter["in"] == "query" and parameter["name"] == "query_value"
for parameter in schema["paths"][path]["get"]["parameters"]
)
for path in dynamic_paths
)
-3
View File
@@ -6,7 +6,6 @@ from fastapi.dependencies.models import (
_get_cache_key,
_get_computed_scope,
_get_oauth_scopes,
_get_security_dependencies,
_get_security_scheme,
_is_async_gen_callable,
_is_async_gen_callable_cached,
@@ -146,7 +145,6 @@ def test_derived_values_are_not_stored_on_dependant() -> None:
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, (), "")
@@ -160,7 +158,6 @@ def test_security_scheme_helpers() -> None:
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)
+141 -1
View File
@@ -64,7 +64,8 @@ async def sse_items_event():
@app.get("/items/stream-mixed", response_class=EventSourceResponse)
async def sse_items_mixed() -> AsyncIterable[Item]:
yield items[0]
for item in items:
yield item
yield ServerSentEvent(data="custom-event", event="special")
yield items[1]
@@ -96,6 +97,12 @@ async def stream_events():
yield {"msg": "world"}
@router.get("/events-typed", response_class=EventSourceResponse)
async def stream_events_typed() -> AsyncIterable[Item]:
for item in items:
yield item
app.include_router(router, prefix="/api")
@@ -274,6 +281,45 @@ def test_sse_on_router_included_in_app(client: TestClient):
assert len(data_lines) == 2
def test_sse_router_typed_stream(client: TestClient):
response = client.get("/api/events-typed")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_sse_router_typed_openapi_schema(client: TestClient):
"""Typed SSE endpoint on a router should preserve itemSchema with contentSchema."""
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
sse_response = paths["/api/events-typed"]["get"]["responses"]["200"]
assert sse_response == {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {"$ref": "#/components/schemas/Item"},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {"type": "integer", "minimum": 0},
},
"required": ["data"],
}
}
},
}
# Keepalive ping tests
@@ -325,3 +371,97 @@ def test_no_keepalive_when_fast(client: TestClient):
assert response.status_code == 200
# KEEPALIVE_COMMENT is ": ping\n\n".
assert ": ping\n" not in response.text
# default_response_class tests
sse_schema_response = {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {"$ref": "#/components/schemas/Item"},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {"type": "integer", "minimum": 0},
},
"required": ["data"],
}
}
},
}
# default_response_class on app
default_app_app = FastAPI(default_response_class=EventSourceResponse)
default_app_router = APIRouter()
@default_app_router.get("/stream")
async def default_app_stream() -> AsyncIterable[Item]:
for item in items:
yield item
default_app_app.include_router(default_app_router, prefix="/api")
def test_default_response_class_on_app_stream():
with TestClient(default_app_app) as client:
response = client.get("/api/stream")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_default_response_class_on_app_openapi_schema():
assert (
default_app_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
== sse_schema_response
)
# default_response_class on parent router
default_parent_app = FastAPI()
parent_router = APIRouter(default_response_class=EventSourceResponse)
child_router = APIRouter()
@child_router.get("/stream")
async def default_parent_stream() -> AsyncIterable[Item]:
for item in items:
yield item
parent_router.include_router(child_router)
default_parent_app.include_router(parent_router, prefix="/api")
def test_default_response_class_on_parent_router_stream():
with TestClient(default_parent_app) as client:
response = client.get("/api/stream")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_default_response_class_on_parent_router_openapi_schema():
assert (
default_parent_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
== sse_schema_response
)
+33 -1
View File
@@ -1,13 +1,14 @@
import json
from typing import AsyncIterable, Iterable # noqa: UP035 to test coverage
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Item(BaseModel):
name: str
optional: str | None = None
app = FastAPI()
@@ -23,6 +24,16 @@ def stream_bare_sync() -> Iterable:
yield {"name": "bar"}
router = APIRouter()
@router.get("/events-jsonl", response_model_exclude_none=True)
async def stream_events_jsonl() -> AsyncIterable[Item]:
yield Item(name="foo")
app.include_router(router, prefix="/api")
client = TestClient(app)
@@ -40,3 +51,24 @@ def test_stream_bare_sync_iterable():
assert response.headers["content-type"] == "application/jsonl"
lines = [json.loads(line) for line in response.text.strip().splitlines()]
assert lines == [{"name": "bar"}]
def test_jsonl_router_typed_stream():
response = client.get("/api/events-jsonl")
assert response.status_code == 200
assert response.headers["content-type"] == "application/jsonl"
lines = [json.loads(line) for line in response.text.strip().splitlines()]
assert lines == [{"name": "foo"}]
def test_jsonl_router_typed_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
jsonl_response = paths["/api/events-jsonl"]["get"]["responses"]["200"]
assert jsonl_response == {
"description": "Successful Response",
"content": {
"application/jsonl": {"itemSchema": {"$ref": "#/components/schemas/Item"}}
},
}