mirror of
https://github.com/fastapi/fastapi.git
synced 2026-09-10 04:17:51 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e7b651d6 | ||
|
|
6bceb84053 | ||
|
|
5429fed84e | ||
|
|
628663f4f8 | ||
|
|
0b54fd0027 | ||
|
|
e92a0dc3ce | ||
|
|
6215d8a6f3 | ||
|
|
12242c4fba | ||
|
|
70a8f3dd03 | ||
|
|
4f0152c028 | ||
|
|
5f255058f4 | ||
|
|
76e2c833b5 | ||
|
|
0f3e7bd682 | ||
|
|
31ce3cb8d7 | ||
|
|
4a01c7f1a5 | ||
|
|
d6537f774b |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Server-Sent Events - `EventSourceResponse` and `ServerSentEvent`
|
||||
|
||||
To stream Server-Sent Events (SSE), use `yield` in your *path operation function* and set `response_class=EventSourceResponse`.
|
||||
|
||||
If you need to set SSE fields like `event`, `id`, `retry`, or `comment`, you can `yield` `ServerSentEvent` objects instead of plain data.
|
||||
|
||||
Read more about it in the [FastAPI docs for Server-Sent Events (SSE)](https://fastapi.tiangolo.com/tutorial/server-sent-events/).
|
||||
|
||||
You can import them directly from `fastapi.sse`:
|
||||
|
||||
```python
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
```
|
||||
|
||||
::: fastapi.sse.EventSourceResponse
|
||||
|
||||
::: fastapi.sse.ServerSentEvent
|
||||
@@ -7,6 +7,35 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 0.141.0 (2026-07-29)
|
||||
|
||||
### Features
|
||||
|
||||
* ✨ Add `app.frontend(check_dir="auto")`, to make local development more convenient with `fastapi dev`. PR [#16102](https://github.com/fastapi/fastapi/pull/16102) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.13 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix `status_code` being ignored for SSE and JSONL streaming endpoints. PR [#15937](https://github.com/fastapi/fastapi/pull/15937) by [@SAURABHSALVE](https://github.com/SAURABHSALVE).
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Fix `format_sse_event` docstring rendering of `\n\n` terminator. PR [#15613](https://github.com/fastapi/fastapi/pull/15613) by [@AshNicolus](https://github.com/AshNicolus).
|
||||
* 📝 Add API reference page for fastapi.sse. PR [#15930](https://github.com/fastapi/fastapi/pull/15930) by [@SAURABHSALVE](https://github.com/SAURABHSALVE).
|
||||
|
||||
## 0.140.12 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix line splitting in `format_sse_event` to comply with SSE spec. PR [#15515](https://github.com/fastapi/fastapi/pull/15515) by [@Zawwarsami16](https://github.com/Zawwarsami16).
|
||||
|
||||
## 0.140.11 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix `response_model_*` params ignored for non-generator endpoints with `Iterable[..]` return type. PR [#15093](https://github.com/fastapi/fastapi/pull/15093) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
## 0.140.10 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -106,9 +106,13 @@ Then missing frontend paths return the normal `404`.
|
||||
|
||||
## Check Directory { #check-directory }
|
||||
|
||||
By default, `app.frontend()` checks that the directory exists when the app is created.
|
||||
By default, `app.frontend()` uses `check_dir="auto"`.
|
||||
|
||||
This helps catch configuration errors early. For example, if the frontend build output directory is missing, **FastAPI** will raise an error on startup.
|
||||
When the `FASTAPI_ENV` environment variable is set to `development`, **FastAPI** only shows a warning if the frontend build output directory is missing. The [`fastapi dev` command](https://github.com/fastapi/fastapi-cli#fastapi-dev) sets this environment variable for you if it is not already set. This lets you start the backend before building or starting the frontend during development.
|
||||
|
||||
In any other environment, **FastAPI** raises an error when the app is created. This helps catch configuration errors early before deploying an app without its frontend files.
|
||||
|
||||
You can also set `check_dir=True` to always check the directory when the app is created.
|
||||
|
||||
If your frontend files are created later, for example by a separate build step after the app object is created, set `check_dir=False`:
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ nav:
|
||||
- reference/httpconnection.md
|
||||
- reference/response.md
|
||||
- reference/responses.md
|
||||
- reference/sse.md
|
||||
- reference/middleware.md
|
||||
- "":
|
||||
- reference/openapi/index.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.140.10"
|
||||
__version__ = "0.141.0"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -1247,13 +1247,16 @@ class FastAPI(Starlette):
|
||||
),
|
||||
] = "auto",
|
||||
check_dir: Annotated[
|
||||
bool,
|
||||
bool | Literal["auto"],
|
||||
Doc(
|
||||
"""
|
||||
Check that the frontend directory exists when the app is created.
|
||||
Check that the frontend directory exists when the app is created. When
|
||||
set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is
|
||||
`"development"`, and check it otherwise. The `fastapi dev` command
|
||||
sets `FASTAPI_ENV` to `"development"` if it is not already set.
|
||||
"""
|
||||
),
|
||||
] = True,
|
||||
] = "auto",
|
||||
) -> None:
|
||||
"""
|
||||
Serve a static frontend build as low-priority routes.
|
||||
@@ -1285,6 +1288,9 @@ class FastAPI(Starlette):
|
||||
app.frontend("/", directory="dist")
|
||||
```
|
||||
"""
|
||||
check_dir = routing._resolve_frontend_check_dir(
|
||||
directory=directory, check_dir=check_dir
|
||||
)
|
||||
self.router.frontend(
|
||||
path,
|
||||
directory=directory,
|
||||
|
||||
+82
-51
@@ -9,6 +9,7 @@ import os
|
||||
import stat
|
||||
import threading
|
||||
import types
|
||||
import warnings
|
||||
from collections.abc import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
@@ -630,10 +631,13 @@ def get_request_handler(
|
||||
_sse_with_checkpoints(sse_receive_stream)
|
||||
)
|
||||
|
||||
response_args = _build_response_args(
|
||||
status_code=status_code, solved_result=solved_result
|
||||
)
|
||||
response = StreamingResponse(
|
||||
sse_stream_content,
|
||||
media_type="text/event-stream",
|
||||
background=solved_result.background_tasks,
|
||||
**response_args,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
# For Nginx proxies to not buffer server sent events
|
||||
@@ -666,10 +670,13 @@ def get_request_handler(
|
||||
|
||||
jsonl_stream_content = _sync_stream_jsonl()
|
||||
|
||||
response_args = _build_response_args(
|
||||
status_code=status_code, solved_result=solved_result
|
||||
)
|
||||
response = StreamingResponse(
|
||||
jsonl_stream_content,
|
||||
media_type="application/jsonl",
|
||||
background=solved_result.background_tasks,
|
||||
**response_args,
|
||||
)
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
elif _is_async_gen_callable(dependant.call) or _is_gen_callable(
|
||||
@@ -987,28 +994,6 @@ def _populate_api_route_state(
|
||||
route.path = path
|
||||
route.endpoint = endpoint
|
||||
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):
|
||||
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
|
||||
@@ -1044,27 +1029,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,
|
||||
@@ -1113,6 +1077,49 @@ 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):
|
||||
@@ -1870,13 +1877,31 @@ def _get_resolved_absolute_path(path: str | os.PathLike[str]) -> str:
|
||||
return os.path.realpath(os.fspath(path))
|
||||
|
||||
|
||||
def _resolve_frontend_check_dir(
|
||||
*,
|
||||
directory: str | os.PathLike[str],
|
||||
check_dir: bool | Literal["auto"],
|
||||
) -> bool:
|
||||
if check_dir != "auto":
|
||||
return check_dir
|
||||
if os.environ.get("FASTAPI_ENV") != "development":
|
||||
return True
|
||||
if not os.path.isdir(directory):
|
||||
warnings.warn(
|
||||
f"Frontend directory '{directory}' does not exist. "
|
||||
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'",
|
||||
stacklevel=3,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class _FrontendStaticFiles(StaticFiles):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
directory: str | os.PathLike[str],
|
||||
fallback: Literal["auto", "index.html", "404.html"] | None,
|
||||
check_dir: bool = True,
|
||||
check_dir: bool,
|
||||
) -> None:
|
||||
self.fallback = fallback
|
||||
if check_dir and not os.path.isdir(directory):
|
||||
@@ -2019,7 +2044,7 @@ class _FrontendRoute(BaseRoute):
|
||||
*,
|
||||
directory: str | os.PathLike[str],
|
||||
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
|
||||
check_dir: bool = True,
|
||||
check_dir: bool,
|
||||
) -> None:
|
||||
if fallback not in {"auto", "index.html", "404.html", None}:
|
||||
raise AssertionError(
|
||||
@@ -2093,7 +2118,7 @@ class _FrontendRouteGroup(BaseRoute):
|
||||
*,
|
||||
directory: str | os.PathLike[str],
|
||||
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
|
||||
check_dir: bool = True,
|
||||
check_dir: bool,
|
||||
) -> None:
|
||||
self.routes.append(
|
||||
_FrontendRoute(
|
||||
@@ -2618,13 +2643,16 @@ class APIRouter(routing.Router):
|
||||
),
|
||||
] = "auto",
|
||||
check_dir: Annotated[
|
||||
bool,
|
||||
bool | Literal["auto"],
|
||||
Doc(
|
||||
"""
|
||||
Check that the frontend directory exists when the app is created.
|
||||
Check that the frontend directory exists when the app is created. When
|
||||
set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is
|
||||
`"development"`, and check it otherwise. The `fastapi dev` command
|
||||
sets `FASTAPI_ENV` to `"development"` if it is not already set.
|
||||
"""
|
||||
),
|
||||
] = True,
|
||||
] = "auto",
|
||||
) -> None:
|
||||
"""
|
||||
Serve a static frontend build as low-priority routes.
|
||||
@@ -2658,6 +2686,9 @@ class APIRouter(routing.Router):
|
||||
app.include_router(router)
|
||||
```
|
||||
"""
|
||||
check_dir = _resolve_frontend_check_dir(
|
||||
directory=directory, check_dir=check_dir
|
||||
)
|
||||
normalized_path = _normalize_frontend_path(path)
|
||||
if self._frontend_routes is None:
|
||||
self._frontend_routes = _FrontendRouteGroup(
|
||||
|
||||
+9
-3
@@ -156,6 +156,12 @@ class ServerSentEvent(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def _split_sse_lines(value: str) -> list[str]:
|
||||
# Split on SSE-spec line terminators only (\n, \r\n, \r), preserving
|
||||
# trailing empty strings.
|
||||
return value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
|
||||
|
||||
def format_sse_event(
|
||||
*,
|
||||
data_str: Annotated[
|
||||
@@ -201,19 +207,19 @@ def format_sse_event(
|
||||
) -> bytes:
|
||||
"""Build SSE wire-format bytes from **pre-serialized** data.
|
||||
|
||||
The result always ends with `\n\n` (the event terminator).
|
||||
The result always ends with `\\n\\n` (the event terminator).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
if comment is not None:
|
||||
for line in comment.splitlines():
|
||||
for line in _split_sse_lines(comment):
|
||||
lines.append(f": {line}")
|
||||
|
||||
if event is not None:
|
||||
lines.append(f"event: {event}")
|
||||
|
||||
if data_str is not None:
|
||||
for line in data_str.splitlines():
|
||||
for line in _split_sse_lines(data_str):
|
||||
lines.append(f"data: {line}")
|
||||
|
||||
if id is not None:
|
||||
|
||||
+3
-3
@@ -58,7 +58,7 @@ Changelog = "https://fastapi.tiangolo.com/release-notes/"
|
||||
|
||||
[project.optional-dependencies]
|
||||
standard = [
|
||||
"fastapi-cli[standard] >=0.0.8",
|
||||
"fastapi-cli[standard] >=0.0.32",
|
||||
"fastar >= 0.9.0",
|
||||
# For the test client
|
||||
"httpx >=0.23.0,<1.0.0",
|
||||
@@ -77,7 +77,7 @@ standard = [
|
||||
]
|
||||
|
||||
standard-no-fastapi-cloud-cli = [
|
||||
"fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.8",
|
||||
"fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.32",
|
||||
# For the test client
|
||||
"httpx >=0.23.0,<1.0.0",
|
||||
# For templates
|
||||
@@ -95,7 +95,7 @@ standard-no-fastapi-cloud-cli = [
|
||||
]
|
||||
|
||||
all = [
|
||||
"fastapi-cli[standard] >=0.0.8",
|
||||
"fastapi-cli[standard] >=0.0.32",
|
||||
# # For the test client
|
||||
"httpx >=0.23.0,<1.0.0",
|
||||
# For templates
|
||||
|
||||
+38
-1
@@ -1176,13 +1176,50 @@ def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not exist") as exc_info:
|
||||
app.frontend("/", directory="missing")
|
||||
app.frontend("/", directory="missing", check_dir=True)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "'missing'" in message
|
||||
assert str(tmp_path / "missing") in message
|
||||
|
||||
|
||||
def test_check_dir_auto_warns_in_development(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("FASTAPI_ENV", "development")
|
||||
app = FastAPI()
|
||||
|
||||
with pytest.warns(UserWarning, match="does not exist") as warnings:
|
||||
app.frontend("/", directory=tmp_path / "missing")
|
||||
|
||||
assert str(tmp_path / "missing") in str(warnings[0].message)
|
||||
assert warnings[0].filename == __file__
|
||||
|
||||
|
||||
def test_check_dir_auto_router_warning_points_to_user_code(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("FASTAPI_ENV", "development")
|
||||
router = APIRouter()
|
||||
|
||||
with pytest.warns(UserWarning, match="does not exist") as warnings:
|
||||
router.frontend("/", directory=tmp_path / "missing")
|
||||
|
||||
assert warnings[0].filename == __file__
|
||||
|
||||
|
||||
def test_check_dir_true_fails_in_development(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("FASTAPI_ENV", "development")
|
||||
app = FastAPI()
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not exist"):
|
||||
app.frontend("/", directory=tmp_path / "missing", check_dir=True)
|
||||
|
||||
|
||||
def test_check_dir_auto_fails_outside_development(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("FASTAPI_ENV", "production")
|
||||
router = APIRouter()
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not exist"):
|
||||
router.frontend("/", directory=tmp_path / "missing")
|
||||
|
||||
|
||||
def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path):
|
||||
app = FastAPI()
|
||||
app.frontend("/", directory=tmp_path / "missing", check_dir=False)
|
||||
|
||||
@@ -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"}]
|
||||
+24
-1
@@ -6,7 +6,7 @@ import fastapi.routing
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import EventSourceResponse
|
||||
from fastapi.sse import ServerSentEvent
|
||||
from fastapi.sse import ServerSentEvent, format_sse_event
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -373,6 +373,29 @@ def test_no_keepalive_when_fast(client: TestClient):
|
||||
assert ": ping\n" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "expected_result"),
|
||||
[
|
||||
("Hello\n", b"data: Hello\ndata: \n\n"),
|
||||
("Hello\n\n", b"data: Hello\ndata: \ndata: \n\n"),
|
||||
("\n", b"data: \ndata: \n\n"),
|
||||
("Hello\r\nWorld", b"data: Hello\ndata: World\n\n"),
|
||||
("Hello\rWorld", b"data: Hello\ndata: World\n\n"),
|
||||
("A\u2028B", "data: A\u2028B\n\n".encode()),
|
||||
("A\vB", b"data: A\x0bB\n\n"),
|
||||
("", b"data: \n\n"),
|
||||
],
|
||||
)
|
||||
def test_format_sse_event_splitlines_behavior_in_data(
|
||||
data: str, expected_result: bytes
|
||||
) -> None:
|
||||
assert format_sse_event(data_str=data) == expected_result
|
||||
|
||||
|
||||
def test_format_sse_event_splitlines_behavior_in_comment():
|
||||
assert format_sse_event(comment="hi\n") == b": hi\n: \n\n"
|
||||
|
||||
|
||||
# default_response_class tests
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI, Response
|
||||
from fastapi.responses import EventSourceResponse, StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
SSE_RESPONSE = {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "SSE stream item",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
JSONL_RESPONSE = {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "JSONL stream item",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def set_accepted(response: Response) -> None:
|
||||
response.status_code = 202
|
||||
|
||||
|
||||
@app.post("/sse", response_class=EventSourceResponse, status_code=201)
|
||||
async def sse() -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "created"}
|
||||
|
||||
|
||||
@app.post("/jsonl", status_code=201)
|
||||
async def jsonl() -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "created"}
|
||||
|
||||
|
||||
@app.post("/raw", response_class=StreamingResponse, status_code=201)
|
||||
async def raw() -> AsyncIterable[str]:
|
||||
yield "accepted"
|
||||
|
||||
|
||||
@app.post(
|
||||
"/sse-dependency",
|
||||
response_class=EventSourceResponse,
|
||||
responses={202: SSE_RESPONSE},
|
||||
)
|
||||
async def sse_dependency(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "accepted"}
|
||||
|
||||
|
||||
@app.post("/jsonl-dependency", responses={202: JSONL_RESPONSE})
|
||||
async def jsonl_dependency(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "accepted"}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/raw-dependency",
|
||||
response_class=StreamingResponse,
|
||||
responses={202: {"description": "Accepted"}},
|
||||
)
|
||||
async def raw_dependency(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[str]:
|
||||
yield "accepted"
|
||||
|
||||
|
||||
@app.post(
|
||||
"/sse-dependency-override",
|
||||
response_class=EventSourceResponse,
|
||||
status_code=201,
|
||||
responses={202: SSE_RESPONSE},
|
||||
)
|
||||
async def sse_dependency_override(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "overridden"}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/jsonl-dependency-override",
|
||||
status_code=201,
|
||||
responses={202: JSONL_RESPONSE},
|
||||
)
|
||||
async def jsonl_dependency_override(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[dict[str, str]]:
|
||||
yield {"message": "overridden"}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/raw-dependency-override",
|
||||
response_class=StreamingResponse,
|
||||
status_code=201,
|
||||
responses={202: {"description": "Accepted"}},
|
||||
)
|
||||
async def raw_dependency_override(
|
||||
accepted: None = Depends(set_accepted),
|
||||
) -> AsyncIterable[str]:
|
||||
yield "overridden"
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_status_code",
|
||||
[
|
||||
("/sse", 201),
|
||||
("/jsonl", 201),
|
||||
("/raw", 201),
|
||||
("/sse-dependency", 202),
|
||||
("/jsonl-dependency", 202),
|
||||
("/raw-dependency", 202),
|
||||
("/sse-dependency-override", 202),
|
||||
("/jsonl-dependency-override", 202),
|
||||
("/raw-dependency-override", 202),
|
||||
],
|
||||
)
|
||||
def test_status_code(path: str, expected_status_code: int) -> None:
|
||||
response = client.post(path)
|
||||
assert response.status_code == expected_status_code
|
||||
|
||||
|
||||
def test_openapi() -> None:
|
||||
openapi = app.openapi()
|
||||
|
||||
assert openapi == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/sse": {
|
||||
"post": {
|
||||
"summary": "Sse",
|
||||
"operationId": "sse_sse_post",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Streamitem Sse Sse Post",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/jsonl": {
|
||||
"post": {
|
||||
"summary": "Jsonl",
|
||||
"operationId": "jsonl_jsonl_post",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "Streamitem Jsonl Jsonl Post",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/raw": {
|
||||
"post": {
|
||||
"summary": "Raw",
|
||||
"operationId": "raw_raw_post",
|
||||
"responses": {"201": {"description": "Successful Response"}},
|
||||
}
|
||||
},
|
||||
"/sse-dependency": {
|
||||
"post": {
|
||||
"summary": "Sse Dependency",
|
||||
"operationId": "sse_dependency_sse_dependency_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Streamitem Sse Dependency Sse Dependency Post",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"202": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "SSE stream item",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/jsonl-dependency": {
|
||||
"post": {
|
||||
"summary": "Jsonl Dependency",
|
||||
"operationId": "jsonl_dependency_jsonl_dependency_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "Streamitem Jsonl Dependency Jsonl Dependency Post",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"202": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "JSONL stream item",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/raw-dependency": {
|
||||
"post": {
|
||||
"summary": "Raw Dependency",
|
||||
"operationId": "raw_dependency_raw_dependency_post",
|
||||
"responses": {
|
||||
"200": {"description": "Successful Response"},
|
||||
"202": {"description": "Accepted"},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/sse-dependency-override": {
|
||||
"post": {
|
||||
"summary": "Sse Dependency Override",
|
||||
"operationId": "sse_dependency_override_sse_dependency_override_post",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Streamitem Sse Dependency Override Sse Dependency Override Post",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"202": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "SSE stream item",
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/jsonl-dependency-override": {
|
||||
"post": {
|
||||
"summary": "Jsonl Dependency Override",
|
||||
"operationId": "jsonl_dependency_override_jsonl_dependency_override_post",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "Streamitem Jsonl Dependency Override Jsonl Dependency Override Post",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"202": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "JSONL stream item",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/raw-dependency-override": {
|
||||
"post": {
|
||||
"summary": "Raw Dependency Override",
|
||||
"operationId": "raw_dependency_override_raw_dependency_override_post",
|
||||
"responses": {
|
||||
"201": {"description": "Successful Response"},
|
||||
"202": {"description": "Accepted"},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1005,9 +1005,9 @@ requires-dist = [
|
||||
{ name = "email-validator", marker = "extra == 'all'", specifier = ">=2.0.0" },
|
||||
{ name = "email-validator", marker = "extra == 'standard'", specifier = ">=2.0.0" },
|
||||
{ name = "email-validator", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=2.0.0" },
|
||||
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.8" },
|
||||
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.8" },
|
||||
{ name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.8" },
|
||||
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.32" },
|
||||
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.32" },
|
||||
{ name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.32" },
|
||||
{ name = "fastar", marker = "extra == 'standard'", specifier = ">=0.9.0" },
|
||||
{ name = "httpx", marker = "extra == 'all'", specifier = ">=0.23.0,<1.0.0" },
|
||||
{ name = "httpx", marker = "extra == 'standard'", specifier = ">=0.23.0,<1.0.0" },
|
||||
@@ -1144,7 +1144,7 @@ translations = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cli"
|
||||
version = "0.0.20"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "rich-toolkit" },
|
||||
@@ -1152,9 +1152,9 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
Reference in new issue
Block a user