Repository navigation
fix(deps): update dependency fastapi to >=0.162.0,<0.163.0 - #360
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are reported for the FastAPI dependency update.
No open issues 🎉
What changed in this PR
Updates the API service’s FastAPI dependency to the 0.161 release series, keeping the dependency constraint and Poetry lockfile aligned.
Changes:
- Changes the FastAPI constraint to
>=0.161.0,<0.162.0. - Locks FastAPI to
0.161.1and refreshes package hashes and lockfile metadata.
| File | Description |
|---|---|
pyproject.toml |
Updates the allowed FastAPI version range. |
poetry.lock |
Records FastAPI 0.161.1 and updates artifact and content hashes. |
Review effort: Balanced
renovate
Bot
force-pushed
the
renovate/fastapi-0-x
branch
from
October 30, 2026 01:41
c045e77 to
dc09ce8
Compare
renovate
Bot
force-pushed
the
renovate/fastapi-0-x
branch
from
October 30, 2026 21:49
dc09ce8 to
04cf69a
Compare
|
[puLL-Merge] - fastapi/fastapi@0.156.4..0.157.0 Diffdiff --git .github/workflows/label-approved.yml .github/workflows/label-approved.yml
deleted file mode 100644
index 1d5d3d6b75c58..0000000000000
--- .github/workflows/label-approved.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: Label Approved
-
-on:
- schedule:
- - cron: "0 12 * * *"
- workflow_dispatch:
-
-permissions: {}
-
-jobs:
- label-approved:
- if: github.repository_owner == 'fastapi'
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- timeout-minutes: 7
- steps:
- - name: Dump GitHub context
- env:
- GITHUB_CONTEXT: ${{ toJson(github) }}
- run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version-file: ".python-version"
- - name: Setup uv
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- with:
- version: "latest-known"
- enable-cache: true
- cache-dependency-glob: |
- pyproject.toml
- uv.lock
- - name: Install GitHub Actions dependencies
- run: uv sync --locked --no-dev --group github-actions
- - name: Label Approved
- run: uv run ./scripts/label_approved.py
- env:
- TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CONFIG: >
- {
- "approved-1":
- {
- "number": 1,
- "await_label": "awaiting-review"
- }
- }
diff --git .github/workflows/notify-translations.yml .github/workflows/notify-translations.yml
deleted file mode 100644
index fc29384bd5db9..0000000000000
--- .github/workflows/notify-translations.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: Notify Translations
-
-on:
- pull_request_target: # zizmor: ignore[dangerous-triggers]
- types:
- - labeled
- - closed
- branches:
- - master
- workflow_dispatch:
- inputs:
- number:
- description: PR number
- required: true
- debug_enabled:
- description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)'
- required: false
- default: 'false'
-
-permissions: {}
-
-jobs:
- job:
- runs-on: ubuntu-latest
- permissions:
- discussions: write
- timeout-minutes: 5
- steps:
- - name: Dump GitHub context
- env:
- GITHUB_CONTEXT: ${{ toJson(github) }}
- run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- ref: ${{ github.event.repository.default_branch }}
- persist-credentials: false
- - name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version-file: ".python-version"
- - name: Setup uv
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- with:
- version: "latest-known"
- enable-cache: true
- cache-dependency-glob: |
- pyproject.toml
- uv.lock
- - name: Install Dependencies
- run: uv sync --locked --no-dev --group github-actions
- # Allow debugging with tmate
- - name: Setup tmate session
- uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24
- if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
- with:
- limit-access-to-actor: true
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Notify Translations
- run: uv run ./scripts/notify_translations.py
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- NUMBER: ${{ github.event.inputs.number || null }}
- DEBUG: ${{ github.event.inputs.debug_enabled || 'false' }}
diff --git a/docs_src/query_param_models/tutorial003_an_py310.py b/docs_src/query_param_models/tutorial003_an_py310.py
new file mode 100644
index 0000000000000..fe0ddca3a0ab9
--- /dev/null
+++ docs_src/query_param_models/tutorial003_an_py310.py
@@ -0,0 +1,24 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Query
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class Pagination(BaseModel):
+ limit: int = Field(10, ge=1)
+ offset: int = Field(0, ge=0)
+
+
+class Filters(BaseModel):
+ tags: list[str] = []
+
+
+@app.get("/items/")
+def read_items(
+ pagination: Annotated[Pagination, Query()],
+ filters: Annotated[Filters, Query()],
+ search: str | None = None,
+):
+ return {"pagination": pagination, "filters": filters, "search": search}
diff --git a/docs_src/query_param_models/tutorial003_py310.py b/docs_src/query_param_models/tutorial003_py310.py
new file mode 100644
index 0000000000000..8d3d431b737ec
--- /dev/null
+++ docs_src/query_param_models/tutorial003_py310.py
@@ -0,0 +1,22 @@
+from fastapi import FastAPI, Query
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class Pagination(BaseModel):
+ limit: int = Field(10, ge=1)
+ offset: int = Field(0, ge=0)
+
+
+class Filters(BaseModel):
+ tags: list[str] = []
+
+
+@app.get("/items/")
+def read_items(
+ pagination: Pagination = Query(),
+ filters: Filters = Query(),
+ search: str | None = None,
+):
+ return {"pagination": pagination, "filters": filters, "search": search}
diff --git fastapi/__init__.py fastapi/__init__.py
index 72e9ab9f9ebfc..7efc66c442d4d 100644
--- fastapi/__init__.py
+++ fastapi/__init__.py
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
-__version__ = "0.156.4"
+__version__ = "0.157.0"
from starlette import status as status
diff --git fastapi/dependencies/models.py fastapi/dependencies/models.py
index 5c7cbc82ba12e..047f49d7ac288 100644
--- fastapi/dependencies/models.py
+++ fastapi/dependencies/models.py
@@ -49,6 +49,7 @@ class Dependant:
use_cache: bool = True
path: str | None = None
scope: Literal["function", "request"] | None = None
+ _request_param_names: dict[str, set[str]] | None = None
_UsesScopesCache = dict[int, tuple[Dependant, bool]]
diff --git fastapi/dependencies/utils.py fastapi/dependencies/utils.py
index 1de8a24bf8095..5543231cfa8f2 100644
--- fastapi/dependencies/utils.py
+++ fastapi/dependencies/utils.py
@@ -155,16 +155,58 @@ def _get_flat_body_params(dependant: Dependant) -> list[ModelField]:
return body_params
-def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
- if not fields:
- return fields
- first_field = fields[0]
- if len(fields) == 1 and lenient_issubclass(
- first_field.field_info.annotation, BaseModel
- ):
- fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
- return fields_to_extract
- return fields
+def _get_flat_fields_from_params(fields: Sequence[ModelField]) -> list[ModelField]:
+ flat_fields: list[ModelField] = []
+ for field in fields:
+ if lenient_issubclass(
+ field.field_info.annotation, BaseModel
+ ) and not _is_json_field(field):
+ flat_fields.extend(get_cached_model_fields(field.field_info.annotation))
+ else:
+ flat_fields.append(field)
+ return flat_fields
+
+
+def _get_request_param_names(
+ dependant: Dependant, dependency_overrides_provider: Any | None = None
+) -> dict[str, set[str]]:
+ """Collect accepted names across the effective dependency graph, by location."""
+ names: dict[str, set[str]] = {"query": set(), "header": set(), "cookie": set()}
+ overrides = (
+ getattr(dependency_overrides_provider, "dependency_overrides", None) or {}
+ )
+ dependants = [dependant]
+ while dependants:
+ current = dependants.pop()
+ for location, fields in (
+ ("query", current.query_params),
+ ("header", current.header_params),
+ ("cookie", current.cookie_params),
+ ):
+ for field in fields:
+ for param in _get_flat_fields_from_params([field]):
+ convert_underscores = location == "header" and getattr(
+ param.field_info,
+ "convert_underscores",
+ getattr(field.field_info, "convert_underscores", True),
+ )
+ for _, name in _get_param_aliases(
+ param, convert_underscores=convert_underscores
+ ):
+ names[location].add(
+ name.lower() if location == "header" else name
+ )
+ for sub_dependant in current.dependencies:
+ if sub_dependant.call in overrides:
+ sub_dependant = get_dependant(
+ path=cast(str, sub_dependant.path),
+ call=overrides[sub_dependant.call],
+ name=sub_dependant.name,
+ parent_oauth_scopes=_get_oauth_scopes(dependant=sub_dependant),
+ scope=sub_dependant.scope,
+ )
+ dependants.append(sub_dependant)
+ return names
def get_flat_params(dependant: Dependant) -> list[ModelField]:
@@ -293,12 +335,11 @@ def get_dependant(
endpoint_signature = get_typed_signature(call)
signature_params = endpoint_signature.parameters
for param_name, param in signature_params.items():
- is_path_param = param_name in path_param_names
param_details = analyze_param(
param_name=param_name,
annotation=param.annotation,
value=param.default,
- is_path_param=is_path_param,
+ path_param_names=path_param_names,
)
if param_details.depends is not None:
assert param_details.depends.dependency
@@ -384,8 +425,9 @@ def analyze_param(
param_name: str,
annotation: Any,
value: Any,
- is_path_param: bool,
+ path_param_names: set[str],
) -> ParamDetails:
+ is_path_param = param_name in path_param_names
field_info = None
depends = None
type_annotation: Any = Any
@@ -537,6 +579,19 @@ def analyze_param(
field_info.annotation = _add_form_union_validators(
field_info.annotation
)
+ validation_alias = field_info.validation_alias
+ if (
+ isinstance(field_info, params.Path)
+ and isinstance(validation_alias, AliasChoices)
+ and all(isinstance(choice, str) for choice in validation_alias.choices)
+ ):
+ for choice in cast(list[str], validation_alias.choices):
+ if choice in path_param_names:
+ # Resolve the route name for both validation and OpenAPI without
+ # changing metadata shared with other routes.
+ field_info = copy(field_info)
+ field_info.validation_alias = choice
+ break
if not field_info.alias and getattr(field_info, "convert_underscores", None):
alias = param_name.replace("_", "-")
else:
@@ -613,6 +668,7 @@ async def solve_dependencies(
async_exit_stack: AsyncExitStack,
embed_body_fields: bool,
_uses_scopes_cache: _UsesScopesCache | None = None,
+ _request_param_names: dict[str, set[str]] | None = None,
) -> SolvedDependency:
request_astack = request.scope.get("fastapi_inner_astack")
assert isinstance(request_astack, AsyncExitStack), (
@@ -632,6 +688,15 @@ async def solve_dependencies(
dependency_cache = {}
if _uses_scopes_cache is None:
_uses_scopes_cache = {}
+ if _request_param_names is None:
+ _request_param_names = dependant._request_param_names
+ if _request_param_names is None or (
+ dependency_overrides_provider
+ and dependency_overrides_provider.dependency_overrides
+ ):
+ _request_param_names = _get_request_param_names(
+ dependant, dependency_overrides_provider
+ )
for sub_dependant in dependant.dependencies:
sub_dependant.call = cast(Callable[..., Any], sub_dependant.call)
call = sub_dependant.call
@@ -664,6 +729,7 @@ async def solve_dependencies(
async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields,
_uses_scopes_cache=_uses_scopes_cache,
+ _request_param_names=_request_param_names,
)
background_tasks = solved_result.background_tasks
if solved_result.errors:
@@ -698,13 +764,19 @@ async def solve_dependencies(
dependant.path_params, request.path_params
)
query_values, query_errors = request_params_to_args(
- dependant.query_params, request.query_params
+ dependant.query_params,
+ request.query_params,
+ known_param_names=_request_param_names["query"],
)
header_values, header_errors = request_params_to_args(
- dependant.header_params, request.headers
+ dependant.header_params,
+ request.headers,
+ known_param_names=_request_param_names["header"],
)
cookie_values, cookie_errors = request_params_to_args(
- dependant.cookie_params, request.cookies
+ dependant.cookie_params,
+ request.cookies,
+ known_param_names=_request_param_names["cookie"],
)
values.update(path_values)
values.update(query_values)
@@ -799,41 +871,31 @@ def _get_multidict_value(
return value
-def request_params_to_args(
- fields: Sequence[ModelField],
- received_params: Mapping[str, Any] | QueryParams | Headers,
-) -> tuple[dict[str, Any], list[Any]]:
- values: dict[str, Any] = {}
- errors: list[dict[str, Any]] = []
-
- if not fields:
- return values, errors
-
- first_field = fields[0]
- fields_to_extract = fields
- single_not_embedded_field = False
- default_convert_underscores = True
- if len(fields) == 1 and lenient_issubclass(
- first_field.field_info.annotation, BaseModel
- ):
- fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
- single_not_embedded_field = True
- # If headers are in a Pydantic model, the way to disable convert_underscores
- # would be with Header(convert_underscores=False) at the Pydantic model level
- default_convert_underscores = getattr(
- first_field.field_info, "convert_underscores", True
- )
-
+def _request_param_model_to_args(
+ model_field: ModelField,
+ received_params: Mapping[str, Any],
+ known_param_names: set[str],
+) -> tuple[Any, list[Any]]:
+ model = cast(type[BaseModel], model_field.field_info.annotation)
+ fields_to_extract = get_cached_model_fields(model)
+ default_convert_underscores = getattr(
+ model_field.field_info, "convert_underscores", True
+ )
params_to_process: dict[str, Any] = {}
header_aliases: dict[str, str] = {}
processed_keys = set()
+ own_names: set[str] = set()
for field in fields_to_extract:
convert_underscores = isinstance(received_params, Headers) and getattr(
field.field_info, "convert_underscores", default_convert_underscores
)
aliases = _get_param_aliases(field, convert_underscores=convert_underscores)
+ own_names.update(
+ name.lower() if isinstance(received_params, Headers) else name
+ for _, name in aliases
+ )
alias, request_alias = next(
(pair for pair in aliases if pair[1] in received_params), aliases[0]
)
@@ -853,8 +915,12 @@ def request_params_to_args(
else:
processed_keys.add(request_alias)
+ # Parameters declared elsewhere are not extras of this model. Keep unknown
+ # inputs and unused aliases of its own fields for Pydantic to validate.
for key in received_params.keys():
- if key not in processed_keys:
+ if key not in processed_keys and (
+ key not in known_param_names or key in own_names
+ ):
if isinstance(received_params, (ImmutableMultiDict, Headers)):
value = received_params.getlist(key)
if isinstance(value, list) and (len(value) == 1):
@@ -864,43 +930,58 @@ def request_params_to_args(
else:
params_to_process[key] = received_params.get(key)
- if single_not_embedded_field:
- field_info = first_field.field_info
- assert isinstance(field_info, params.Param), (
- "Params must be subclasses of Param"
- )
- loc: tuple[str, ...] = (field_info.in_.value,)
- v_, errors_ = _validate_value_with_model_field(
- field=first_field, value=params_to_process, values=values, loc=loc
- )
- if header_aliases and cast(
- type[BaseModel], first_field.field_info.annotation
- ).model_config.get("loc_by_alias", True):
- # Report the header names without changing model aliases or schema.
- for error in errors_:
- error_loc = error["loc"]
- if len(error_loc) > 1 and error_loc[1] in header_aliases:
- error["loc"] = (
- error_loc[0],
- header_aliases[error_loc[1]],
- *error_loc[2:],
- )
- return {first_field.name: v_}, errors_
+ field_info = model_field.field_info
+ assert isinstance(field_info, params.Param), "Params must be subclasses of Param"
+ loc: tuple[str, ...] = (field_info.in_.value,)
+ v_, errors_ = _validate_value_with_model_field(
+ field=model_field, value=params_to_process, values={}, loc=loc
+ )
+ if header_aliases and model.model_config.get("loc_by_alias", True):
+ # Report the header names without changing model aliases or schema.
+ for error in errors_:
+ error_loc = error["loc"]
+ if len(error_loc) > 1 and error_loc[1] in header_aliases:
+ error["loc"] = (
+ error_loc[0],
+ header_aliases[error_loc[1]],
+ *error_loc[2:],
+ )
+ return v_, errors_
+
+def request_params_to_args(
+ fields: Sequence[ModelField],
+ received_params: Mapping[str, Any] | QueryParams | Headers,
+ *,
+ known_param_names: set[str] | None = None,
+) -> tuple[dict[str, Any], list[Any]]:
+ values: dict[str, Any] = {}
+ errors: list[Any] = []
+ if not fields:
+ return values, errors
+ if known_param_names is None:
+ known_param_names = set()
for field in fields:
- aliases = _get_param_aliases(field)
- _, alias = next(
- (pair for pair in aliases if pair[1] in received_params), aliases[0]
- )
- value = _get_multidict_value(field, received_params, alias=alias)
- field_info = field.field_info
- assert isinstance(field_info, params.Param), (
- "Params must be subclasses of Param"
- )
- loc = (field_info.in_.value, alias)
- v_, errors_ = _validate_value_with_model_field(
- field=field, value=value, values=values, loc=loc
- )
+ if lenient_issubclass(
+ field.field_info.annotation, BaseModel
+ ) and not _is_json_field(field):
+ v_, errors_ = _request_param_model_to_args(
+ field, received_params, known_param_names
+ )
+ else:
+ aliases = _get_param_aliases(field)
+ _, alias = next(
+ (pair for pair in aliases if pair[1] in received_params), aliases[0]
+ )
+ value = _get_multidict_value(field, received_params, alias=alias)
+ field_info = field.field_info
+ assert isinstance(field_info, params.Param), (
+ "Params must be subclasses of Param"
+ )
+ loc = (field_info.in_.value, alias)
+ v_, errors_ = _validate_value_with_model_field(
+ field=field, value=value, values=values, loc=loc
+ )
if errors_:
errors.extend(errors_)
else:
@@ -1032,10 +1113,6 @@ def _extract_form_items(
value: Any = False
else:
value = _get_multidict_value(field, received_body, alias=alias)
- if field.config is None:
- # Individual parameters are later looked up by their declared alias.
- alias = get_validation_alias(field)
- field_aliases.add(alias)
if value is not Undefined:
yield alias, value, field
# Leave later aliases unchanged for other fields or extra validation.
@@ -1049,17 +1126,25 @@ def _extract_form_items(
yield key, param_values, None
+async def _read_file_bytes(file: UploadFile) -> bytes:
+ # Automatic conversion must not consume a file shared with other parameters.
+ position = file.file.tell()
+ try:
+ return await file.read()
+ finally:
+ await file.seek(position)
+
+
async def _extract_form_body(
body_fields: list[ModelField],
received_body: FormData,
-) -> dict[str, Any]:
- values: dict[str, Any] = {}
+) -> AsyncIterator[tuple[str, Any, ModelField | None]]:
for alias, value, field in _extract_form_items(body_fields, received_body):
if field is not None and isinstance(field.field_info, params.File):
if is_bytes_or_nonable_bytes_annotation(
field.field_info.annotation
) and isinstance(value, UploadFile):
- value = await value.read()
+ value = await _read_file_bytes(value)
elif is_bytes_sequence_annotation(
field.field_info.annotation
) and value_is_sequence(value):
@@ -1067,12 +1152,11 @@ async def _extract_form_body(
results: list[bytes | str] = []
for sub_value in value:
if isinstance(sub_value, UploadFile):
- results.append(await sub_value.read())
+ results.append(await _read_file_bytes(sub_value))
else:
results.append(sub_value)
value = serialize_sequence_value(field=field, value=results)
- values[alias] = value
- return values
+ yield alias, value, field
async def request_body_to_args(
@@ -1086,6 +1170,7 @@ async def request_body_to_args(
single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields
first_field = body_fields[0]
body_to_process = received_body
+ form_values: dict[str, tuple[str, Any]] = {}
fields_to_extract: list[ModelField] = body_fields
@@ -1103,7 +1188,15 @@ async def request_body_to_args(
# Preserve all form values for validators around the union.
# Each branch applies its own model field extraction afterward.
fields_to_extract = []
- body_to_process = await _extract_form_body(fields_to_extract, received_body)
+ body_to_process = {}
+ async for alias, value, field in _extract_form_body(
+ fields_to_extract, received_body
+ ):
+ if single_not_embedded_field:
+ body_to_process[alias] = value
+ elif field is not None:
+ # Keep each parameter's selected alias and scalar/list shape.
+ form_values[field.name] = (alias, value)
if single_not_embedded_field:
loc: tuple[str, ...] = ("body",)
@@ -1112,14 +1205,15 @@ async def request_body_to_args(
)
return {first_field.name: v_}, errors_
for field in body_fields:
- if isinstance(received_body, FormData):
- # Form values have already been extracted using their canonical aliases.
- aliases = [get_validation_alias(field)]
- else:
- aliases = [alias for alias, _ in _get_param_aliases(field)]
+ aliases = [alias for alias, _ in _get_param_aliases(field)]
loc = ("body", aliases[0])
- value: Any = Undefined
- if body_to_process is not Undefined and not isinstance(body_to_process, bytes):
+ value = Undefined
+ if isinstance(received_body, FormData):
+ alias, value = form_values.get(field.name, (aliases[0], Undefined))
+ loc = ("body", alias)
+ elif body_to_process is not Undefined and not isinstance(
+ body_to_process, bytes
+ ):
try:
for alias in aliases:
value = body_to_process.get(alias, Undefined)
@@ -1204,15 +1298,18 @@ def _get_param_aliases(
if convert_underscores and alias == field.name:
request_alias = alias.replace("_", "-")
validation_alias = field.field_info.validation_alias
- if (
- isinstance(field.field_info, params.Path)
- or not isinstance(validation_alias, AliasChoices)
- or any(isinstance(alias, AliasPath) for alias in validation_alias.choices)
+ if isinstance(field.field_info, params.Path) or isinstance(
+ validation_alias, AliasPath
):
return [(alias, request_alias)]
- choices = cast(list[str], validation_alias.choices)
- # Explicit choices are literal names, including underscores in headers.
- aliases = [(choice, choice) for choice in choices]
+ aliases = [(alias, request_alias)]
+ if isinstance(validation_alias, AliasChoices):
+ if any(isinstance(choice, AliasPath) for choice in validation_alias.choices):
+ return aliases
+ # Explicit choices are literal names, including underscores in headers.
+ aliases = [
+ (choice, choice) for choice in cast(list[str], validation_alias.choices)
+ ]
config = field.config or {}
if config.get("validate_by_alias") is False or config.get(
"validate_by_name", config.get("populate_by_name", False)
@@ -1222,6 +1319,6 @@ def _get_param_aliases(
request_name = request_name.replace("_", "-")
if config.get("validate_by_alias") is False:
return [(field.name, request_name)]
- if field.name not in choices:
+ if field.name not in [alias for alias, _ in aliases]:
aliases.append((field.name, request_name))
return aliases
diff --git fastapi/openapi/utils.py fastapi/openapi/utils.py
index aec55ae05f064..31d1ee5fcbb6e 100644
--- fastapi/openapi/utils.py
+++ fastapi/openapi/utils.py
@@ -42,7 +42,6 @@
generate_operation_id_for_path,
is_body_allowed_for_status_code,
)
-from pydantic import BaseModel
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute
@@ -166,60 +165,51 @@ def _get_openapi_operation_parameters(
separate_input_output_schemas: bool = True,
) -> list[dict[str, Any]]:
parameters = []
- 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),
- (ParamTypes.header, header_params),
- (ParamTypes.cookie, cookie_params),
+ (ParamTypes.path, dependency_data.path_params),
+ (ParamTypes.query, dependency_data.query_params),
+ (ParamTypes.header, dependency_data.header_params),
+ (ParamTypes.cookie, dependency_data.cookie_params),
]
- default_convert_underscores = True
- 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
- )
for param_type, param_group in parameter_groups:
- for param in param_group:
- field_info = param.field_info
- # field_info = cast(Param, field_info)
- if not getattr(field_info, "include_in_schema", True):
+ for parent in param_group:
+ if not getattr(parent.field_info, "include_in_schema", True):
continue
- convert_underscores = param_type == ParamTypes.header and getattr(
- field_info, "convert_underscores", default_convert_underscores
- )
- alias, name = _get_param_aliases(
- param, convert_underscores=convert_underscores
- )[0]
- param_schema = get_schema_from_model_field(
- field=param,
- model_name_map=model_name_map,
- field_mapping=field_mapping,
- separate_input_output_schemas=separate_input_output_schemas,
- field_alias=alias,
- )
+ for param in _get_flat_fields_from_params([parent]):
+ field_info = param.field_info
+ convert_underscores = param_type == ParamTypes.header and getattr(
+ field_info,
+ "convert_underscores",
+ getattr(parent.field_info, "convert_underscores", True),
+ )
+ alias, name = _get_param_aliases(
+ param, convert_underscores=convert_underscores
+ )[0]
+ param_schema = get_schema_from_model_field(
+ field=param,
+ model_name_map=model_name_map,
+ field_mapping=field_mapping,
+ separate_input_output_schemas=separate_input_output_schemas,
+ field_alias=alias,
+ )
- parameter = {
- "name": name,
- "in": param_type.value,
- "required": param.field_info.is_required(),
- "schema": param_schema,
- }
- if field_info.description:
- parameter["description"] = field_info.description
- openapi_examples = getattr(field_info, "openapi_examples", None)
- example = getattr(field_info, "example", None)
- if openapi_examples:
- parameter["examples"] = jsonable_encoder(openapi_examples)
- elif example is not _Unset:
- parameter["example"] = jsonable_encoder(example)
- if getattr(field_info, "deprecated", None):
- parameter["deprecated"] = True
- parameters.append(parameter)
+ parameter = {
+ "name": name,
+ "in": param_type.value,
+ "required": param.field_info.is_required(),
+ "schema": param_schema,
+ }
+ if field_info.description:
+ parameter["description"] = field_info.description
+ openapi_examples = getattr(field_info, "openapi_examples", None)
+ example = getattr(field_info, "example", None)
+ if openapi_examples:
+ parameter["examples"] = jsonable_encoder(openapi_examples)
+ elif example is not _Unset:
+ parameter["example"] = jsonable_encoder(example)
+ if getattr(field_info, "deprecated", None):
+ parameter["deprecated"] = True
+ parameters.append(parameter)
return parameters
diff --git fastapi/routing.py fastapi/routing.py
index a3f8043c9e326..9a14ebf6512ae 100644
--- fastapi/routing.py
+++ fastapi/routing.py
@@ -60,6 +60,7 @@
SolvedDependency,
_get_body_field,
_get_flat_body_params,
+ _get_request_param_names,
_should_embed_body_fields,
get_dependant,
get_parameterless_sub_dependant,
@@ -917,6 +918,7 @@ def _build_dependant_with_parameterless_dependencies(
0,
get_parameterless_sub_dependant(depends=depends, path=path),
)
+ dependant._request_param_names = _get_request_param_names(dependant)
body_params = _get_flat_body_params(dependant)
embed_body_fields = _should_embed_body_fields(body_params)
return dependant, body_params, embed_body_fields
diff --git scripts/label_approved.py scripts/label_approved.py
deleted file mode 100644
index 81de92efbe84d..0000000000000
--- scripts/label_approved.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import logging
-from typing import Literal
-
-from github import Github
-from github.PullRequestReview import PullRequestReview
-from pydantic import BaseModel, SecretStr
-from pydantic_settings import BaseSettings
-
-
-class LabelSettings(BaseModel):
- await_label: str | None = None
- number: int
-
-
-default_config = {"approved-2": LabelSettings(await_label="awaiting-review", number=2)}
-
-
-class Settings(BaseSettings):
- github_repository: str
- token: SecretStr
- debug: bool | None = False
- config: dict[str, LabelSettings] | Literal[""] = default_config
-
-
-settings = Settings()
-if settings.debug:
- logging.basicConfig(level=logging.DEBUG)
-else:
- logging.basicConfig(level=logging.INFO)
-logging.debug(f"Using config: {settings.model_dump_json()}")
-g = Github(settings.token.get_secret_value())
-repo = g.get_repo(settings.github_repository)
-for pr in repo.get_pulls(state="open"):
- logging.info(f"Checking PR: #{pr.number}")
- pr_labels = list(pr.get_labels())
- pr_label_by_name = {label.name: label for label in pr_labels}
- reviews = list(pr.get_reviews())
- review_by_user: dict[str, PullRequestReview] = {}
- for review in reviews:
- if review.user.login in review_by_user:
- stored_review = review_by_user[review.user.login]
- if review.submitted_at >= stored_review.submitted_at:
- review_by_user[review.user.login] = review
- else:
- review_by_user[review.user.login] = review
- approved_reviews = [
- review for review in review_by_user.values() if review.state == "APPROVED"
- ]
- config = settings.config or default_config
- for approved_label, conf in config.items():
- logging.debug(f"Processing config: {conf.model_dump_json()}")
- if conf.await_label is None or (conf.await_label in pr_label_by_name):
- logging.debug(f"Processable PR: {pr.number}")
- if len(approved_reviews) >= conf.number:
- logging.info(f"Adding label to PR: {pr.number}")
- pr.add_to_labels(approved_label)
- if conf.await_label:
- logging.info(f"Removing label from PR: {pr.number}")
- pr.remove_from_labels(conf.await_label)
-logging.info("Finished")
diff --git scripts/notify_translations.py scripts/notify_translations.py
deleted file mode 100644
index 85cf768031c93..0000000000000
--- scripts/notify_translations.py
+++ /dev/null
@@ -1,433 +0,0 @@
-import logging
-import random
-import sys
-import time
-from pathlib import Path
-from typing import Any, cast
-
-import httpx
-from github import Github
-from pydantic import BaseModel, SecretStr
-from pydantic_settings import BaseSettings
-
-awaiting_label = "awaiting-review"
-lang_all_label = "lang-all"
-approved_label = "approved-1"
-
-
-github_graphql_url = "https://api.github.com/graphql"
-questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"
-
-all_discussions_query = """
-query Q($category_id: ID) {
- repository(name: "fastapi", owner: "fastapi") {
- discussions(categoryId: $category_id, first: 100) {
- nodes {
- title
- id
- number
- labels(first: 10) {
- edges {
- node {
- id
- name
- }
- }
- }
- }
- }
- }
-}
-"""
-
-translation_discussion_query = """
-query Q($after: String, $discussion_number: Int!) {
- repository(name: "fastapi", owner: "fastapi") {
- discussion(number: $discussion_number) {
- comments(first: 100, after: $after) {
- edges {
- cursor
- node {
- id
- url
- body
- }
- }
- }
- }
- }
-}
-"""
-
-add_comment_mutation = """
-mutation Q($discussion_id: ID!, $body: String!) {
- addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {
- comment {
- id
- url
- body
- }
- }
-}
-"""
-
-update_comment_mutation = """
-mutation Q($comment_id: ID!, $body: String!) {
- updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {
- comment {
- id
- url
- body
- }
- }
-}
-"""
-
-
-class Comment(BaseModel):
- id: str
- url: str
- body: str
-
-
-class UpdateDiscussionComment(BaseModel):
- comment: Comment
-
-
-class UpdateCommentData(BaseModel):
- updateDiscussionComment: UpdateDiscussionComment
-
-
-class UpdateCommentResponse(BaseModel):
- data: UpdateCommentData
-
-
-class AddDiscussionComment(BaseModel):
- comment: Comment
-
-
-class AddCommentData(BaseModel):
- addDiscussionComment: AddDiscussionComment
-
-
-class AddCommentResponse(BaseModel):
- data: AddCommentData
-
-
-class CommentsEdge(BaseModel):
- node: Comment
- cursor: str
-
-
-class Comments(BaseModel):
- edges: list[CommentsEdge]
-
-
-class CommentsDiscussion(BaseModel):
- comments: Comments
-
-
-class CommentsRepository(BaseModel):
- discussion: CommentsDiscussion
-
-
-class CommentsData(BaseModel):
- repository: CommentsRepository
-
-
-class CommentsResponse(BaseModel):
- data: CommentsData
-
-
-class AllDiscussionsLabelNode(BaseModel):
- id: str
- name: str
-
-
-class AllDiscussionsLabelsEdge(BaseModel):
- node: AllDiscussionsLabelNode
-
-
-class AllDiscussionsDiscussionLabels(BaseModel):
- edges: list[AllDiscussionsLabelsEdge]
-
-
-class AllDiscussionsDiscussionNode(BaseModel):
- title: str
- id: str
- number: int
- labels: AllDiscussionsDiscussionLabels
-
-
-class AllDiscussionsDiscussions(BaseModel):
- nodes: list[AllDiscussionsDiscussionNode]
-
-
-class AllDiscussionsRepository(BaseModel):
- discussions: AllDiscussionsDiscussions
-
-
-class AllDiscussionsData(BaseModel):
- repository: AllDiscussionsRepository
-
-
-class AllDiscussionsResponse(BaseModel):
- data: AllDiscussionsData
-
-
-class Settings(BaseSettings):
- model_config = {"env_ignore_empty": True}
-
- github_repository: str
- github_token: SecretStr
- github_event_path: Path
- github_event_name: str | None = None
- httpx_timeout: int = 30
- debug: bool | None = False
- number: int | None = None
-
-
-class PartialGitHubEventIssue(BaseModel):
- number: int | None = None
-
-
-class PartialGitHubEvent(BaseModel):
- pull_request: PartialGitHubEventIssue | None = None
-
-
-def get_graphql_response(
- *,
- settings: Settings,
- query: str,
- after: str | None = None,
- category_id: str | None = None,
- discussion_number: int | None = None,
- discussion_id: str | None = None,
- comment_id: str | None = None,
- body: str | None = None,
-) -> dict[str, Any]:
- headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"}
- variables = {
- "after": after,
- "category_id": category_id,
- "discussion_number": discussion_number,
- "discussion_id": discussion_id,
- "comment_id": comment_id,
- "body": body,
- }
- response = httpx.post(
- github_graphql_url,
- headers=headers,
- timeout=settings.httpx_timeout,
- json={"query": query, "variables": variables, "operationName": "Q"},
- )
- if response.status_code != 200:
- logging.error(
- f"Response was not 200, after: {after}, category_id: {category_id}"
- )
- logging.error(response.text)
- raise RuntimeError(response.text)
- data = response.json()
- if "errors" in data:
- logging.error(f"Errors in response, after: {after}, category_id: {category_id}")
- logging.error(data["errors"])
- logging.error(response.text)
- raise RuntimeError(response.text)
- return cast(dict[str, Any], data)
-
-
-def get_graphql_translation_discussions(
- *, settings: Settings
-) -> list[AllDiscussionsDiscussionNode]:
- data = get_graphql_response(
- settings=settings,
- query=all_discussions_query,
- category_id=questions_translations_category_id,
- )
- graphql_response = AllDiscussionsResponse.model_validate(data)
- return graphql_response.data.repository.discussions.nodes
-
-
-def get_graphql_translation_discussion_comments_edges(
- *, settings: Settings, discussion_number: int, after: str | None = None
-) -> list[CommentsEdge]:
- data = get_graphql_response(
- settings=settings,
- query=translation_discussion_query,
- discussion_number=discussion_number,
- after=after,
- )
- graphql_response = CommentsResponse.model_validate(data)
- return graphql_response.data.repository.discussion.comments.edges
-
-
-def get_graphql_translation_discussion_comments(
- *, settings: Settings, discussion_number: int
-) -> list[Comment]:
- comment_nodes: list[Comment] = []
- discussion_edges = get_graphql_translation_discussion_comments_edges(
- settings=settings, discussion_number=discussion_number
- )
-
- while discussion_edges:
- for discussion_edge in discussion_edges:
- comment_nodes.append(discussion_edge.node)
- last_edge = discussion_edges[-1]
- discussion_edges = get_graphql_translation_discussion_comments_edges(
- settings=settings,
- discussion_number=discussion_number,
- after=last_edge.cursor,
- )
- return comment_nodes
-
-
-def create_comment(*, settings: Settings, discussion_id: str, body: str) -> Comment:
- data = get_graphql_response(
- settings=settings,
- query=add_comment_mutation,
- discussion_id=discussion_id,
- body=body,
- )
- response = AddCommentResponse.model_validate(data)
- return response.data.addDiscussionComment.comment
-
-
-def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment:
- data = get_graphql_response(
- settings=settings,
- query=update_comment_mutation,
- comment_id=comment_id,
- body=body,
- )
- response = UpdateCommentResponse.model_validate(data)
- return response.data.updateDiscussionComment.comment
-
-
-def main() -> None:
- settings = Settings()
- if settings.debug:
- logging.basicConfig(level=logging.DEBUG)
- else:
- logging.basicConfig(level=logging.INFO)
- logging.debug(f"Using config: {settings.model_dump_json()}")
- g = Github(settings.github_token.get_secret_value())
- repo = g.get_repo(settings.github_repository)
- if not settings.github_event_path.is_file():
- raise RuntimeError(
- f"No github event file available at: {settings.github_event_path}"
- )
- contents = settings.github_event_path.read_text("utf-8")
- github_event = PartialGitHubEvent.model_validate_json(contents)
- logging.info(f"Using GitHub event: {github_event}")
- number = (
- github_event.pull_request and github_event.pull_request.number
- ) or settings.number
- if number is None:
- raise RuntimeError("No PR number available")
- number = cast(int, number)
-
- # Avoid race conditions with multiple labels
- sleep_time = random.random() * 10 # random number between 0 and 10 seconds
- logging.info(
- f"Sleeping for {sleep_time} seconds to avoid "
- "race conditions and multiple comments"
- )
- time.sleep(sleep_time)
-
- # Get PR
- logging.debug(f"Processing PR: #{number}")
- pr = repo.get_pull(number)
- label_strs = {label.name for label in pr.get_labels()}
- langs = []
- for label in label_strs:
- if label.startswith("lang-") and not label == lang_all_label:
- langs.append(label[5:])
- logging.info(f"PR #{pr.number} has labels: {label_strs}")
- if not langs or lang_all_label not in label_strs:
- logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")
- sys.exit(0)
-
- # Generate translation map, lang ID to discussion
- discussions = get_graphql_translation_discussions(settings=settings)
- lang_to_discussion_map: dict[str, AllDiscussionsDiscussionNode] = {}
- for discussion in discussions:
- for edge in discussion.labels.edges:
- label = edge.node.name
- if label.startswith("lang-") and not label == lang_all_label:
- lang = label[5:]
- lang_to_discussion_map[lang] = discussion
- logging.debug(f"Using translations map: {lang_to_discussion_map}")
-
- # Messages to create or check
- new_translation_message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}. 🎉 This requires 2 approvals from native speakers to be merged. 🤓"
- done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"
-
- # Normally only one language, but still
- for lang in langs:
- if lang not in lang_to_discussion_map:
- log_message = f"Could not find discussion for language: {lang}"
- logging.error(log_message)
- raise RuntimeError(log_message)
- discussion = lang_to_discussion_map[lang]
- logging.info(
- f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"
- )
-
- already_notified_comment: Comment | None = None
- already_done_comment: Comment | None = None
-
- logging.info(
- f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"
- )
- comments = get_graphql_translation_discussion_comments(
- settings=settings, discussion_number=discussion.number
- )
- for comment in comments:
- if new_translation_message in comment.body:
- already_notified_comment = comment
- elif done_translation_message in comment.body:
- already_done_comment = comment
- logging.info(
- f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"
- )
-
- if pr.state == "open" and awaiting_label in label_strs:
- logging.info(
- f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"
- )
- if already_notified_comment:
- logging.info(
- f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"
- )
- else:
- logging.info(
- f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"
- )
- comment = create_comment(
- settings=settings,
- discussion_id=discussion.id,
- body=new_translation_message,
- )
- logging.info(f"Notified in comment: {comment.url}")
- elif pr.state == "closed" or approved_label in label_strs:
- logging.info(f"Already approved or closed PR #{pr.number}")
- if already_done_comment:
- logging.info(
- f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"
- )
- elif already_notified_comment:
- updated_comment = update_comment(
- settings=settings,
- comment_id=already_notified_comment.id,
- body=done_translation_message,
- )
- logging.info(f"Marked as done in comment: {updated_comment.url}")
- else:
- logging.info(
- f"There doesn't seem to be anything to be done about PR #{pr.number}"
- )
- logging.info("Finished")
-
-
-if __name__ == "__main__":
- main()
diff --git tests/test_header_cookie_alias_choices.py tests/test_header_cookie_alias_choices.py
index 725b1764ab52e..f2b549271205e 100644
--- tests/test_header_cookie_alias_choices.py
+++ tests/test_header_cookie_alias_choices.py
@@ -359,20 +359,26 @@ def read(data: Annotated[Model, Cookie()]):
"convert_underscores, name, rejected_name",
[(True, "x-values", "x_values"), (False, "x_values", "x-values")],
)
+@pytest.mark.parametrize(
+ "field_info",
+ [
+ Field(alias="X-Second"),
+ Field(validation_alias="X-Second"),
+ Field(validation_alias=AliasChoices("X-First", "X-Second")),
+ ],
+)
def test_header_model_name_fallback(
- config, expected_value, convert_underscores, name, rejected_name
+ config, expected_value, convert_underscores, name, rejected_name, field_info
):
app = FastAPI()
class Model(BaseModel):
model_config = config
- x_values: Annotated[
- list[int], Field(validation_alias=AliasChoices("X-First", "X-Second"))
- ]
+ x_values: Annotated[list[int], field_info]
@app.get("/")
def read(data: Annotated[Model, Header(convert_underscores=convert_underscores)]):
- return data
+ return {"x_values": data.x_values}
client = TestClient(app)
response = client.get("/", headers={name: "1"})
@@ -403,16 +409,24 @@ def read(data: Annotated[Model, Header(convert_underscores=convert_underscores)]
),
],
)
-def test_cookie_model_name_fallback(config, expected_value):
+@pytest.mark.parametrize(
+ "field_info",
+ [
+ Field(alias="second"),
+ Field(validation_alias="second"),
+ Field(validation_alias=AliasChoices("first", "second")),
+ ],
+)
+def test_cookie_model_name_fallback(config, expected_value, field_info):
app = FastAPI()
class Model(BaseModel):
model_config = config
- value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))]
+ value: Annotated[int, field_info]
@app.get("/")
def read(data: Annotated[Model, Cookie()]):
- return data
+ return {"value": data.value}
client = TestClient(app)
response = client.get("/", headers={"cookie": "value=1"})
@@ -423,7 +437,7 @@ def read(data: Annotated[Model, Cookie()]):
assert response.json() == {"value": expected_value}
-def test_path_alias_choices_are_unchanged():
+def test_unmatched_path_alias_choices_preserve_parameter_name():
response = client.get("/path/1")
assert response.status_code == 200
assert response.json() == 1
diff --git a/tests/test_multiple_parameter_models.py b/tests/test_multiple_parameter_models.py
new file mode 100644
index 0000000000000..abd4332cf0f9e
--- /dev/null
+++ tests/test_multiple_parameter_models.py
@@ -0,0 +1,417 @@
+from typing import Annotated
+from unittest.mock import patch
+
+import pytest
+from fastapi import APIRouter, Cookie, Depends, FastAPI, Header, Query, WebSocket
+from fastapi.testclient import TestClient
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field, Json, model_validator
+
+
+@pytest.mark.parametrize(
+ "marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
+)
+@pytest.mark.parametrize("in_dependency", [False, True])
+@pytest.mark.parametrize("extra", ["forbid", "allow"])
+def test_composed_parameter_models(marker, location, in_dependency, extra):
+ app = FastAPI()
+
+ class Filters(BaseModel):
+ model_config = ConfigDict(extra=extra)
+ term: str
+
+ class Pagination(BaseModel):
+ model_config = ConfigDict(extra=extra)
+ limit: int = Field(10, ge=1, description="Page size")
+
+ def filters_dependency(filters: Annotated[Filters, marker()]):
+ return filters
+
+ filter_annotation = Depends(filters_dependency) if in_dependency else marker()
+
+ @app.get("/")
+ def read(
+ filters: Annotated[Filters, filter_annotation],
+ pagination: Annotated[Pagination, marker()],
+ host: Annotated[str, marker()] = "testserver",
+ ):
+ return {"filters": filters, "pagination": pagination, "host": host}
+
+ client = TestClient(app)
+ client.headers.clear()
+
+ def send(data):
+ if location == "query":
+ return client.get("/", params=data)
+ if location == "header":
+ return client.get("/", headers=data)
+ return client.get(
+ "/", headers={"cookie": "; ".join(f"{k}={v}" for k, v in data.items())}
+ )
+
+ response = send({"term": "hello", "limit": "2", "host": "example.com"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "filters": {"term": "hello"},
+ "pagination": {"limit": 2},
+ "host": "example.com",
+ }
+ response = send({"term": "hello"})
+ assert response.status_code == 200, response.text
+ assert response.json()["pagination"] == {"limit": 10}
+ response = send({"term": "hello", "limit": "0"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == [location, "limit"]
+ response = send({"term": "hello", "typo": "unexpected"})
+ if extra == "forbid":
+ assert response.status_code == 422
+ assert all(
+ error["loc"] == [location, "typo"] for error in response.json()["detail"]
+ )
+ else:
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "filters": {"term": "hello", "typo": "unexpected"},
+ "pagination": {"limit": 10, "typo": "unexpected"},
+ "host": "testserver",
+ }
+ parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+ assert {(p["in"], p["name"]) for p in parameters} == {
+ (location, "term"),
+ (location, "limit"),
+ (location, "host"),
+ }
+ limit = next(p for p in parameters if p["name"] == "limit")
+ assert limit["schema"]["minimum"] == 1
+ assert limit["schema"]["default"] == 10
+ assert limit["description"] == "Page size"
+ assert limit["required"] is False
+
+
+def test_parameter_shapes_and_aliases_are_independent():
+ app = FastAPI()
+
+ class Many(BaseModel):
+ names: Annotated[
+ list[str], Field(validation_alias=AliasChoices("names", "legacy"))
+ ]
+
+ class One(BaseModel):
+ text: str = Field(alias="legacy")
+
+ @app.get("/")
+ def read(many: Annotated[Many, Query()], one: Annotated[One, Query()], legacy: str):
+ return {"many": many, "one": one, "last": legacy}
+
+ response = TestClient(app).get(
+ "/", params=[("legacy", "first"), ("legacy", "last")]
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "many": {"names": ["first", "last"]},
+ "one": {"legacy": "last"},
+ "last": "last",
+ }
+
+
+def test_header_conversion_is_per_model_and_does_not_mutate_cached_fields():
+ app = FastAPI()
+
+ class Headers(BaseModel):
+ x_id: int
+ tags: list[str] = Field(default_factory=list, alias="X-Tags")
+
+ @app.get("/mixed")
+ def read(
+ converted: Annotated[Headers, Header()],
+ literal: Annotated[Headers, Header(convert_underscores=False)],
+ ):
+ return {"converted": converted, "literal": literal}
+
+ @app.get("/query")
+ def read_query(data: Annotated[Headers, Query()]):
+ return data
+
+ client = TestClient(app)
+ response = client.get(
+ "/mixed",
+ headers=[("X-Id", "1"), ("x_id", "2"), ("X-Tags", "a"), ("x-tags", "b")],
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "converted": {"x_id": 1, "X-Tags": ["a", "b"]},
+ "literal": {"x_id": 2, "X-Tags": ["a", "b"]},
+ }
+ schema = app.openapi()
+ assert {p["name"] for p in schema["paths"]["/mixed"]["get"]["parameters"]} == {
+ "x-id",
+ "x_id",
+ "X-Tags",
+ }
+ assert {p["name"] for p in schema["paths"]["/query"]["get"]["parameters"]} == {
+ "x_id",
+ "X-Tags",
+ }
+ response = client.get("/query", params={"x_id": "3", "X-Tags": ["c", "d"]})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"x_id": 3, "X-Tags": ["c", "d"]}
+ response = client.get("/mixed", headers={"x-id": "bad", "x_id": "also-bad"})
+ assert response.status_code == 422
+ assert [error["loc"] for error in response.json()["detail"]] == [
+ ["header", "x-id"],
+ ["header", "x_id"],
+ ]
+ assert Headers.model_fields["x_id"].alias is None
+
+
+def test_model_validators_defaults_and_unknown_extras():
+ app = FastAPI()
+
+ class Range(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ start: int = 0
+ end: int = 10
+ tags: list[str] = Field(default_factory=list)
+
+ @model_validator(mode="after")
+ def check_range(self):
+ if self.start > self.end:
+ raise ValueError("start must not exceed end")
+ return self
+
+ @app.get("/")
+ def read(data: Annotated[Range, Query()], order: str = "asc"):
+ return {
+ "data": data,
+ "fields_set": sorted(data.model_fields_set),
+ "order": order,
+ }
+
+ client = TestClient(app)
+ response = client.get("/", params={"order": "desc", "note": ["a", "b"]})
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "data": {"start": 0, "end": 10, "tags": [], "note": ["a", "b"]},
+ "fields_set": ["note"],
+ "order": "desc",
+ }
+ response = client.get("/", params={"start": "20"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query"]
+
+
+def test_dependency_overrides_and_router_parameters():
+ app = FastAPI()
+
+ class Filters(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ term: str
+
+ def pagination(limit: int = 10):
+ return limit
+
+ def replacement(page: int = 1):
+ return page
+
+ def token(token: str):
+ return token
+
+ router = APIRouter(dependencies=[Depends(token)])
+
+ @router.get("/")
+ def read(
+ filters: Annotated[Filters, Query()],
+ page: Annotated[int, Depends(pagination)],
+ ):
+ return {"filters": filters, "page": page}
+
+ app.include_router(router)
+ client = TestClient(app)
+ assert (
+ client.get(
+ "/", params={"term": "a", "limit": "2", "token": "secret"}
+ ).status_code
+ == 200
+ )
+ with patch(
+ "fastapi.dependencies.utils._get_request_param_names",
+ side_effect=AssertionError("Parameter names should already be collected"),
+ ):
+ response = client.get(
+ "/", params={"term": "a", "limit": "2", "token": "secret"}
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {"filters": {"term": "a"}, "page": 2}
+ app.dependency_overrides[pagination] = replacement
+ response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"filters": {"term": "a"}, "page": 3}
+ response = client.get("/", params={"term": "a", "limit": "2", "token": "secret"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "limit"]
+ app.dependency_overrides.clear()
+ response = client.get("/", params={"term": "a", "limit": "4", "token": "secret"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"filters": {"term": "a"}, "page": 4}
+ response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "page"]
+
+
+def test_parameter_models_keep_request_locations_separate():
+ app = FastAPI()
+
+ class Model(BaseModel):
+ value: int
+
+ @app.get("/")
+ def read(
+ query: Annotated[Model, Query()],
+ header: Annotated[Model, Header()],
+ cookie: Annotated[Model, Cookie()],
+ ):
+ return {"query": query, "header": header, "cookie": cookie}
+
+ response = TestClient(app).get(
+ "/", params={"value": "1"}, headers={"value": "2", "cookie": "value=3"}
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "query": {"value": 1},
+ "header": {"value": 2},
+ "cookie": {"value": 3},
+ }
+ parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+ assert {(param["in"], param["name"]) for param in parameters} == {
+ ("query", "value"),
+ ("header", "value"),
+ ("cookie", "value"),
+ }
+
+
+@pytest.mark.parametrize("marker", [Header, Cookie])
+def test_other_locations_do_not_allow_extra_query_parameters(marker):
+ app = FastAPI()
+
+ class Filters(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ term: str
+
+ @app.get("/")
+ def read(
+ filters: Annotated[Filters, Query()], limit: Annotated[int, marker()] = 10
+ ):
+ return filters
+
+ client = TestClient(app)
+ response = client.get("/", params={"term": "a"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"term": "a"}
+ response = client.get("/", params={"term": "a", "limit": "2"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "limit"]
+ assert response.json()["detail"][0]["type"] == "extra_forbidden"
+
+
+def test_hidden_model_and_shared_required_parameter():
+ app = FastAPI()
+
+ class Optional(BaseModel):
+ shared: int = 1
+
+ class Required(BaseModel):
+ shared: int
+
+ class Hidden(BaseModel):
+ secret: str
+
+ @app.get("/")
+ def read(
+ optional: Annotated[Optional, Query()],
+ required: Annotated[Required, Query()],
+ hidden: Annotated[Hidden, Query(include_in_schema=False)],
+ ):
+ return [optional, required, hidden]
+
+ client = TestClient(app)
+ response = client.get("/", params={"shared": "2", "secret": "ok"})
+ assert response.status_code == 200, response.text
+ assert response.json() == [{"shared": 2}, {"shared": 2}, {"secret": "ok"}]
+ assert client.get("/", params={"secret": "ok"}).status_code == 422
+ parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+ assert len(parameters) == 1
+ assert parameters[0]["name"] == "shared"
+ assert parameters[0]["required"] is True
+
+
+def test_shared_alias_choices_still_validate_unused_choices_as_extras():
+ app = FastAPI()
+
+ class Model(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))]
+
+ @app.get("/")
+ def read(model: Annotated[Model, Query()], second: int = 0):
+ return model
+
+ client = TestClient(app)
+ assert client.get("/", params={"second": "2"}).json() == {"value": 2}
+ response = client.get("/", params={"first": "1", "second": "2"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "second"]
+
+
+@pytest.mark.parametrize(
+ "marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
+)
+def test_explicit_json_model_stays_a_single_parameter(marker, location):
+ app = FastAPI()
+
+ class Model(BaseModel):
+ value: int
+
+ @app.get("/")
+ def read(
+ data: Annotated[Json[Model], marker()], limit: Annotated[int, marker()] = 10
+ ):
+ return {"data": data, "limit": limit}
+
+ client = TestClient(app)
+ data = '{"value": 2}'
+ if location == "query":
+ response = client.get("/", params={"data": data})
+ elif location == "header":
+ response = client.get("/", headers={"data": data})
+ else:
+ response = client.get("/", headers={"cookie": f"data={data}"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"data": {"value": 2}, "limit": 10}
+ assert {p["name"] for p in app.openapi()["paths"]["/"]["get"]["parameters"]} == {
+ "data",
+ "limit",
+ }
+
+
+def test_nested_dependencies_and_websocket_parameter_models():
+ app = FastAPI()
+
+ class Model(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ term: str
+
+ def filters(data: Annotated[Model, Query()]):
+ return data
+
+ def nested(data: Annotated[Model, Depends(filters)]):
+ return data
+
+ @app.websocket("/ws")
+ async def read(
+ websocket: WebSocket, data: Annotated[Model, Depends(nested)], limit: int = 10
+ ):
+ await websocket.accept()
+ await websocket.send_json({"term": data.term, "limit": limit})
+ await websocket.close()
+
+ with TestClient(app).websocket_connect("/ws?term=a&limit=2") as websocket:
+ assert websocket.receive_json() == {"term": "a", "limit": 2}
diff --git a/tests/test_path_alias_choices.py b/tests/test_path_alias_choices.py
new file mode 100644
index 0000000000000..11aecab8f7237
--- /dev/null
+++ tests/test_path_alias_choices.py
@@ -0,0 +1,287 @@
+from typing import Annotated
+
+import pytest
+from fastapi import APIRouter, Depends, FastAPI, Path, WebSocket
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+from pydantic import AliasChoices, AliasPath
+
+app = FastAPI()
+router = APIRouter()
+identifier_path = Path(
+ alias="unused",
+ validation_alias=AliasChoices("item_id", "id"),
+ ge=1,
+)
+Identifier = Annotated[int, identifier_path]
+
+
+def get_identifier(value: Identifier):
+ return value
+
+
+@router.get("/")
+def read_item(value: Annotated[int, Depends(get_identifier)]):
+ return value
+
+
+app.include_router(router, prefix="/items/{item_id}")
+app.include_router(router, prefix="/legacy-items/{id}")
+
+
+@app.websocket("/ws/{id}")
+async def read_websocket(
+ websocket: WebSocket, value: Annotated[int, Depends(get_identifier)]
+):
+ await websocket.accept()
+ await websocket.send_json(value)
+ await websocket.close()
+
+
+client = TestClient(app)
+
+
+@pytest.mark.parametrize("prefix", ["/items", "/legacy-items"])
+def test_dependency_alias_choices_follow_router_prefix(prefix: str):
+ response = client.get(f"{prefix}/1/")
+ assert response.status_code == 200
+ assert response.json() == 1
+ assert identifier_path.validation_alias == AliasChoices("item_id", "id")
+ assert identifier_path.alias == "unused"
+
+
+@pytest.mark.parametrize(
+ "prefix, alias", [("/items", "item_id"), ("/legacy-items", "id")]
+)
+def test_invalid_value_uses_route_parameter_name(prefix: str, alias: str):
+ response = client.get(f"{prefix}/invalid/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["path", alias],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ "input": "invalid",
+ }
+ ]
+ }
+
+
+def test_selected_alias_preserves_constraints():
+ response = client.get("/legacy-items/0/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "greater_than_equal",
+ "loc": ["path", "id"],
+ "msg": "Input should be greater than or equal to 1",
+ "input": "0",
+ "ctx": {"ge": 1},
+ }
+ ]
+ }
+
+
+@pytest.mark.parametrize("alias", ["first", "second"])
+def test_individual_path_alias_choices(alias: str):
+ app = FastAPI()
+
+ @app.get("/{" + alias + "}")
+ def read(
+ value: Annotated[int, Path(validation_alias=AliasChoices("first", "second"))],
+ ):
+ return value
+
+ client = TestClient(app)
+ response = client.get("/1")
+ assert response.status_code == 200
+ assert response.json() == 1
+
+
+def test_multiple_matching_choices_follow_declaration_order():
+ app = FastAPI()
+
+ @app.get("/{second}/{first}")
+ def read(
+ value: Annotated[
+ int, Path(alias="second", validation_alias=AliasChoices("first", "second"))
+ ],
+ second: str,
+ ):
+ return {"value": value, "second": second}
+
+ client = TestClient(app)
+ response = client.get("/2/1")
+ assert response.status_code == 200
+ assert response.json() == {"value": 1, "second": "2"}
+ response = client.get("/2/invalid")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["path", "first"],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ "input": "invalid",
+ }
+ ]
+ }
+
+
+def test_unmatched_choices_preserve_explicit_alias():
+ app = FastAPI()
+
+ @app.get("/{legacy}")
+ def read(
+ value: Annotated[
+ int, Path(alias="legacy", validation_alias=AliasChoices("first", "second"))
+ ],
+ ):
+ return value
+
+ client = TestClient(app)
+ response = client.get("/1")
+ assert response.status_code == 200
+ assert response.json() == 1
+
+
+@pytest.mark.parametrize(
+ "validation_alias",
+ [AliasPath("value"), AliasChoices("first", AliasPath("value"))],
+)
+def test_alias_path_extraction_is_unchanged(validation_alias):
+ app = FastAPI()
+
+ @app.get("/{first}/{value}")
+ def read(
+ value: Annotated[int, Path(validation_alias=validation_alias)], first: int
+ ):
+ return {"value": value, "first": first}
+
+ client = TestClient(app)
+ response = client.get("/1/2")
+ assert response.status_code == 200
+ assert response.json() == {"value": 2, "first": 1}
+
+
+def test_websocket_dependency_uses_matching_alias():
+ with client.websocket_connect("/ws/1") as websocket:
+ assert websocket.receive_json() == 1
+
+
+def test_openapi():
+ response = client.get("/openapi.json")
+ assert response.status_code == 200
+ assert response.json() == snapshot(
+ {
+ "openapi": "3.1.0",
+ "info": {"title": "FastAPI", "version": "0.1.0"},
+ "paths": {
+ "/items/{item_id}/": {
+ "get": {
+ "summary": "Read Item",
+ "operationId": "read_item_items__item_id___get",
+ "parameters": [
+ {
+ "name": "item_id",
+ "in": "path",
+ "required": True,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "title": "Item Id",
+ },
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/legacy-items/{id}/": {
+ "get": {
+ "summary": "Read Item",
+ "operationId": "read_item_legacy_items__id___get",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "title": "Id",
+ },
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ },
+ "components": {
+ "schemas": {
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {
+ "$ref": "#/components/schemas/ValidationError"
+ },
+ "type": "array",
+ "title": "Detail",
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError",
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [{"type": "string"}, {"type": "integer"}]
+ },
+ "type": "array",
+ "title": "Location",
+ },
+ "msg": {"type": "string", "title": "Message"},
+ "type": {"type": "string", "title": "Error Type"},
+ "input": {"title": "Input"},
+ "ctx": {"type": "object", "title": "Context"},
+ },
+ "type": "object",
+ "required": ["loc", "msg", "type"],
+ "title": "ValidationError",
+ },
+ }
+ },
+ }
+ )
diff --git tests/test_query_alias_choices.py tests/test_query_alias_choices.py
index 34a234cab8bed..9870f74bebb0b 100644
--- tests/test_query_alias_choices.py
+++ tests/test_query_alias_choices.py
@@ -201,42 +201,84 @@ def read(data: Annotated[Model, Query()]):
@pytest.mark.parametrize(
- "validation_alias", [AliasChoices("item", "tag"), AliasChoices("item", "items")]
+ "field_info",
+ [
+ Field(alias="item"),
+ Field(validation_alias="item"),
+ Field(validation_alias=AliasChoices("item", "tag")),
+ Field(validation_alias=AliasChoices("item", "items")),
+ ],
+)
+@pytest.mark.parametrize(
+ "config",
+ [
+ ConfigDict(populate_by_name=True),
+ pytest.param(
+ ConfigDict(validate_by_name=True),
+ marks=pytest.mark.skipif(
+ PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
+ reason="validate_by_name requires Pydantic 2.11",
+ ),
+ ),
+ ],
)
-def test_model_list_populated_by_name(validation_alias: AliasChoices):
+def test_model_list_populated_by_name(field_info, config):
app = FastAPI()
class Model(BaseModel):
- model_config = ConfigDict(populate_by_name=True)
- items: Annotated[list[str], Field(validation_alias=validation_alias)]
+ model_config = config
+ items: Annotated[list[int], field_info]
@app.get("/")
def read(data: Annotated[Model, Query()]):
- return data
+ return {"items": data.items}
client = TestClient(app)
- response = client.get("/", params={"items": "one"})
+ response = client.get("/", params={"items": "1"})
assert response.status_code == 200
- assert response.json() == {"items": ["one"]}
- response = client.get("/", params={"items": "one", "item": "two"})
+ assert response.json() == {"items": [1]}
+ response = client.get("/", params={"items": "1", "item": "2"})
assert response.status_code == 200
- assert response.json() == {"items": ["two"]}
+ assert response.json() == {"items": [2]}
+ response = client.get("/", params={"items": "invalid"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["query", "items", 0],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ "input": "invalid",
+ }
+ ]
+ }
+ response = client.get("/", params={"items": "1", "item": "invalid"})
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "item", 0]
@pytest.mark.skipif(
PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
reason="validate_by_name and validate_by_alias require Pydantic 2.11",
)
-def test_model_can_disable_validation_by_alias():
+@pytest.mark.parametrize(
+ "field_info",
+ [
+ Field(alias="tag"),
+ Field(validation_alias="tag"),
+ Field(validation_alias=AliasChoices("item", "tag")),
+ ],
+)
+def test_model_can_disable_validation_by_alias(field_info):
app = FastAPI()
class Model(BaseModel):
model_config = ConfigDict(validate_by_alias=False, extra="forbid")
- items: Annotated[list[str], Field(validation_alias=AliasChoices("item", "tag"))]
+ items: Annotated[list[str], field_info]
@app.get("/")
def read(data: Annotated[Model, Query()]):
- return data
+ return {"items": data.items}
client = TestClient(app)
response = client.get("/", params={"items": "one"})
diff --git tests/test_request_param_model_by_alias.py tests/test_request_param_model_by_alias.py
index c29130d7a6da4..f1b22b2a7b47b 100644
--- tests/test_request_param_model_by_alias.py
+++ tests/test_request_param_model_by_alias.py
@@ -1,7 +1,12 @@
+from typing import Annotated
+
+import pytest
from dirty_equals import IsPartialDict
from fastapi import Cookie, FastAPI, Header, Query
+from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE
from fastapi.testclient import TestClient
-from pydantic import BaseModel, Field
+from inline_snapshot import snapshot
+from pydantic import BaseModel, ConfigDict, Field
app = FastAPI()
@@ -70,3 +75,173 @@ def test_cookie_model_with_alias_by_name():
assert response.status_code == 422, response.text
details = response.json()
assert details["detail"][0]["input"] == {"param": "value"}
+
+
+@pytest.mark.skipif(
+ PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
+ reason="validate_by_alias requires Pydantic 2.11",
+)
+def test_openapi_with_validation_by_alias_disabled():
+ app = FastAPI()
+
+ class Model(BaseModel):
+ model_config = ConfigDict(validate_by_alias=False)
+ int_value: Annotated[int, Field(alias="legacy")]
+
+ @app.get("/query")
+ def read_query(data: Annotated[Model, Query()]):
+ return data
+
+ @app.get("/header")
+ def read_header(data: Annotated[Model, Header()]):
+ return data
+
+ @app.get("/cookie")
+ def read_cookie(data: Annotated[Model, Cookie()]):
+ return data
+
+ client = TestClient(app)
+ response = client.get("/query", params={"int_value": "1"})
+ assert response.status_code == 200
+ assert response.json() == {"legacy": 1}
+ response = client.get("/header", headers={"int-value": "1"})
+ assert response.status_code == 200
+ assert response.json() == {"legacy": 1}
+ response = client.get("/cookie", headers={"cookie": "int_value=1"})
+ assert response.status_code == 200
+ assert response.json() == {"legacy": 1}
+ response = client.get("/openapi.json")
+ assert response.status_code == 200
+ assert response.json() == snapshot(
+ {
+ "openapi": "3.1.0",
+ "info": {"title": "FastAPI", "version": "0.1.0"},
+ "paths": {
+ "/query": {
+ "get": {
+ "summary": "Read Query",
+ "operationId": "read_query_query_get",
+ "parameters": [
+ {
+ "name": "int_value",
+ "in": "query",
+ "required": True,
+ "schema": {"type": "integer", "title": "Int Value"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/header": {
+ "get": {
+ "summary": "Read Header",
+ "operationId": "read_header_header_get",
+ "parameters": [
+ {
+ "name": "int-value",
+ "in": "header",
+ "required": True,
+ "schema": {"type": "integer", "title": "Int Value"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/cookie": {
+ "get": {
+ "summary": "Read Cookie",
+ "operationId": "read_cookie_cookie_get",
+ "parameters": [
+ {
+ "name": "int_value",
+ "in": "cookie",
+ "required": True,
+ "schema": {"type": "integer", "title": "Int Value"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ },
+ "components": {
+ "schemas": {
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {
+ "$ref": "#/components/schemas/ValidationError"
+ },
+ "type": "array",
+ "title": "Detail",
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError",
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [{"type": "string"}, {"type": "integer"}]
+ },
+ "type": "array",
+ "title": "Location",
+ },
+ "msg": {"type": "string", "title": "Message"},
+ "type": {"type": "string", "title": "Error Type"},
+ "input": {"title": "Input"},
+ "ctx": {"type": "object", "title": "Context"},
+ },
+ "type": "object",
+ "required": ["loc", "msg", "type"],
+ "title": "ValidationError",
+ },
+ }
+ },
+ }
+ )
diff --git tests/test_request_params/test_file/test_nullable_and_defaults.py tests/test_request_params/test_file/test_nullable_and_defaults.py
index d4d988564eeb2..5d6cd2dda37f3 100644
--- tests/test_request_params/test_file/test_nullable_and_defaults.py
+++ tests/test_request_params/test_file/test_nullable_and_defaults.py
@@ -300,6 +300,112 @@ def read_file(
assert response.json() == "hello"
+def test_file_alias_choices_errors_use_selected_aliases():
+ app = FastAPI()
+
+ @app.post("/")
+ def read_files(
+ file: Annotated[
+ UploadFile,
+ File(alias="legacy", validation_alias=AliasChoices("primary", "secondary")),
+ ],
+ files: Annotated[
+ list[UploadFile],
+ File(validation_alias=AliasChoices("primary-files", "secondary-files")),
+ ],
+ ):
+ return {"file": file.filename, "files": [item.filename for item in files]}
+
+ client = TestClient(app)
+ response = client.post(
+ "/",
+ files=[
+ ("secondary", ("one.txt", b"one")),
+ ("secondary-files", ("two.txt", b"two")),
+ ("secondary-files", ("three.txt", b"three")),
+ ],
+ )
+ assert response.status_code == 200
+ assert response.json() == {"file": "one.txt", "files": ["two.txt", "three.txt"]}
+
+ response = client.post(
+ "/",
+ files=[
+ ("secondary", (None, "invalid")),
+ ("secondary-files", ("valid.txt", b"valid")),
+ ("secondary-files", (None, "invalid")),
+ ("legacy", ("ignored.txt", b"ignored")),
+ ],
+ )
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("value_error", ["body", "secondary"]),
+ ("value_error", ["body", "secondary-files", 1]),
+ ]
+
+ response = client.post(
+ "/",
+ files=[
+ ("primary", (None, "invalid")),
+ ("secondary", ("valid.txt", b"valid")),
+ ("primary-files", (None, "invalid")),
+ ("secondary-files", ("valid.txt", b"valid")),
+ ],
+ )
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("value_error", ["body", "primary"]),
+ ("value_error", ["body", "primary-files", 0]),
+ ]
+
+ response = client.post("/")
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("missing", ["body", "primary"]),
+ ("missing", ["body", "primary-files"]),
+ ]
+
+
+def test_unselected_file_alias_choices_use_the_next_alias():
+ app = FastAPI()
+
+ @app.post("/")
+ def read_file(
+ file: Annotated[
+ bytes,
+ File(alias="legacy", validation_alias=AliasChoices("file", "selected")),
+ ],
+ ):
+ return file.decode()
+
+ client = TestClient(app)
+ response = client.post(
+ "/",
+ content=(
+ b"--boundary\r\n"
+ b'Content-Disposition: form-data; name="file"; filename=""\r\n'
+ b"Content-Type: application/octet-stream\r\n\r\n"
+ b"\r\n--boundary\r\n"
+ b'Content-Disposition: form-data; name="selected"; filename="hello.txt"\r\n'
+ b"Content-Type: application/octet-stream\r\n\r\n"
+ b"hello\r\n--boundary--\r\n"
+ ),
+ headers={"content-type": "multipart/form-data; boundary=boundary"},
+ )
+ assert response.status_code == 200
+ assert response.json() == "hello"
+
+ response = client.post(
+ "/",
+ content=EMPTY_FILE_FORM,
+ headers={"content-type": "multipart/form-data; boundary=boundary"},
+ )
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("missing", ["body", "file"]),
+ ]
+
+
@pytest.mark.parametrize(
"file_alias,files_alias",
[("primary", "primary-files"), ("secondary", "secondary-files"), ("file", "files")],
diff --git tests/test_request_params/test_form/test_nullable_and_defaults.py tests/test_request_params/test_form/test_nullable_and_defaults.py
index 0c45682f2b72c..abd7d1200341e 100644
--- tests/test_request_params/test_form/test_nullable_and_defaults.py
+++ tests/test_request_params/test_form/test_nullable_and_defaults.py
@@ -295,10 +295,8 @@ def read_model(params: Annotated[Model, Form()]):
assert response.json() == {"value": expected}
-@pytest.mark.parametrize(
- "path,error_alias", [("/parameter", "value"), ("/model", "first")]
-)
-def test_required_form_alias_choices_preserve_validation_precedence(path, error_alias):
+@pytest.mark.parametrize("path", ["/parameter", "/model"])
+def test_required_form_alias_choices_preserve_validation_precedence(path):
app = FastAPI()
class Model(BaseModel):
@@ -322,13 +320,31 @@ def read_model(params: Annotated[Model, Form()]):
response = client.post(path, data={"first": "invalid", "second": "5"})
assert response.status_code == 422
assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
- ("int_parsing", ["body", error_alias]),
+ ("int_parsing", ["body", "first"]),
]
response = client.post(path, data={"first": "", "second": ""})
assert response.status_code == 422
assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
- ("missing", ["body", error_alias]),
+ ("missing", ["body", "first"]),
+ ]
+
+ response = client.post(path, data={"first": "", "second": "invalid"})
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("int_parsing", ["body", "second"]),
+ ]
+
+ response = client.post(path, data={"second": "invalid"})
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("int_parsing", ["body", "second"]),
+ ]
+
+ response = client.post(path)
+ assert response.status_code == 422
+ assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+ ("missing", ["body", "first"]),
]
@@ -396,6 +412,71 @@ def read_model(params: Annotated[Model, Form()]):
assert response.json() == {"value": expected}
+@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"])
+def test_form_list_alias_choices_error_uses_selected_alias(encoding):
+ app = FastAPI()
+
+ @app.post("/")
+ def read_form(
+ values: Annotated[
+ list[int],
+ Form(alias="legacy", validation_alias=AliasChoices("first", "second")),
+ ],
+ ):
+ return values
+
+ client = TestClient(app)
+ if encoding == "urlencoded":
+ response = client.post("/", data={"second": ["1", "2"]})
+ else:
+ response = client.post(
+ "/", files=[("second", (None, "1")), ("second", (None, "2"))]
+ )
+ assert response.status_code == 200
+ assert response.json() == [1, 2]
+
+ if encoding == "urlencoded":
+ response = client.post("/", data={"second": ["1", "invalid"], "legacy": "3"})
+ else:
+ response = client.post(
+ "/",
+ files=[
+ ("second", (None, "1")),
+ ("second", (None, "invalid")),
+ ("legacy", (None, "3")),
+ ],
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["body", "second", 1],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ "input": "invalid",
+ }
+ ]
+ }
+
+
+def test_individual_form_parameters_can_share_an_alias_choice():
+ app = FastAPI()
+
+ @app.post("/")
+ def read_form(
+ values: Annotated[
+ list[int], Form(validation_alias=AliasChoices("values", "shared"))
+ ],
+ value: Annotated[int, Form(validation_alias=AliasChoices("value", "shared"))],
+ ):
+ return {"values": values, "value": value}
+
+ client = TestClient(app)
+ response = client.post("/", data={"shared": ["1", "2"]})
+ assert response.status_code == 200
+ assert response.json() == {"values": [1, 2], "value": 2}
+
+
@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"])
def test_form_model_fields_can_share_an_unused_alias_choice(encoding):
app = FastAPI()
@@ -935,10 +1016,8 @@ def read_model(params: Annotated[Model, Form()]):
assert response.json() == {"value": False, "nullable": False}
-@pytest.mark.parametrize(
- "path,error_alias", [("/parameter", "legacy"), ("/model", "enabled")]
-)
-def test_checkbox_alias_choices_preserve_validation_precedence(path, error_alias):
+@pytest.mark.parametrize("path", ["/parameter", "/model"])
+def test_checkbox_alias_choices_preserve_validation_precedence(path):
app = FastAPI()
class Model(BaseModel):
@@ -976,7 +1055,7 @@ def read_model(params: Annotated[Model, Form()]):
"detail": [
{
"type": "bool_parsing",
- "loc": ["body", error_alias],
+ "loc": ["body", "enabled"],
"msg": "Input should be a valid boolean, unable to interpret input",
"input": "invalid",
}
diff --git a/tests/test_tutorial/test_query_param_models/test_tutorial003.py b/tests/test_tutorial/test_query_param_models/test_tutorial003.py
new file mode 100644
index 0000000000000..c441edca3e783
--- /dev/null
+++ tests/test_tutorial/test_query_param_models/test_tutorial003.py
@@ -0,0 +1,38 @@
+import importlib
+
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py310
+
+
+@pytest.fixture(
+ name="client",
+ params=[
+ pytest.param("tutorial003_py310", marks=needs_py310),
+ pytest.param("tutorial003_an_py310", marks=needs_py310),
+ ],
+)
+def get_client(request: pytest.FixtureRequest):
+ mod = importlib.import_module(f"docs_src.query_param_models.{request.param}")
+ return TestClient(mod.app)
+
+
+def test_combined_query_param_models(client: TestClient):
+ response = client.get("/items/?limit=5&offset=10&tags=red&tags=blue&search=shirt")
+ assert response.status_code == 200
+ assert response.json() == {
+ "pagination": {"limit": 5, "offset": 10},
+ "filters": {"tags": ["red", "blue"]},
+ "search": "shirt",
+ }
+
+
+def test_combined_query_param_models_defaults(client: TestClient):
+ response = client.get("/items/")
+ assert response.status_code == 200
+ assert response.json() == {
+ "pagination": {"limit": 10, "offset": 0},
+ "filters": {"tags": []},
+ "search": None,
+ }
diff --git tests/test_tutorial/test_query_params/test_tutorial003.py tests/test_tutorial/test_query_params/test_tutorial003.py
index 2951f1adf9529..151abef2cb905 100644
--- tests/test_tutorial/test_query_params/test_tutorial003.py
+++ tests/test_tutorial/test_query_params/test_tutorial003.py
@@ -50,6 +50,32 @@ def test_read_user_item(client: TestClient, path, expected_json):
assert response.json() == expected_json
+@pytest.mark.parametrize("short", ["0", "false", "off", "no", "FaLsE"])
+def test_boolean_query_false(client: TestClient, short: str):
+ response = client.get("/items/foo", params={"short": short})
+ assert response.status_code == 200
+ assert response.json() == {
+ "item_id": "foo",
+ "description": "This is an amazing item that has a long description",
+ }
+
+
+@pytest.mark.parametrize("short", ["", "maybe"])
+def test_invalid_boolean_query(client: TestClient, short: str):
+ response = client.get("/items/foo", params={"short": short})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "bool_parsing",
+ "loc": ["query", "short"],
+ "msg": "Input should be a valid boolean, unable to interpret input",
+ "input": short,
+ }
+ ]
+ }
+
+
def test_openapi_schema(client: TestClient):
response = client.get("/openapi.json")
assert response.status_code == 200
diff --git tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
index 74c10b885d480..bdd6fb271cd7f 100644
--- tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
+++ tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
@@ -22,39 +22,34 @@ def get_client(request: pytest.FixtureRequest):
return client
-@pytest.mark.xfail(
- reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419"
-)
def test_query_params_str_validations_no_query(client: TestClient):
response = client.get("/items/")
- assert response.status_code == 200
- assert response.json() == { # pragma: no cover
- "items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
- }
-
-
-@pytest.mark.xfail(
- reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419"
-)
-def test_query_params_str_validations_empty_str(client: TestClient):
- response = client.get("/items/?q=")
- assert response.status_code == 200
- assert response.json() == { # pragma: no cover
- "items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["query", "q"],
+ "msg": "Field required",
+ "input": None,
+ }
+ ]
}
-def test_query_params_str_validations_q_query(client: TestClient):
- response = client.get("/items/", params={"q": "query"})
+@pytest.mark.parametrize("q", ["query", "None", "null"])
+def test_query_params_str_validations_q_query(client: TestClient, q: str):
+ response = client.get("/items/", params={"q": q})
assert response.status_code == 200
assert response.json() == {
"items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
- "q": "query",
+ "q": q,
}
-def test_query_params_str_validations_q_short(client: TestClient):
- response = client.get("/items/", params={"q": "fa"})
+@pytest.mark.parametrize("q", ["", "fa"])
+def test_query_params_str_validations_q_short(client: TestClient, q: str):
+ response = client.get("/items/", params={"q": q})
assert response.status_code == 422
assert response.json() == {
"detail": [
@@ -62,7 +57,7 @@ def test_query_params_str_validations_q_short(client: TestClient):
"type": "string_too_short",
"loc": ["query", "q"],
"msg": "String should have at least 3 characters",
- "input": "fa",
+ "input": q,
"ctx": {"min_length": 3},
}
]
diff --git a/tests/test_upload_file_read_position.py b/tests/test_upload_file_read_position.py
new file mode 100644
index 0000000000000..8e8a8ec592962
--- /dev/null
+++ tests/test_upload_file_read_position.py
@@ -0,0 +1,147 @@
+from io import BytesIO
+from typing import Annotated
+
+import pytest
+from fastapi import Depends, FastAPI, File, UploadFile
+from fastapi.dependencies.utils import _read_file_bytes
+from fastapi.testclient import TestClient
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ pytest.param(b"hello", id="memory"),
+ pytest.param(b"x" * (1024 * 1024 + 1), id="disk"),
+ ],
+)
+def test_file_bytes_shared_by_dependency_and_endpoint(content: bytes):
+ app = FastAPI()
+
+ def inspect_file(file: Annotated[bytes, File()]):
+ return file.decode()
+
+ @app.post("/")
+ def upload(
+ inspected: Annotated[str, Depends(inspect_file)],
+ file: Annotated[bytes, File()],
+ ):
+ return {"dependency": inspected, "endpoint": file.decode()}
+
+ client = TestClient(app)
+ response = client.post("/", files={"file": ("hello.txt", content)})
+ assert response.status_code == 200
+ assert response.json() == {
+ "dependency": content.decode(),
+ "endpoint": content.decode(),
+ }
+
+
+def test_file_bytes_list_shared_by_dependency_and_endpoint():
+ app = FastAPI()
+
+ def inspect_files(files: Annotated[list[bytes], File()]):
+ return [file.decode() for file in files]
+
+ @app.post("/")
+ def upload(
+ inspected: Annotated[list[str], Depends(inspect_files)],
+ files: Annotated[list[bytes], File()],
+ ):
+ return {"dependency": inspected, "endpoint": [file.decode() for file in files]}
+
+ client = TestClient(app)
+ response = client.post(
+ "/",
+ files=[
+ ("files", ("one.txt", b"one")),
+ ("files", ("two.txt", b"two")),
+ ],
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "dependency": ["one", "two"],
+ "endpoint": ["one", "two"],
+ }
+
+
+def test_bytes_dependency_preserves_upload_file_for_endpoint():
+ app = FastAPI()
+
+ def inspect_file(file: Annotated[bytes, File()]):
+ return file.decode()
+
+ @app.post("/")
+ async def upload(
+ inspected: Annotated[str, Depends(inspect_file)],
+ file: Annotated[UploadFile, File()],
+ ):
+ content = await file.read()
+ second_read = await file.read()
+ return {
+ "dependency": inspected,
+ "endpoint": content.decode(),
+ "second_read": second_read.decode(),
+ }
+
+ client = TestClient(app)
+ response = client.post("/", files={"file": ("hello.txt", b"hello")})
+ assert response.status_code == 200
+ assert response.json() == {
+ "dependency": "hello",
+ "endpoint": "hello",
+ "second_read": "",
+ }
+
+
+def test_automatic_file_read_preserves_nonzero_position():
+ app = FastAPI()
+
+ async def read_prefix(file: Annotated[UploadFile, File()]):
+ return (await file.read(2)).decode()
+
+ def inspect_remainder(
+ prefix: Annotated[str, Depends(read_prefix)],
+ file: Annotated[bytes, File()],
+ ):
+ return {"prefix": prefix, "remainder": file.decode()}
+
+ @app.post("/")
+ async def upload(
+ inspected: Annotated[dict[str, str], Depends(inspect_remainder)],
+ file: Annotated[UploadFile, File()],
+ ):
+ position = file.file.tell()
+ remainder = await file.read()
+ return {
+ "dependency": inspected,
+ "position": position,
+ "remainder": remainder.decode(),
+ }
+
+ client = TestClient(app)
+ response = client.post("/", files={"file": ("hello.txt", b"hello")})
+ assert response.status_code == 200
+ assert response.json() == {
+ "dependency": {"prefix": "he", "remainder": "llo"},
+ "position": 2,
+ "remainder": "llo",
+ }
+
+
+@pytest.mark.anyio
+async def test_failed_automatic_file_read_restores_position(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ with BytesIO(b"hello") as stream:
+ file = UploadFile(file=stream)
+ stream.seek(2)
+
+ async def failing_read(size: int = -1) -> bytes:
+ stream.read(1)
+ raise OSError("read failed")
+
+ monkeypatch.setattr(file, "read", failing_read)
+ with pytest.raises(OSError, match="read failed"):
+ await _read_file_bytes(file)
+
+ assert stream.tell() == 2
DescriptionAllows multiple Pydantic parameter models per location (query/header/cookie), mixed with scalar params and nested dependencies. Each model validates only its own inputs plus unknown ones. Names declared elsewhere in the dependency graph no longer count as model extras. Also:
Possible Issues
Security Hotspots
ChangesChanges
sequenceDiagram
participant R as APIRoute
participant U as dependencies.utils
participant S as solve_dependencies
participant P as request_params_to_args
participant M as _request_param_model_to_args
participant V as Pydantic
R->>U: _get_request_param_names(dependant)
U-->>R: {query, header, cookie} name sets
R->>R: dependant._request_param_names = names
Note over S: per request
S->>S: use cached names (recompute if overrides set)
loop sub-dependants
S->>S: solve_dependencies(sub, _request_param_names)
end
S->>P: query/header/cookie fields + known_param_names
loop each field
alt BaseModel (non-Json)
P->>M: model field, received params, known names
M->>M: collect own fields + unknown keys (skip names owned elsewhere)
M->>V: validate model
V-->>M: value / errors (loc remapped for headers)
M-->>P: value, errors
else scalar
P->>V: validate single alias value
V-->>P: value / errors
end
end
P-->>S: values, errors
|
onyb
approved these changes
Nov 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
>=0.156.4,<0.157.0→>=0.162.0,<0.163.0Release Notes
fastapi/fastapi (fastapi)
v0.162.0Compare Source
Omitted
boolandbool | Noneform fields now use their declared defaults or produce missing-field errors, consistent with other fields and Pydantic behavior. FastAPI no longer infersFalsefor omitted booleans. This applies to individual form parameters, form models, and unions of form models.Omitted boolean model fields are excluded from
model_dump(exclude_unset=True), and their defaults no longer count as submitted fields during union selection. Default factories andvalidate_defaultfollow the usual rules.For HTML checkboxes, declare a boolean with a default of
False. See Boolean Form Fields.Breaking Changes
v0.161.1Compare Source
Fixes
v0.161.0Compare Source
Features
v0.160.0Compare Source
Query, header, cookie, and form parameter models now apply their
extraconfiguration to all submitted fields in the same parameter location, consistent with Pydantic behavior. This includes fields declared in other models, individual parameters, or dependencies, which were previously excluded.With
extra="forbid", fields outside the model are rejected even if another parameter declares them. Withextra="allow", those fields are included as model extras. With the defaultextra="ignore", they are ignored.Breaking Changes
extraconfiguration to sibling fields. PR #16594 by @tiangolo.Internal
v0.159.1Compare Source
Features
v0.159.0Compare Source
Features
v0.158.2Compare Source
Features
v0.158.1Compare Source
Fixes
v0.158.0Compare Source
Features
v0.157.0Compare Source
Features
Docs
Internal
v0.156.8Compare Source
Fixes
bytes. PR #16566 by @tiangolo.v0.156.7Compare Source
Fixes
AliasChoices. PR #16564 by @tiangolo.v0.156.6Compare Source
Fixes
v0.156.5Compare Source
Fixes
AliasChoicesfor path parameters. PR #16560 by @tiangolo.Configuration
📅 Schedule: (UTC)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR was generated by Mend Renovate. View the repository job log.