mirror of
https://github.com/fastapi/fastapi.git
synced 2026-09-09 12:00:05 -04:00
Compare commits
No files matched your search
@@ -201,6 +201,78 @@ jobs:
|
||||
mode: memory
|
||||
run: uv run --no-sync pytest tests/memory_benchmarks --codspeed
|
||||
|
||||
regression-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: pr
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Find changed tests
|
||||
if: github.event_name == 'pull_request'
|
||||
id: changed-tests
|
||||
working-directory: pr
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git diff --name-only --diff-filter=AM -z "$BASE_SHA" "$HEAD_SHA" -- tests \
|
||||
| while IFS= read -r -d '' file; do
|
||||
case "$(basename "$file")" in
|
||||
test_*.py) printf '%s\0' "$file" ;;
|
||||
esac
|
||||
done > "$RUNNER_TEMP/changed-tests"
|
||||
if [ -s "$RUNNER_TEMP/changed-tests" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --binary "$BASE_SHA" "$HEAD_SHA" -- tests \
|
||||
> "$RUNNER_TEMP/tests.patch"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No added or modified test files; regression proof is not applicable."
|
||||
fi
|
||||
- name: Check out the base revision
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version-file: "base/.python-version"
|
||||
- name: Setup uv
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||
version: "0.11.18"
|
||||
enable-cache: true
|
||||
- name: Run the changed tests against the base code
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
working-directory: base
|
||||
run: |
|
||||
git apply "$RUNNER_TEMP/tests.patch"
|
||||
uv sync --locked --no-dev --group tests --extra all
|
||||
set +e
|
||||
xargs -0 uv run --no-sync pytest -- < "$RUNNER_TEMP/changed-tests"
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "::warning::The changed tests already pass on the base revision. Check whether the fix is still needed."
|
||||
echo "### Regression proof: base already passes :warning:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The changed tests pass without the pull request's code changes." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "The changed tests fail on the base revision as expected (pytest exit code $status)."
|
||||
echo "### Regression proof: base fails as expected :white_check_mark:" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
coverage-combine:
|
||||
needs:
|
||||
- test
|
||||
@@ -253,6 +325,7 @@ jobs:
|
||||
- test
|
||||
- coverage-combine
|
||||
- benchmark
|
||||
- regression-test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -119,6 +119,10 @@ Running `fastapi dev` initiates development mode.
|
||||
|
||||
By default, **auto-reload** is enabled, automatically reloading the server when you make changes to your code. This is resource-intensive and could be less stable than when it's disabled. You should only use it for development. It also listens on the IP address `127.0.0.1`, which is the IP for your machine to communicate with itself alone (`localhost`).
|
||||
|
||||
Before importing your app, `fastapi dev` sets the `FASTAPI_ENV` environment variable to `development`. If `FASTAPI_ENV` is already set, its existing value is preserved. This lets app startup code choose development-friendly behavior while allowing you to provide an app-specific environment such as `staging`.
|
||||
|
||||
The conventional `FASTAPI_ENV` values are `development` and `production`. `fastapi run` currently leaves `FASTAPI_ENV` unchanged, so set it explicitly if your app needs to detect production mode.
|
||||
|
||||
## `fastapi run` { #fastapi-run }
|
||||
|
||||
Executing `fastapi run` starts FastAPI in production mode.
|
||||
|
||||
@@ -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,69 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 0.141.1 (2026-07-29)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix support for background tasks and headers from dependencies in `app.frontend()`. PR [#16105](https://github.com/fastapi/fastapi/pull/16105) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Document `FASTAPI_ENV` in FastAPI CLI guide. PR [#16104](https://github.com/fastapi/fastapi/pull/16104) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 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
|
||||
|
||||
* 🐛 Fix handling sequences with nested Annotated types. PR [#14874](https://github.com/fastapi/fastapi/pull/14874) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
### Internal
|
||||
|
||||
* 🐛 Accept any base test failure as regression. PR [#16092](https://github.com/fastapi/fastapi/pull/16092) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 🐛 Preserve pytest exit code in regression check. PR [#16091](https://github.com/fastapi/fastapi/pull/16091) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ✅ Test PR regressions against base code. PR [#16090](https://github.com/fastapi/fastapi/pull/16090) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -132,6 +136,8 @@ Frontend responses run inside the normal **FastAPI** application, so HTTP middle
|
||||
|
||||
Dependencies from the app, from an `APIRouter`, and from `include_router()` also apply to frontend responses. This can be useful for protecting a frontend with cookie authentication or similar.
|
||||
|
||||
Dependencies can also modify response headers and add background tasks, as with normal *path operations*.
|
||||
|
||||
## Static Build Output Only { #static-build-output-only }
|
||||
|
||||
`app.frontend()` serves files already generated by your frontend build.
|
||||
|
||||
@@ -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.8"
|
||||
__version__ = "0.141.1"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ def _annotation_is_sequence(annotation: type[Any] | None) -> bool:
|
||||
|
||||
def field_annotation_is_sequence(annotation: type[Any] | None) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_sequence(get_args(annotation)[0])
|
||||
|
||||
if origin is Union or origin is UnionType:
|
||||
for arg in get_args(annotation):
|
||||
if field_annotation_is_sequence(arg):
|
||||
@@ -108,6 +112,10 @@ def field_annotation_is_scalar(annotation: Any) -> bool:
|
||||
|
||||
def field_annotation_is_scalar_sequence(annotation: type[Any] | None) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_scalar_sequence(get_args(annotation)[0])
|
||||
|
||||
if origin is Union or origin is UnionType:
|
||||
at_least_one_scalar_sequence = False
|
||||
for arg in get_args(annotation):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+99
-56
@@ -9,6 +9,7 @@ import os
|
||||
import stat
|
||||
import threading
|
||||
import types
|
||||
import warnings
|
||||
from collections.abc import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
@@ -55,6 +56,7 @@ from fastapi.dependencies.models import (
|
||||
_is_gen_callable,
|
||||
)
|
||||
from fastapi.dependencies.utils import (
|
||||
SolvedDependency,
|
||||
_get_body_field,
|
||||
_get_flat_body_params,
|
||||
_should_embed_body_fields,
|
||||
@@ -630,10 +632,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 +671,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 +995,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 +1030,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 +1078,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 +1878,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):
|
||||
@@ -1911,6 +1937,12 @@ class _FrontendStaticFiles(StaticFiles):
|
||||
assert isinstance(path, str)
|
||||
return os.path.normpath(os.path.join(*path.split("/")))
|
||||
|
||||
async def get_response_for_scope(self, scope: Scope) -> Response:
|
||||
if not self.config_checked:
|
||||
await self.check_config()
|
||||
self.config_checked = True
|
||||
return await self.get_response(self.get_path(scope), scope)
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
if scope["method"] not in ("GET", "HEAD"):
|
||||
if await self._lookup_static_resource(path) is not None:
|
||||
@@ -2019,7 +2051,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(
|
||||
@@ -2061,7 +2093,8 @@ class _FrontendRoute(BaseRoute):
|
||||
return None
|
||||
|
||||
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self.app(scope, receive, send)
|
||||
response = await self.app.get_response_for_scope(scope)
|
||||
await response(scope, receive, send)
|
||||
|
||||
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||
raise NoMatchFound(name, path_params)
|
||||
@@ -2093,7 +2126,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(
|
||||
@@ -2164,8 +2197,12 @@ class _FrontendRouteGroup(BaseRoute):
|
||||
dependant=dependant,
|
||||
dependency_overrides_provider=dependency_overrides_provider,
|
||||
embed_body_fields=embed_body_fields,
|
||||
):
|
||||
await route.handle(scope, receive, send)
|
||||
) as solved_result:
|
||||
response = await route.app.get_response_for_scope(scope)
|
||||
if response.background is None:
|
||||
response.background = solved_result.background_tasks
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await route.handle(scope, receive, send)
|
||||
|
||||
@@ -2185,7 +2222,7 @@ class _FrontendRouteGroup(BaseRoute):
|
||||
dependant: Dependant,
|
||||
dependency_overrides_provider: Any | None,
|
||||
embed_body_fields: bool,
|
||||
) -> AsyncIterator[None]:
|
||||
) -> AsyncIterator[SolvedDependency]:
|
||||
request = Request(scope, receive, send)
|
||||
previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING)
|
||||
previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING)
|
||||
@@ -2203,7 +2240,7 @@ class _FrontendRouteGroup(BaseRoute):
|
||||
)
|
||||
if solved_result.errors:
|
||||
raise RequestValidationError(solved_result.errors)
|
||||
yield
|
||||
yield solved_result
|
||||
finally:
|
||||
if previous_inner_astack is _SCOPE_MISSING:
|
||||
scope.pop("fastapi_inner_astack", None)
|
||||
@@ -2618,13 +2655,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 +2698,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
|
||||
|
||||
+71
-2
@@ -7,7 +7,15 @@ from typing import Literal
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
)
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
@@ -491,6 +499,30 @@ def test_app_middleware_still_runs_for_frontend_dependencies(tmp_path: Path):
|
||||
assert calls == ["middleware-before", "dependency", "middleware-after"]
|
||||
|
||||
|
||||
def test_frontend_dependency_response_headers_and_background_tasks(tmp_path: Path):
|
||||
calls: list[str] = []
|
||||
|
||||
def frontend_dependency(
|
||||
response: Response, background_tasks: BackgroundTasks
|
||||
) -> None:
|
||||
response.headers["X-Frontend-Dependency"] = "applied"
|
||||
response.set_cookie("frontend", "dependency")
|
||||
background_tasks.add_task(calls.append, "background")
|
||||
|
||||
dist = tmp_path / "dist"
|
||||
write_file(dist / "index.html", "app")
|
||||
app = FastAPI(dependencies=[Depends(frontend_dependency)])
|
||||
app.frontend("/", directory=dist)
|
||||
|
||||
response = TestClient(app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "app"
|
||||
assert response.headers["X-Frontend-Dependency"] == "applied"
|
||||
assert response.cookies["frontend"] == "dependency"
|
||||
assert calls == ["background"]
|
||||
|
||||
|
||||
def test_frontend_dependency_validation_errors_return_422(tmp_path: Path):
|
||||
def require_token(token: str) -> None:
|
||||
pass # pragma: no cover
|
||||
@@ -1176,13 +1208,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from typing import Annotated
|
||||
|
||||
from dirty_equals import IsList
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import Field
|
||||
|
||||
MaxSizedSet = Annotated[set[str], Field(max_length=3)]
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root(foo: Annotated[MaxSizedSet | None, Query()] = None):
|
||||
return {"foo": foo}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_none():
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": None}
|
||||
|
||||
|
||||
def test_endpoint_valid():
|
||||
response = client.get("/", params={"foo": ["a", "b"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": IsList("a", "b", check_order=False)}
|
||||
|
||||
|
||||
def test_endpoint_too_long():
|
||||
response = client.get("/", params={"foo": ["a", "b", "c", "d"]})
|
||||
assert response.status_code == 422
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"type": "too_long",
|
||||
"loc": ["query", "foo"],
|
||||
"msg": "Set should have at most 3 items after validation, not more",
|
||||
"input": IsList("a", "b", "c", "d", check_order=False),
|
||||
"ctx": {
|
||||
"actual_length": None,
|
||||
"field_type": "Set",
|
||||
"max_length": 3,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_openapi():
|
||||
assert app.openapi() == snapshot(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {"title": "Context", "type": "object"},
|
||||
"input": {"title": "Input"},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}],
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error Type", "type": "string"},
|
||||
},
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
"info": {
|
||||
"title": "FastAPI",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"operationId": "read_root__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "foo",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 3,
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"title": "Foo",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Read Root",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user