Compare commits

...
12 Commits
Author SHA1 Message Date
Sebastián Ramírezandgithub-actions[bot] 6b6c032658 🔖 Release version 0.140.9 (#16089)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 12:36:53 +00:00
github-actions[bot] 506eba8ac3 📝 Update release notes
[skip ci]
2026-07-28 12:28:57 +00:00
Muhammad Bin GulzarandSebastián Ramírez aadfcce763 🐛 Fix exclude_defaults not propagated to dict keys and values in jsonable_encoder (#16043)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 12:28:18 +00:00
github-actions[bot] 4ffd451720 📝 Update release notes
[skip ci]
2026-07-28 12:21:55 +00:00
dependabot[bot] 5e8b7f1cb5 ⬆ Bump gitpython from 3.1.50 to 3.1.54 (#16047)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:18:40 +02:00
github-actions[bot] c4e91df63b 📝 Update release notes
[skip ci]
2026-07-28 12:17:20 +00:00
dependabot[bot] 53363a6be1 ⬆ Bump pymdown-extensions from 10.21.3 to 11.0 (#16048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:16:46 +02:00
github-actions[bot] 8b041fe96a 📝 Update release notes
[skip ci]
2026-07-28 12:10:23 +00:00
dependabot[bot] ba86fc13b4 ⬆ Bump pyasn1 from 0.6.3 to 0.6.4 (#16045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:09:41 +02:00
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
8 changed files with 221 additions and 13 deletions

No files matched your search

+18
View File
@@ -7,6 +7,24 @@ hide:
## Latest Changes
## 0.140.9 (2026-07-28)
### Fixes
* 🐛 Fix `exclude_defaults` not propagated to dict keys and values in `jsonable_encoder`. PR [#16043](https://github.com/fastapi/fastapi/pull/16043) by [@MBGrao](https://github.com/MBGrao).
### Internal
* ⬆ Bump gitpython from 3.1.50 to 3.1.54. PR [#16047](https://github.com/fastapi/fastapi/pull/16047) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump pymdown-extensions from 10.21.3 to 11.0. PR [#16048](https://github.com/fastapi/fastapi/pull/16048) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump pyasn1 from 0.6.3 to 0.6.4. PR [#16045](https://github.com/fastapi/fastapi/pull/16045) by [@dependabot[bot]](https://github.com/apps/dependabot).
## 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
+1 -1
View File
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.140.7"
__version__ = "0.140.9"
from starlette import status as status
+2
View File
@@ -299,6 +299,7 @@ def jsonable_encoder(
key,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
custom_encoder=custom_encoder,
sqlalchemy_safe=sqlalchemy_safe,
@@ -307,6 +308,7 @@ def jsonable_encoder(
value,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
custom_encoder=custom_encoder,
sqlalchemy_safe=sqlalchemy_safe,
+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
+14
View File
@@ -202,6 +202,20 @@ def test_encode_model_with_default():
}
def test_encode_model_with_default_in_dict_and_list():
model = ModelWithDefault(foo="foo", bar="bar")
assert jsonable_encoder([model], exclude_defaults=True) == [{"foo": "foo"}]
assert jsonable_encoder({"key": model}, exclude_defaults=True) == {
"key": {"foo": "foo"}
}
assert jsonable_encoder({"key": [model]}, exclude_defaults=True) == {
"key": [{"foo": "foo"}]
}
assert jsonable_encoder({"key": model}) == {
"key": {"foo": "foo", "bar": "bar", "bla": "bla"}
}
def test_custom_encoders():
class safe_datetime(datetime):
pass
+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"}}
},
}
Generated
+9 -9
View File
@@ -1384,14 +1384,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.50"
version = "3.1.54"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" },
]
[[package]]
@@ -2820,11 +2820,11 @@ memory = [
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
@@ -3166,15 +3166,15 @@ crypto = [
[[package]]
name = "pymdown-extensions"
version = "10.21.3"
version = "11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
]
[[package]]