mirror of
https://github.com/fastapi/fastapi.git
synced 2026-09-08 11:35:12 -04:00
Compare commits
No files matched your search
@@ -40,11 +40,7 @@ jobs:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
- uses: tiangolo/latest-changes@c9b73efbc8992ef1a401e4235ea307a8ca8a724b # 0.6.1
|
||||
- uses: tiangolo/latest-changes@8a940392f4c65274539453a5d5a76d9550203ac1 # 0.7.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
latest_changes_file: docs/en/docs/release-notes.md
|
||||
latest_changes_header: '## Latest Changes'
|
||||
end_regex: '^## '
|
||||
debug_logs: true
|
||||
label_header_prefix: '### '
|
||||
@@ -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:
|
||||
|
||||
@@ -7,6 +7,83 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 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
|
||||
|
||||
* 🐛 Fix stream item type lost when using `include_router()`. PR [#15077](https://github.com/fastapi/fastapi/pull/15077) by [@alex-raw](https://github.com/alex-raw).
|
||||
|
||||
## 0.140.7 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⚡️ Avoid flattening dependencies for OpenAPI. PR [#16076](https://github.com/fastapi/fastapi/pull/16076) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Internal
|
||||
|
||||
* ⬆️ Upgrade latest-changes to 0.7.1. PR [#16077](https://github.com/fastapi/fastapi/pull/16077) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Add OpenAPI dependency benchmarks. PR [#16075](https://github.com/fastapi/fastapi/pull/16075) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.6 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⚡️ Avoid flattening dependencies for request parameters, mainly for OpenAPI. PR [#16073](https://github.com/fastapi/fastapi/pull/16073) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.5 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⚡️ Avoid flattening dependencies for body fields. PR [#16071](https://github.com/fastapi/fastapi/pull/16071) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.4 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⚡️ Skip unused dependency repeat bookkeeping. PR [#16069](https://github.com/fastapi/fastapi/pull/16069) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.3 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ⚡️ Avoid repeated dependency flattening in OpenAPI. PR [#16067](https://github.com/fastapi/fastapi/pull/16067) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.2 (2026-07-27)
|
||||
|
||||
### Refactors
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.140.2"
|
||||
__version__ = "0.140.12"
|
||||
|
||||
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):
|
||||
|
||||
@@ -134,10 +134,6 @@ def _get_security_scheme(*, dependant: Dependant) -> SecurityBase:
|
||||
return unwrapped
|
||||
|
||||
|
||||
def _get_security_dependencies(*, dependant: Dependant) -> list[Dependant]:
|
||||
return [dep for dep in dependant.dependencies if _is_security_scheme(dependant=dep)]
|
||||
|
||||
|
||||
@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)
|
||||
def _is_gen_callable_cached(call_identity: _CallIdentity) -> bool:
|
||||
call = call_identity.call
|
||||
|
||||
@@ -144,74 +144,14 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
|
||||
)
|
||||
|
||||
|
||||
def get_flat_dependant(
|
||||
dependant: Dependant,
|
||||
*,
|
||||
skip_repeats: bool = False,
|
||||
visited: list[DependencyCacheKey] | None = None,
|
||||
parent_oauth_scopes: list[str] | None = None,
|
||||
_uses_scopes_cache: _UsesScopesCache | None = None,
|
||||
) -> Dependant:
|
||||
if visited is None:
|
||||
visited = []
|
||||
if _uses_scopes_cache is None:
|
||||
_uses_scopes_cache = {}
|
||||
visited.append(
|
||||
_get_cache_key(
|
||||
dependant=dependant,
|
||||
uses_scopes_cache=_uses_scopes_cache,
|
||||
)
|
||||
)
|
||||
use_parent_oauth_scopes = (parent_oauth_scopes or []) + (
|
||||
_get_oauth_scopes(dependant=dependant)
|
||||
)
|
||||
|
||||
flat_dependant = Dependant(
|
||||
path_params=dependant.path_params.copy(),
|
||||
query_params=dependant.query_params.copy(),
|
||||
header_params=dependant.header_params.copy(),
|
||||
cookie_params=dependant.cookie_params.copy(),
|
||||
body_params=dependant.body_params.copy(),
|
||||
name=dependant.name,
|
||||
call=dependant.call,
|
||||
request_param_name=dependant.request_param_name,
|
||||
websocket_param_name=dependant.websocket_param_name,
|
||||
http_connection_param_name=dependant.http_connection_param_name,
|
||||
response_param_name=dependant.response_param_name,
|
||||
background_tasks_param_name=dependant.background_tasks_param_name,
|
||||
security_scopes_param_name=dependant.security_scopes_param_name,
|
||||
own_oauth_scopes=dependant.own_oauth_scopes,
|
||||
parent_oauth_scopes=use_parent_oauth_scopes,
|
||||
use_cache=dependant.use_cache,
|
||||
path=dependant.path,
|
||||
scope=dependant.scope,
|
||||
)
|
||||
for sub_dependant in dependant.dependencies:
|
||||
if (
|
||||
skip_repeats
|
||||
and _get_cache_key(
|
||||
dependant=sub_dependant,
|
||||
uses_scopes_cache=_uses_scopes_cache,
|
||||
)
|
||||
in visited
|
||||
):
|
||||
continue
|
||||
flat_sub = get_flat_dependant(
|
||||
sub_dependant,
|
||||
skip_repeats=skip_repeats,
|
||||
visited=visited,
|
||||
parent_oauth_scopes=_get_oauth_scopes(dependant=flat_dependant),
|
||||
_uses_scopes_cache=_uses_scopes_cache,
|
||||
)
|
||||
flat_dependant.dependencies.append(flat_sub)
|
||||
flat_dependant.path_params.extend(flat_sub.path_params)
|
||||
flat_dependant.query_params.extend(flat_sub.query_params)
|
||||
flat_dependant.header_params.extend(flat_sub.header_params)
|
||||
flat_dependant.cookie_params.extend(flat_sub.cookie_params)
|
||||
flat_dependant.body_params.extend(flat_sub.body_params)
|
||||
flat_dependant.dependencies.extend(flat_sub.dependencies)
|
||||
|
||||
return flat_dependant
|
||||
def _get_flat_body_params(dependant: Dependant) -> list[ModelField]:
|
||||
body_params: list[ModelField] = []
|
||||
dependants = [dependant]
|
||||
while dependants:
|
||||
current_dependant = dependants.pop()
|
||||
body_params.extend(current_dependant.body_params)
|
||||
dependants.extend(reversed(current_dependant.dependencies))
|
||||
return body_params
|
||||
|
||||
|
||||
def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
|
||||
@@ -227,11 +167,31 @@ def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
|
||||
|
||||
|
||||
def get_flat_params(dependant: Dependant) -> list[ModelField]:
|
||||
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
|
||||
path_params = _get_flat_fields_from_params(flat_dependant.path_params)
|
||||
query_params = _get_flat_fields_from_params(flat_dependant.query_params)
|
||||
header_params = _get_flat_fields_from_params(flat_dependant.header_params)
|
||||
cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params)
|
||||
path_params: list[ModelField] = []
|
||||
query_params: list[ModelField] = []
|
||||
header_params: list[ModelField] = []
|
||||
cookie_params: list[ModelField] = []
|
||||
visited: list[DependencyCacheKey] = []
|
||||
uses_scopes_cache: _UsesScopesCache = {}
|
||||
dependants = [dependant]
|
||||
while dependants:
|
||||
current_dependant = dependants.pop()
|
||||
cache_key = _get_cache_key(
|
||||
dependant=current_dependant,
|
||||
uses_scopes_cache=uses_scopes_cache,
|
||||
)
|
||||
if cache_key in visited:
|
||||
continue
|
||||
visited.append(cache_key)
|
||||
path_params.extend(current_dependant.path_params)
|
||||
query_params.extend(current_dependant.query_params)
|
||||
header_params.extend(current_dependant.header_params)
|
||||
cookie_params.extend(current_dependant.cookie_params)
|
||||
dependants.extend(reversed(current_dependant.dependencies))
|
||||
path_params = _get_flat_fields_from_params(path_params)
|
||||
query_params = _get_flat_fields_from_params(query_params)
|
||||
header_params = _get_flat_fields_from_params(header_params)
|
||||
cookie_params = _get_flat_fields_from_params(cookie_params)
|
||||
return path_params + query_params + header_params + cookie_params
|
||||
|
||||
|
||||
@@ -1038,8 +998,8 @@ async def request_body_to_args(
|
||||
return values, errors
|
||||
|
||||
|
||||
def get_body_field(
|
||||
*, flat_dependant: Dependant, name: str, embed_body_fields: bool
|
||||
def _get_body_field(
|
||||
*, body_params: list[ModelField], name: str, embed_body_fields: bool
|
||||
) -> ModelField | None:
|
||||
"""
|
||||
Get a ModelField representing the request body for a path operation, combining
|
||||
@@ -1051,34 +1011,30 @@ def get_body_field(
|
||||
This is **not** used to validate/parse the request body, that's done with each
|
||||
individual body parameter.
|
||||
"""
|
||||
if not flat_dependant.body_params:
|
||||
if not body_params:
|
||||
return None
|
||||
first_param = flat_dependant.body_params[0]
|
||||
first_param = body_params[0]
|
||||
if not embed_body_fields:
|
||||
return first_param
|
||||
model_name = "Body_" + name
|
||||
BodyModel = create_body_model(
|
||||
fields=flat_dependant.body_params, model_name=model_name
|
||||
)
|
||||
required = any(
|
||||
True for f in flat_dependant.body_params if f.field_info.is_required()
|
||||
)
|
||||
BodyModel = create_body_model(fields=body_params, model_name=model_name)
|
||||
required = any(True for f in body_params if f.field_info.is_required())
|
||||
BodyFieldInfo_kwargs: dict[str, Any] = {
|
||||
"annotation": BodyModel,
|
||||
"alias": "body",
|
||||
}
|
||||
if not required:
|
||||
BodyFieldInfo_kwargs["default"] = None
|
||||
if any(isinstance(f.field_info, params.File) for f in flat_dependant.body_params):
|
||||
if any(isinstance(f.field_info, params.File) for f in body_params):
|
||||
BodyFieldInfo: type[params.Body] = params.File
|
||||
elif any(isinstance(f.field_info, params.Form) for f in flat_dependant.body_params):
|
||||
elif any(isinstance(f.field_info, params.Form) for f in body_params):
|
||||
BodyFieldInfo = params.Form
|
||||
else:
|
||||
BodyFieldInfo = params.Body
|
||||
|
||||
body_param_media_types = [
|
||||
f.field_info.media_type
|
||||
for f in flat_dependant.body_params
|
||||
for f in body_params
|
||||
if isinstance(f.field_info, params.Body)
|
||||
]
|
||||
if len(set(body_param_media_types)) == 1:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+76
-20
@@ -3,6 +3,7 @@ import http.client
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from fastapi import routing
|
||||
@@ -17,13 +18,14 @@ from fastapi._compat import (
|
||||
from fastapi.datastructures import DefaultPlaceholder, _Unset
|
||||
from fastapi.dependencies.models import (
|
||||
Dependant,
|
||||
_get_cache_key,
|
||||
_get_oauth_scopes,
|
||||
_get_security_dependencies,
|
||||
_get_security_scheme,
|
||||
_is_security_scheme,
|
||||
_UsesScopesCache,
|
||||
)
|
||||
from fastapi.dependencies.utils import (
|
||||
_get_flat_fields_from_params,
|
||||
get_flat_dependant,
|
||||
get_flat_params,
|
||||
get_validation_alias,
|
||||
)
|
||||
@@ -34,7 +36,7 @@ from fastapi.openapi.models import OpenAPI
|
||||
from fastapi.params import Body, ParamTypes
|
||||
from fastapi.responses import Response
|
||||
from fastapi.sse import _SSE_EVENT_SCHEMA
|
||||
from fastapi.types import ModelNameMap
|
||||
from fastapi.types import DependencyCacheKey, ModelNameMap
|
||||
from fastapi.utils import (
|
||||
deep_dict_update,
|
||||
generate_operation_id_for_path,
|
||||
@@ -83,13 +85,57 @@ status_code_ranges: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def get_openapi_security_definitions(
|
||||
flat_dependant: Dependant,
|
||||
@dataclass
|
||||
class _OpenAPIDependencyData:
|
||||
path_params: list[ModelField] = field(default_factory=list)
|
||||
query_params: list[ModelField] = field(default_factory=list)
|
||||
header_params: list[ModelField] = field(default_factory=list)
|
||||
cookie_params: list[ModelField] = field(default_factory=list)
|
||||
security_dependencies: list[tuple[Dependant, list[str]]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
def _get_openapi_dependency_data(dependant: Dependant) -> _OpenAPIDependencyData:
|
||||
dependency_data = _OpenAPIDependencyData()
|
||||
visited: list[DependencyCacheKey] = []
|
||||
uses_scopes_cache: _UsesScopesCache = {}
|
||||
dependants: list[tuple[Dependant, list[str], bool]] = [(dependant, [], True)]
|
||||
while dependants:
|
||||
current_dependant, parent_oauth_scopes, is_root = dependants.pop()
|
||||
cache_key = _get_cache_key(
|
||||
dependant=current_dependant,
|
||||
uses_scopes_cache=uses_scopes_cache,
|
||||
)
|
||||
if cache_key in visited:
|
||||
continue
|
||||
visited.append(cache_key)
|
||||
dependency_data.path_params.extend(current_dependant.path_params)
|
||||
dependency_data.query_params.extend(current_dependant.query_params)
|
||||
dependency_data.header_params.extend(current_dependant.header_params)
|
||||
dependency_data.cookie_params.extend(current_dependant.cookie_params)
|
||||
oauth_scopes = parent_oauth_scopes.copy()
|
||||
for scope in _get_oauth_scopes(dependant=current_dependant):
|
||||
if scope not in oauth_scopes:
|
||||
oauth_scopes.append(scope)
|
||||
if not is_root and _is_security_scheme(dependant=current_dependant):
|
||||
dependency_data.security_dependencies.append(
|
||||
(current_dependant, oauth_scopes)
|
||||
)
|
||||
dependants.extend(
|
||||
(sub_dependant, oauth_scopes, False)
|
||||
for sub_dependant in reversed(current_dependant.dependencies)
|
||||
)
|
||||
return dependency_data
|
||||
|
||||
|
||||
def _get_openapi_security_definitions(
|
||||
security_dependencies: list[tuple[Dependant, list[str]]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
security_definitions = {}
|
||||
# Use a dict to merge scopes for same security scheme
|
||||
operation_security_dict: dict[str, list[str]] = {}
|
||||
for security_dependency in _get_security_dependencies(dependant=flat_dependant):
|
||||
for security_dependency, oauth_scopes in security_dependencies:
|
||||
security_scheme = _get_security_scheme(dependant=security_dependency)
|
||||
security_definition = jsonable_encoder(
|
||||
security_scheme.model,
|
||||
@@ -101,7 +147,7 @@ def get_openapi_security_definitions(
|
||||
# Merge scopes for the same security scheme
|
||||
if security_name not in operation_security_dict:
|
||||
operation_security_dict[security_name] = []
|
||||
for scope in _get_oauth_scopes(dependant=security_dependency):
|
||||
for scope in oauth_scopes:
|
||||
if scope not in operation_security_dict[security_name]:
|
||||
operation_security_dict[security_name].append(scope)
|
||||
operation_security = [
|
||||
@@ -112,7 +158,7 @@ def get_openapi_security_definitions(
|
||||
|
||||
def _get_openapi_operation_parameters(
|
||||
*,
|
||||
dependant: Dependant,
|
||||
dependency_data: _OpenAPIDependencyData,
|
||||
model_name_map: ModelNameMap,
|
||||
field_mapping: dict[
|
||||
tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any]
|
||||
@@ -120,11 +166,10 @@ def _get_openapi_operation_parameters(
|
||||
separate_input_output_schemas: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
parameters = []
|
||||
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
|
||||
path_params = _get_flat_fields_from_params(flat_dependant.path_params)
|
||||
query_params = _get_flat_fields_from_params(flat_dependant.query_params)
|
||||
header_params = _get_flat_fields_from_params(flat_dependant.header_params)
|
||||
cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params)
|
||||
path_params = _get_flat_fields_from_params(dependency_data.path_params)
|
||||
query_params = _get_flat_fields_from_params(dependency_data.query_params)
|
||||
header_params = _get_flat_fields_from_params(dependency_data.header_params)
|
||||
cookie_params = _get_flat_fields_from_params(dependency_data.cookie_params)
|
||||
parameter_groups = [
|
||||
(ParamTypes.path, path_params),
|
||||
(ParamTypes.query, query_params),
|
||||
@@ -132,8 +177,8 @@ def _get_openapi_operation_parameters(
|
||||
(ParamTypes.cookie, cookie_params),
|
||||
]
|
||||
default_convert_underscores = True
|
||||
if len(flat_dependant.header_params) == 1:
|
||||
first_field = flat_dependant.header_params[0]
|
||||
if len(dependency_data.header_params) == 1:
|
||||
first_field = dependency_data.header_params[0]
|
||||
if lenient_issubclass(first_field.field_info.annotation, BaseModel):
|
||||
default_convert_underscores = getattr(
|
||||
first_field.field_info, "convert_underscores", True
|
||||
@@ -284,21 +329,33 @@ def get_openapi_path(
|
||||
assert current_response_class, "A response class is needed to generate OpenAPI"
|
||||
route_response_media_type: str | None = current_response_class.media_type
|
||||
if route.include_in_schema:
|
||||
dependency_data = _get_openapi_dependency_data(route.dependant)
|
||||
all_route_params = [
|
||||
field
|
||||
for fields in (
|
||||
dependency_data.path_params,
|
||||
dependency_data.query_params,
|
||||
dependency_data.header_params,
|
||||
dependency_data.cookie_params,
|
||||
)
|
||||
for field in _get_flat_fields_from_params(fields)
|
||||
]
|
||||
for method in route.methods:
|
||||
operation = get_openapi_operation_metadata(
|
||||
route=route, method=method, operation_ids=operation_ids
|
||||
)
|
||||
parameters: list[dict[str, Any]] = []
|
||||
flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True)
|
||||
security_definitions, operation_security = get_openapi_security_definitions(
|
||||
flat_dependant=flat_dependant
|
||||
security_definitions, operation_security = (
|
||||
_get_openapi_security_definitions(
|
||||
security_dependencies=dependency_data.security_dependencies
|
||||
)
|
||||
)
|
||||
if operation_security:
|
||||
operation.setdefault("security", []).extend(operation_security)
|
||||
if security_definitions:
|
||||
security_schemes.update(security_definitions)
|
||||
operation_parameters = _get_openapi_operation_parameters(
|
||||
dependant=route.dependant,
|
||||
dependency_data=dependency_data,
|
||||
model_name_map=model_name_map,
|
||||
field_mapping=field_mapping,
|
||||
separate_input_output_schemas=separate_input_output_schemas,
|
||||
@@ -458,7 +515,6 @@ def get_openapi_path(
|
||||
deep_dict_update(openapi_response, process_response)
|
||||
openapi_response["description"] = description
|
||||
http422 = "422"
|
||||
all_route_params = get_flat_params(route.dependant)
|
||||
if (all_route_params or route.body_field) and not any(
|
||||
status in operation["responses"]
|
||||
for status in [http422, "4XX", "default"]
|
||||
|
||||
+55
-53
@@ -55,10 +55,10 @@ from fastapi.dependencies.models import (
|
||||
_is_gen_callable,
|
||||
)
|
||||
from fastapi.dependencies.utils import (
|
||||
_get_body_field,
|
||||
_get_flat_body_params,
|
||||
_should_embed_body_fields,
|
||||
get_body_field,
|
||||
get_dependant,
|
||||
get_flat_dependant,
|
||||
get_parameterless_sub_dependant,
|
||||
get_stream_item_type,
|
||||
get_typed_return_annotation,
|
||||
@@ -849,16 +849,16 @@ def _build_dependant_with_parameterless_dependencies(
|
||||
path: str,
|
||||
call: Callable[..., Any],
|
||||
dependencies: Sequence[params.Depends],
|
||||
) -> tuple[Dependant, Dependant, bool]:
|
||||
) -> tuple[Dependant, list[ModelField], bool]:
|
||||
dependant = get_dependant(path=path, call=call, scope="function")
|
||||
for depends in dependencies[::-1]:
|
||||
dependant.dependencies.insert(
|
||||
0,
|
||||
get_parameterless_sub_dependant(depends=depends, path=path),
|
||||
)
|
||||
flat_dependant = get_flat_dependant(dependant)
|
||||
embed_body_fields = _should_embed_body_fields(flat_dependant.body_params)
|
||||
return dependant, flat_dependant, embed_body_fields
|
||||
body_params = _get_flat_body_params(dependant)
|
||||
embed_body_fields = _should_embed_body_fields(body_params)
|
||||
return dependant, body_params, embed_body_fields
|
||||
|
||||
|
||||
class _RouteWithPath(Protocol):
|
||||
@@ -982,32 +982,11 @@ def _populate_api_route_state(
|
||||
generate_unique_id
|
||||
),
|
||||
strict_content_type: bool | DefaultPlaceholder = Default(True),
|
||||
stream_item_type: Any | None = None,
|
||||
) -> None:
|
||||
route.path = path
|
||||
route.endpoint = endpoint
|
||||
route.stream_item_type = None
|
||||
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.stream_item_type = stream_item_type
|
||||
route.summary = summary
|
||||
route.response_description = response_description
|
||||
route.deprecated = deprecated
|
||||
@@ -1043,27 +1022,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,
|
||||
@@ -1090,15 +1048,15 @@ def _populate_api_route_state(
|
||||
assert callable(endpoint), "An endpoint must be a callable"
|
||||
(
|
||||
route.dependant,
|
||||
flat_dependant,
|
||||
body_params,
|
||||
route._embed_body_fields,
|
||||
) = _build_dependant_with_parameterless_dependencies(
|
||||
path=route.path_format,
|
||||
call=route.endpoint,
|
||||
dependencies=route.dependencies,
|
||||
)
|
||||
route.body_field = get_body_field(
|
||||
flat_dependant=flat_dependant,
|
||||
route.body_field = _get_body_field(
|
||||
body_params=body_params,
|
||||
name=route.unique_id,
|
||||
embed_body_fields=route._embed_body_fields,
|
||||
)
|
||||
@@ -1112,6 +1070,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):
|
||||
@@ -1464,6 +1465,7 @@ class _EffectiveRouteContext:
|
||||
include_context.included_router.strict_content_type,
|
||||
include_context.strict_content_type,
|
||||
),
|
||||
stream_item_type=route.stream_item_type,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
+8
-2
@@ -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[
|
||||
@@ -206,14 +212,14 @@ def format_sse_event(
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.benchmarks.utils import (
|
||||
ROUTE_COUNT,
|
||||
ROUTE_PATH_PREFIX,
|
||||
create_openapi_app,
|
||||
generate_openapi,
|
||||
)
|
||||
|
||||
if "--codspeed" not in sys.argv:
|
||||
pytest.skip(
|
||||
"Benchmark tests are skipped by default; run with --codspeed.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_openapi_dependency_graph(benchmark) -> None:
|
||||
app = create_openapi_app()
|
||||
schema = benchmark(generate_openapi, app)
|
||||
dynamic_paths = [
|
||||
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
|
||||
]
|
||||
assert len(dynamic_paths) == ROUTE_COUNT
|
||||
assert all(
|
||||
any(
|
||||
parameter["in"] == "query" and parameter["name"] == "query_value"
|
||||
for parameter in schema["paths"][path]["get"]["parameters"]
|
||||
)
|
||||
for path in dynamic_paths
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
LAST_DEPENDENCY_INDEX = 100
|
||||
ROUTE_COUNT = 20
|
||||
ROUTE_PATH_PREFIX = "/openapi-route-"
|
||||
|
||||
|
||||
def create_openapi_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
dependencies: dict[int, Callable[..., Any]] = {}
|
||||
|
||||
def create_dependency(index: int) -> Callable[..., Any]:
|
||||
if index == LAST_DEPENDENCY_INDEX:
|
||||
|
||||
def dependency(query_value: int = index) -> str:
|
||||
return str(query_value)
|
||||
|
||||
dependency.__name__ = f"dependency_{index}"
|
||||
return dependency
|
||||
|
||||
next_dependency = dependencies[index + 1]
|
||||
|
||||
async def dependency(
|
||||
sub_dependency: Annotated[str, Depends(next_dependency)],
|
||||
query_value: int = index,
|
||||
) -> str:
|
||||
return f"{query_value} -> {sub_dependency}"
|
||||
|
||||
dependency.__name__ = f"dependency_{index}"
|
||||
return dependency
|
||||
|
||||
for index in reversed(range(LAST_DEPENDENCY_INDEX + 1)):
|
||||
dependencies[index] = create_dependency(index)
|
||||
|
||||
async def endpoint(
|
||||
value: Annotated[str, Depends(dependencies[0])],
|
||||
) -> dict[str, str]:
|
||||
return {"value": value}
|
||||
|
||||
for index in range(ROUTE_COUNT):
|
||||
app.add_api_route(f"{ROUTE_PATH_PREFIX}{index}", endpoint, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def generate_openapi(app: FastAPI) -> dict[str, Any]:
|
||||
app.openapi_schema = None
|
||||
return app.openapi()
|
||||
@@ -0,0 +1,33 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.benchmarks.utils import (
|
||||
ROUTE_COUNT,
|
||||
ROUTE_PATH_PREFIX,
|
||||
create_openapi_app,
|
||||
generate_openapi,
|
||||
)
|
||||
|
||||
if "--codspeed" not in sys.argv:
|
||||
pytest.skip(
|
||||
"Benchmark tests are skipped by default; run with --codspeed.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_openapi_dependency_graph(benchmark) -> None:
|
||||
app = create_openapi_app()
|
||||
schema = benchmark(generate_openapi, app)
|
||||
dynamic_paths = [
|
||||
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
|
||||
]
|
||||
assert len(dynamic_paths) == ROUTE_COUNT
|
||||
assert all(
|
||||
any(
|
||||
parameter["in"] == "query" and parameter["name"] == "query_value"
|
||||
for parameter in schema["paths"][path]["get"]["parameters"]
|
||||
)
|
||||
for path in dynamic_paths
|
||||
)
|
||||
@@ -6,7 +6,6 @@ from fastapi.dependencies.models import (
|
||||
_get_cache_key,
|
||||
_get_computed_scope,
|
||||
_get_oauth_scopes,
|
||||
_get_security_dependencies,
|
||||
_get_security_scheme,
|
||||
_is_async_gen_callable,
|
||||
_is_async_gen_callable_cached,
|
||||
@@ -146,7 +145,6 @@ def test_derived_values_are_not_stored_on_dependant() -> None:
|
||||
assert _get_oauth_scopes(dependant=dependant) == []
|
||||
assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
|
||||
assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
|
||||
assert _get_security_dependencies(dependant=dependant) == []
|
||||
assert _get_computed_scope(dependant=dependant) is None
|
||||
assert _get_cache_key(dependant=dependant) == (async_dependency, (), "")
|
||||
|
||||
@@ -160,7 +158,6 @@ def test_security_scheme_helpers() -> None:
|
||||
|
||||
assert _is_security_scheme(dependant=security_dependant)
|
||||
assert _get_security_scheme(dependant=security_dependant) is security_scheme
|
||||
assert _get_security_dependencies(dependant=dependant) == [security_dependant]
|
||||
assert _uses_scopes(dependant=dependant)
|
||||
|
||||
|
||||
|
||||
@@ -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"}]
|
||||
+165
-2
@@ -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
|
||||
|
||||
@@ -64,7 +64,8 @@ async def sse_items_event():
|
||||
|
||||
@app.get("/items/stream-mixed", response_class=EventSourceResponse)
|
||||
async def sse_items_mixed() -> AsyncIterable[Item]:
|
||||
yield items[0]
|
||||
for item in items:
|
||||
yield item
|
||||
yield ServerSentEvent(data="custom-event", event="special")
|
||||
yield items[1]
|
||||
|
||||
@@ -96,6 +97,12 @@ async def stream_events():
|
||||
yield {"msg": "world"}
|
||||
|
||||
|
||||
@router.get("/events-typed", response_class=EventSourceResponse)
|
||||
async def stream_events_typed() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
|
||||
@@ -274,6 +281,45 @@ def test_sse_on_router_included_in_app(client: TestClient):
|
||||
assert len(data_lines) == 2
|
||||
|
||||
|
||||
def test_sse_router_typed_stream(client: TestClient):
|
||||
response = client.get("/api/events-typed")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_sse_router_typed_openapi_schema(client: TestClient):
|
||||
"""Typed SSE endpoint on a router should preserve itemSchema with contentSchema."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
paths = response.json()["paths"]
|
||||
sse_response = paths["/api/events-typed"]["get"]["responses"]["200"]
|
||||
assert sse_response == {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {"$ref": "#/components/schemas/Item"},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Keepalive ping tests
|
||||
|
||||
|
||||
@@ -325,3 +371,120 @@ def test_no_keepalive_when_fast(client: TestClient):
|
||||
assert response.status_code == 200
|
||||
# KEEPALIVE_COMMENT is ": ping\n\n".
|
||||
assert ": ping\n" not in response.text
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
sse_schema_response = {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {"$ref": "#/components/schemas/Item"},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# default_response_class on app
|
||||
|
||||
default_app_app = FastAPI(default_response_class=EventSourceResponse)
|
||||
default_app_router = APIRouter()
|
||||
|
||||
|
||||
@default_app_router.get("/stream")
|
||||
async def default_app_stream() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
default_app_app.include_router(default_app_router, prefix="/api")
|
||||
|
||||
|
||||
def test_default_response_class_on_app_stream():
|
||||
with TestClient(default_app_app) as client:
|
||||
response = client.get("/api/stream")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_default_response_class_on_app_openapi_schema():
|
||||
assert (
|
||||
default_app_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
|
||||
== sse_schema_response
|
||||
)
|
||||
|
||||
|
||||
# default_response_class on parent router
|
||||
|
||||
default_parent_app = FastAPI()
|
||||
parent_router = APIRouter(default_response_class=EventSourceResponse)
|
||||
child_router = APIRouter()
|
||||
|
||||
|
||||
@child_router.get("/stream")
|
||||
async def default_parent_stream() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
parent_router.include_router(child_router)
|
||||
default_parent_app.include_router(parent_router, prefix="/api")
|
||||
|
||||
|
||||
def test_default_response_class_on_parent_router_stream():
|
||||
with TestClient(default_parent_app) as client:
|
||||
response = client.get("/api/stream")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_default_response_class_on_parent_router_openapi_schema():
|
||||
assert (
|
||||
default_parent_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
|
||||
== sse_schema_response
|
||||
)
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
from typing import AsyncIterable, Iterable # noqa: UP035 to test coverage
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
optional: str | None = None
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
@@ -23,6 +24,16 @@ def stream_bare_sync() -> Iterable:
|
||||
yield {"name": "bar"}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/events-jsonl", response_model_exclude_none=True)
|
||||
async def stream_events_jsonl() -> AsyncIterable[Item]:
|
||||
yield Item(name="foo")
|
||||
|
||||
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@@ -40,3 +51,24 @@ def test_stream_bare_sync_iterable():
|
||||
assert response.headers["content-type"] == "application/jsonl"
|
||||
lines = [json.loads(line) for line in response.text.strip().splitlines()]
|
||||
assert lines == [{"name": "bar"}]
|
||||
|
||||
|
||||
def test_jsonl_router_typed_stream():
|
||||
response = client.get("/api/events-jsonl")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/jsonl"
|
||||
lines = [json.loads(line) for line in response.text.strip().splitlines()]
|
||||
assert lines == [{"name": "foo"}]
|
||||
|
||||
|
||||
def test_jsonl_router_typed_openapi_schema():
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
paths = response.json()["paths"]
|
||||
jsonl_response = paths["/api/events-jsonl"]["get"]["responses"]["200"]
|
||||
assert jsonl_response == {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {"itemSchema": {"$ref": "#/components/schemas/Item"}}
|
||||
},
|
||||
}
|
||||
@@ -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