Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions[bot]
63eb33ce10 🎨 Auto format 2026-06-15 13:33:02 +00:00
Yurii Motov
3b0c595c2e Fix response_model being set to None for non-generator endpoints 2026-06-15 15:31:57 +02:00
Yurii Motov
6ab384c423 Move blocks down 2026-06-15 15:30:11 +02:00
Yurii Motov
2fd2248d40 Add tests for response_model_* params with non-generator Iterable return type 2026-06-15 15:15:25 +02:00
Sebastián Ramírez
a82e5f2fac 🔖 Release version 0.137.1 (#15766)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-15 11:26:48 +00:00
github-actions[bot]
edd1461589 📝 Update release notes
[skip ci]
2026-06-15 11:20:24 +00:00
Sebastián Ramírez
b78c82262f 🚨 Fix typing checks for APIRoute (#15765) 2026-06-15 13:19:51 +02:00
github-actions[bot]
e0f8cadf09 📝 Update release notes
[skip ci]
2026-06-15 10:55:32 +00:00
Sebastián Ramírez
d8aad201eb 🐛 Fix bug, allow empty path in path operation in prefixless router (#15763) 2026-06-15 12:55:06 +02:00
15 changed files with 207 additions and 74 deletions

View File

@@ -7,6 +7,13 @@ hide:
## Latest Changes
## 0.137.1 (2026-06-15)
### Fixes
* 🚨 Fix typing checks for APIRoute. PR [#15765](https://github.com/fastapi/fastapi/pull/15765) by [@tiangolo](https://github.com/tiangolo).
* 🐛 Fix bug, allow empty path in path operation in prefixless router. PR [#15763](https://github.com/fastapi/fastapi/pull/15763) by [@tiangolo](https://github.com/tiangolo).
## 0.137.0 (2026-06-14)
### Breaking Changes

View File

@@ -492,7 +492,9 @@ item: Item
### 部署你的应用(可选) { #deploy-your-app-optional }
你可以选择用一条命令将 FastAPI 应用部署到 [FastAPI Cloud](https://fastapicloud.com)。🚀
你可以选择 FastAPI 应用部署到 [FastAPI Cloud](https://fastapicloud.com),如果还没有的话去加入候补名单吧。🚀
如果你已经有 **FastAPI Cloud** 账号(我们从候补名单邀请了你 😉),你可以用一个命令部署你的应用。
<div class="termy">
@@ -508,8 +510,6 @@ Deploying to FastAPI Cloud...
</div>
CLI 会自动检测你的 FastAPI 应用并将其部署到云端。如果你尚未登录,浏览器会打开以完成认证流程。
就这样!现在你可以通过该 URL 访问你的应用了。✨
#### 关于 FastAPI Cloud { #about-fastapi-cloud }

View File

@@ -108,7 +108,7 @@ q: str | None = None
{* ../../docs_src/body_multiple_params/tutorial004_an_py310.py hl[28] *}
/// note | 注意
/// info | 信息
`Body` 同样具有与 `Query``Path` 以及其他后面将看到的类完全相同的额外校验和元数据参数。
@@ -123,7 +123,7 @@ q: str | None = None
但是,如果你希望它期望一个拥有 `item` 键并在值中包含模型内容的 JSON就像在声明额外的请求体参数时所做的那样则可以使用一个特殊的 `Body` 参数 `embed`
```Python
item: Annotated[Item, Body(embed=True)]
item: Item = Body(embed=True)
```
比如:

View File

@@ -12,7 +12,7 @@
声明 `Cookie` 参数的方式与声明 `Query``Path` 参数相同。
你可以定义默认值,以及所有额外的验证或注参数:
第一个值是默认值,还可以传递所有验证参数或注参数:
{* ../../docs_src/cookie_params/tutorial001_an_py310.py hl[9] *}
@@ -24,13 +24,13 @@
///
/// note | 注意
/// info | 信息
必须使用 `Cookie` 声明 cookie 参数,否则该参数会被解释为查询参数。
///
/// note | 注意
/// info | 信息
请注意,由于**浏览器会以特殊方式并在幕后处理 cookies**,它们**不会**轻易允许**JavaScript**访问它们。

View File

@@ -42,14 +42,12 @@ $ python myapp.py
那么文件中由 Python 自动创建的内部变量 `__name__`,会将字符串 `"__main__"` 作为值。
所以,这一段
所以,下面这部分代码才会运行
```Python
uvicorn.run(app, host="0.0.0.0", port=8000)
```
会运行。
---
如果你是导入这个模块(文件)就不会这样。
@@ -64,15 +62,13 @@ from myapp import app
在这种情况下,`myapp.py` 内部的自动变量不会有值为 `"__main__"` 的变量 `__name__`
所以,这一行:
所以,下面这一行不会被执行:
```Python
uvicorn.run(app, host="0.0.0.0", port=8000)
```
不会被执行。
/// note | 注意
/// info | 信息
更多信息请检查 [Python 官方文档](https://docs.python.org/3/library/__main__.html).

View File

@@ -56,7 +56,7 @@ OpenAPI 概图会自动添加标签,供 API 文档接口使用:
## 从 docstring 获取描述 { #description-from-docstring }
描述内容比较长且占用多行时,可以在函数的 <dfn title="作为函数内部的第一个表达式(不赋给任何变量)的多行字符串,用于文档用途">文档字符串</dfn> 中声明*路径操作*的描述,**FastAPI** 会从中读取。
描述内容比较长且占用多行时,可以在函数的 <dfn title="作为函数内部的第一个表达式(不赋给任何变量)的多行字符串,用于文档用途">docstring</dfn> 中声明*路径操作*的描述,**FastAPI** 会从中读取。
文档字符串支持 [Markdown](https://en.wikipedia.org/wiki/Markdown),能正确解析和显示 Markdown 的内容,但要注意文档字符串的缩进。
@@ -72,13 +72,13 @@ OpenAPI 概图会自动添加标签,供 API 文档接口使用:
{* ../../docs_src/path_operation_configuration/tutorial005_py310.py hl[18] *}
/// note | 注意
/// info | 信息
注意,`response_description` 只用于描述响应,`description` 一般则用于描述*路径操作*。
///
/// tip | 提示
/// check | 检查
OpenAPI 规定每个*路径操作*都要有响应描述。

View File

@@ -8,7 +8,7 @@
{* ../../docs_src/path_params_numeric_validations/tutorial001_an_py310.py hl[1,3] *}
/// note | 注意
/// info | 信息
FastAPI 在 0.95.0 版本添加了对 `Annotated` 的支持(并开始推荐使用它)。
@@ -131,7 +131,7 @@ Python 不会对这个 `*` 做任何事,但它会知道之后的所有参数
* `lt`:小于(`l`ess `t`han
* `le`:小于等于(`l`ess than or `e`qual
/// note | 注意
/// info | 信息
`Query``Path` 以及你后面会看到的其他类,都是一个通用 `Param` 类的子类。
@@ -139,7 +139,7 @@ Python 不会对这个 `*` 做任何事,但它会知道之后的所有参数
///
/// note | 技术细节
/// note | 注意
当你从 `fastapi` 导入 `Query``Path` 和其他对象时,它们实际上是函数。

View File

@@ -2,7 +2,7 @@
你可以使用 `File` 定义由客户端上传的文件。
/// note | 注意
/// info | 信息
要接收上传的文件,请先安装 [`python-multipart`](https://github.com/Kludex/python-multipart)。
@@ -28,7 +28,7 @@ $ pip install python-multipart
{* ../../docs_src/request_files/tutorial001_an_py310.py hl[9] *}
/// note | 注意
/// info | 信息
`File` 是直接继承自 `Form` 的类。

View File

@@ -2,7 +2,7 @@
FastAPI 支持同时使用 `File``Form` 定义文件和表单字段。
/// note | 注意
/// info | 信息
接收上传的文件和/或表单数据,首先安装 [`python-multipart`](https://github.com/Kludex/python-multipart)。

View File

@@ -18,7 +18,7 @@
`status_code` 参数接收表示 HTTP 状态码的数字。
/// note | 注意
/// info | 信息
`status_code` 还能接收 `IntEnum` 类型,比如 Python 的 [`http.HTTPStatus`](https://docs.python.org/3/library/http.html#http.HTTPStatus)。

View File

@@ -8,7 +8,7 @@
## 使用 `TestClient` { #using-testclient }
/// note | 注意
/// info | 信息
要使用 `TestClient`,先要安装 [`httpx`](https://www.python-httpx.org)。
@@ -142,7 +142,7 @@ $ pip install httpx
关于如何传数据给后端的更多信息(使用 `httpx` 或 `TestClient`),请查阅 [HTTPX 文档](https://www.python-httpx.org)。
/// note | 注意
/// info | 信息
注意 `TestClient` 接收可以被转化为JSON的数据而不是Pydantic模型。

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.137.0"
__version__ = "0.137.1"
from starlette import status as status

View File

@@ -930,28 +930,6 @@ def _populate_api_route_state(
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.summary = summary
route.response_description = response_description
route.deprecated = deprecated
@@ -987,27 +965,6 @@ 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"
)
response_name = "Response_" + route.unique_id
route.response_field = create_model_field(
name=response_name,
type_=route.response_model,
mode="serialization",
)
else:
route.response_field = None
if route.stream_item_type:
stream_item_name = "StreamItem_" + route.unique_id
route.stream_item_field = create_model_field(
name=stream_item_name,
type_=route.stream_item_type,
mode="serialization",
)
else:
route.stream_item_field = None
route.dependencies = list(dependencies or [])
route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "")
# if a "form feed" character (page break) is found in the description text,
@@ -1059,9 +1016,88 @@ def _populate_api_route_state(
route.is_json_stream = is_generator and isinstance(
response_class, DefaultPlaceholder
)
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 and 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):
route.stream_item_type = stream_item
response_model = None
else:
response_model = return_annotation
route.response_model = response_model
if route.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_" + route.unique_id
route.response_field = create_model_field(
name=response_name,
type_=route.response_model,
mode="serialization",
)
else:
route.response_field = None
if route.stream_item_type:
stream_item_name = "StreamItem_" + route.unique_id
route.stream_item_field = create_model_field(
name=stream_item_name,
type_=route.stream_item_type,
mode="serialization",
)
else:
route.stream_item_field = None
class APIRoute(routing.Route):
stream_item_type: Any | None
response_model: Any
summary: str | None
response_description: str
deprecated: bool | None
operation_id: str | None
response_model_include: IncEx | None
response_model_exclude: IncEx | None
response_model_by_alias: bool
response_model_exclude_unset: bool
response_model_exclude_defaults: bool
response_model_exclude_none: bool
include_in_schema: bool
response_class: type[Response] | DefaultPlaceholder
dependency_overrides_provider: Any | None
callbacks: list[BaseRoute] | None
openapi_extra: dict[str, Any] | None
generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder
strict_content_type: bool | DefaultPlaceholder
tags: list[str | Enum]
responses: dict[int | str, dict[str, Any]]
unique_id: str
status_code: int | None
response_field: ModelField | None
stream_item_field: ModelField | None
dependencies: list[params.Depends]
description: str
response_fields: dict[int | str, ModelField]
dependant: Dependant
_flat_dependant: Dependant
_embed_body_fields: bool
body_field: ModelField | None
is_sse_stream: bool
is_json_stream: bool
def __init__(
self,
path: str,
@@ -2435,9 +2471,16 @@ class APIRouter(routing.Router):
"A path prefix must not end with '/', as the routes will start with '/'"
)
else:
for r in _iter_included_route_candidates(router.routes):
path = getattr(r, "path", None)
name = getattr(r, "name", "unknown")
for route, route_context in _iter_routes_with_context(router.routes):
if route_context is None:
path = getattr(route, "path", None)
name = getattr(route, "name", "unknown")
elif route_context.starlette_route is not None:
path = getattr(route_context.starlette_route, "path", None)
name = getattr(route_context.starlette_route, "name", "unknown")
else:
path = route_context.path
name = route_context.name
if path is not None and not path:
raise FastAPIError(
f"Prefix and path cannot be both empty (path operation: {name})"

View File

@@ -2,6 +2,7 @@ from typing import Annotated, cast
import pytest
from fastapi import APIRouter, Body, Depends, FastAPI, Request
from fastapi.exceptions import FastAPIError
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.routing import (
APIRoute,
@@ -807,6 +808,60 @@ def test_no_prefix_include_validation_sees_effective_starlette_route_candidates(
assert cast(Route, candidates[0]).path == "/child/items"
def test_no_prefix_include_validation_sees_effective_api_route_path():
leaf_router = APIRouter()
@leaf_router.get("")
def read_items():
return []
parent_router = APIRouter()
parent_router.include_router(leaf_router, prefix="/items")
# for coverage
candidates = list(_iter_included_route_candidates(parent_router.routes))
assert cast(APIRoute, candidates[0]).path == ""
app = FastAPI()
app.include_router(parent_router)
client = TestClient(app)
response = client.get("/items")
assert response.status_code == 200, response.text
assert response.json() == []
def test_no_prefix_include_validation_sees_effective_starlette_route_path():
def endpoint(request):
return PlainTextResponse("ok")
child_router = APIRouter(routes=[Route("/items", endpoint, name="read_items")])
parent_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
app = FastAPI()
app.include_router(parent_router)
client = TestClient(app)
response = client.get("/child/items")
assert response.status_code == 200, response.text
assert response.text == "ok"
def test_no_prefix_include_validation_rejects_empty_effective_api_route_path():
router = APIRouter()
@router.get("")
def read_items(): # pragma: no cover
return []
app = FastAPI()
with pytest.raises(FastAPIError):
app.include_router(router)
def test_apirouter_matches_fallback_without_include_context():
router = APIRouter()

View File

@@ -1,3 +1,5 @@
from collections.abc import Iterable
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
@@ -65,6 +67,21 @@ def get_exclude_unset_none() -> ModelDefaults:
return ModelDefaults(x=None, y="y")
@app.get("/iterable_exclude_unset", response_model_exclude_unset=True)
def get_iterable_exclude_unset() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
@app.get("/iterable_exclude_defaults", response_model_exclude_defaults=True)
def get_iterable_exclude_defaults() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
@app.get("/iterable_exclude_none", response_model_exclude_none=True)
def get_iterable_exclude_none() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
client = TestClient(app)
@@ -91,3 +108,18 @@ def test_return_exclude_none():
def test_return_exclude_unset_none():
response = client.get("/exclude_unset_none")
assert response.json() == {"y": "y"}
def test_return_iterable_exclude_unset():
response = client.get("/iterable_exclude_unset")
assert response.json() == [{"x": None, "y": "y"}]
def test_return_iterable_exclude_defaults():
response = client.get("/iterable_exclude_defaults")
assert response.json() == [{}]
def test_return_iterable_exclude_none():
response = client.get("/iterable_exclude_none")
assert response.json() == [{"y": "y", "z": "z"}]