From 20f40b29c0241fc73d82857ab456ef6fda15659f Mon Sep 17 00:00:00 2001 From: Kent Huang Date: Tue, 2 Dec 2025 11:31:59 +0800 Subject: [PATCH 01/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20`TypeError`=20when?= =?UTF-8?q?=20encoding=20a=20decimal=20with=20a=20`NaN`=20or=20`Infinity`?= =?UTF-8?q?=20value=20(#12935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kent Huang --- fastapi/encoders.py | 12 ++++++++---- tests/test_jsonable_encoder.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 6fc6228e1..793951089 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -34,14 +34,14 @@ def isoformat(o: Union[datetime.date, datetime.time]) -> str: return o.isoformat() -# Taken from Pydantic v1 as is +# Adapted from Pydantic v1 # TODO: pv2 should this return strings instead? def decimal_encoder(dec_value: Decimal) -> Union[int, float]: """ - Encodes a Decimal as int of there's no exponent, otherwise float + Encodes a Decimal as int if there's no exponent, otherwise float This is useful when we use ConstrainedDecimal to represent Numeric(x,0) - where a integer (but not int typed) is used. Encoding this as a float + where an integer (but not int typed) is used. Encoding this as a float results in failed round-tripping between encode and parse. Our Id type is a prime example of this. @@ -50,8 +50,12 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: >>> decimal_encoder(Decimal("1")) 1 + + >>> decimal_encoder(Decimal("NaN")) + nan """ - if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] + exponent = dec_value.as_tuple().exponent + if isinstance(exponent, int) and exponent >= 0: return int(dec_value) else: return float(dec_value) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 447c5b4d6..3b6513e27 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal from enum import Enum +from math import isinf, isnan from pathlib import PurePath, PurePosixPath, PureWindowsPath from typing import Optional @@ -306,6 +307,20 @@ def test_decimal_encoder_int(): assert jsonable_encoder(data) == {"value": 2} +@needs_pydanticv2 +def test_decimal_encoder_nan(): + data = {"value": Decimal("NaN")} + assert isnan(jsonable_encoder(data)["value"]) + + +@needs_pydanticv2 +def test_decimal_encoder_infinity(): + data = {"value": Decimal("Infinity")} + assert isinf(jsonable_encoder(data)["value"]) + data = {"value": Decimal("-Infinity")} + assert isinf(jsonable_encoder(data)["value"]) + + def test_encode_deque_encodes_child_models(): class Model(BaseModel): test: str From 327bad18aa326b02ba5e680648d26a92052aef1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 03:32:40 +0000 Subject: [PATCH 02/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index ea509a760..d1602598a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix `TypeError` when encoding a decimal with a `NaN` or `Infinity` value. PR [#12935](https://github.com/fastapi/fastapi/pull/12935) by [@kentwelcome](https://github.com/kentwelcome). + ### Internal * 🔧 Update sponsors: add Greptile. PR [#14429](https://github.com/fastapi/fastapi/pull/14429) by [@tiangolo](https://github.com/tiangolo). From bf322d0e94abf67fc407abb26c9718c3ac73d2d4 Mon Sep 17 00:00:00 2001 From: Hemanth U <77799372+hemanth-thirthahalli@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:32:38 +0530 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Windows=20UnicodeEnc?= =?UTF-8?q?odeError=20in=20CLI=20test=20(#14295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sofie Van Landeghem Co-authored-by: Sebastián Ramírez --- tests/test_fastapi_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_fastapi_cli.py b/tests/test_fastapi_cli.py index a5c10778a..78a49a1fb 100644 --- a/tests/test_fastapi_cli.py +++ b/tests/test_fastapi_cli.py @@ -1,3 +1,4 @@ +import os import subprocess import sys from unittest.mock import patch @@ -20,6 +21,7 @@ def test_fastapi_cli(): ], capture_output=True, encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, ) assert result.returncode == 1, result.stdout assert "Path does not exist non_existent_file.py" in result.stdout From 74b4c3c9a1237326716eb2a7743cd3ad824dadb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 04:03:00 +0000 Subject: [PATCH 04/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index d1602598a..7997e69b5 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -13,6 +13,7 @@ hide: ### Internal +* 🐛 Fix Windows UnicodeEncodeError in CLI test. PR [#14295](https://github.com/fastapi/fastapi/pull/14295) by [@hemanth-thirthahalli](https://github.com/hemanth-thirthahalli). * 🔧 Update sponsors: add Greptile. PR [#14429](https://github.com/fastapi/fastapi/pull/14429) by [@tiangolo](https://github.com/tiangolo). * 👥 Update FastAPI GitHub topic repositories. PR [#14426](https://github.com/fastapi/fastapi/pull/14426) by [@tiangolo](https://github.com/tiangolo). * ⬆ Bump markdown-include-variants from 0.0.6 to 0.0.7. PR [#14423](https://github.com/fastapi/fastapi/pull/14423) by [@YuriiMotov](https://github.com/YuriiMotov). From 8f99a2b7347aeaff09ca3f06eaecf52a786ef2dc Mon Sep 17 00:00:00 2001 From: Sofie Van Landeghem Date: Tue, 2 Dec 2025 09:34:13 +0530 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=90=9B=20Avoid=20accessing=20non-ex?= =?UTF-8?q?isting=20"$ref"=20key=20for=20Pydantic=20v2=20compat=20remappin?= =?UTF-8?q?g=20(#14361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- fastapi/_compat/v2.py | 2 +- tests/test_schema_compat_pydantic_v2.py | 92 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/test_schema_compat_pydantic_v2.py diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 5cd49343b..7196a6190 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -304,7 +304,7 @@ def _remap_definitions_and_field_mappings( old_name_to_new_name_map = {} for field_key, schema in field_mapping.items(): model = field_key[0].type_ - if model not in model_name_map: + if model not in model_name_map or "$ref" not in schema: continue new_name = model_name_map[model] old_name = schema["$ref"].split("/")[-1] diff --git a/tests/test_schema_compat_pydantic_v2.py b/tests/test_schema_compat_pydantic_v2.py new file mode 100644 index 000000000..39626c0ec --- /dev/null +++ b/tests/test_schema_compat_pydantic_v2.py @@ -0,0 +1,92 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from pydantic import BaseModel + +from tests.utils import needs_py310, needs_pydanticv2 + + +@pytest.fixture(name="client") +def get_client(): + from enum import Enum + + app = FastAPI() + + class PlatformRole(str, Enum): + admin = "admin" + user = "user" + + class OtherRole(str, Enum): ... + + class User(BaseModel): + username: str + role: PlatformRole | OtherRole + + @app.get("/users") + async def get_user() -> User: + return {"username": "alice", "role": "admin"} + + client = TestClient(app) + return client + + +@needs_py310 +@needs_pydanticv2 +def test_get(client: TestClient): + response = client.get("/users") + assert response.json() == {"username": "alice", "role": "admin"} + + +@needs_py310 +@needs_pydanticv2 +def test_openapi_schema(client: TestClient): + response = client.get("openapi.json") + assert response.json() == snapshot( + { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/users": { + "get": { + "summary": "Get User", + "operationId": "get_user_users_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/User"} + } + }, + } + }, + } + } + }, + "components": { + "schemas": { + "PlatformRole": { + "type": "string", + "enum": ["admin", "user"], + "title": "PlatformRole", + }, + "User": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "role": { + "anyOf": [ + {"$ref": "#/components/schemas/PlatformRole"}, + {"enum": [], "title": "OtherRole"}, + ], + "title": "Role", + }, + }, + "type": "object", + "required": ["username", "role"], + "title": "User", + }, + } + }, + } + ) From 3b4b5ab8c819dce27fbaf0aa0a9c74b7f3255ec0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 04:04:37 +0000 Subject: [PATCH 06/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 7997e69b5..f3f3a0e85 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Avoid accessing non-existing "$ref" key for Pydantic v2 compat remapping. PR [#14361](https://github.com/fastapi/fastapi/pull/14361) by [@svlandeg](https://github.com/svlandeg). * 🐛 Fix `TypeError` when encoding a decimal with a `NaN` or `Infinity` value. PR [#12935](https://github.com/fastapi/fastapi/pull/12935) by [@kentwelcome](https://github.com/kentwelcome). ### Internal From c3373205d03af626631b12ad91f26e77e6ddfd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 2 Dec 2025 05:30:18 +0100 Subject: [PATCH 07/31] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.123.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index f3f3a0e85..b5b3e5b0f 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.123.1 + ### Fixes * 🐛 Avoid accessing non-existing "$ref" key for Pydantic v2 compat remapping. PR [#14361](https://github.com/fastapi/fastapi/pull/14361) by [@svlandeg](https://github.com/svlandeg). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 25ed2bbeb..4f6982035 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.123.0" +__version__ = "0.123.1" from starlette import status as status From d68c066246c9a70f179ca165614dac19b8c8beeb Mon Sep 17 00:00:00 2001 From: ad hoc Date: Tue, 2 Dec 2025 05:39:55 +0100 Subject: [PATCH 08/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20support=20for=20form?= =?UTF-8?q?=20values=20with=20empty=20strings=20interpreted=20as=20missing?= =?UTF-8?q?=20(`None`=20if=20that's=20the=20default),=20for=20compatibilit?= =?UTF-8?q?y=20with=20HTML=20forms=20(#13537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Co-authored-by: Yurii Motov Co-authored-by: Sebastián Ramírez --- fastapi/dependencies/utils.py | 3 ++- tests/test_form_default.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/test_form_default.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index d43fa8a51..0f25a3c36 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -902,8 +902,9 @@ async def _extract_form_body( value = serialize_sequence_value(field=field, value=results) if value is not None: values[field.alias] = value + field_aliases = {field.alias for field in body_fields} for key, value in received_body.items(): - if key not in values: + if key not in field_aliases: values[key] = value return values diff --git a/tests/test_form_default.py b/tests/test_form_default.py new file mode 100644 index 000000000..2a12049d1 --- /dev/null +++ b/tests/test_form_default.py @@ -0,0 +1,35 @@ +from typing import Optional + +from fastapi import FastAPI, File, Form +from starlette.testclient import TestClient +from typing_extensions import Annotated + +app = FastAPI() + + +@app.post("/urlencoded") +async def post_url_encoded(age: Annotated[Optional[int], Form()] = None): + return age + + +@app.post("/multipart") +async def post_multi_part( + age: Annotated[Optional[int], Form()] = None, + file: Annotated[Optional[bytes], File()] = None, +): + return {"file": file, "age": age} + + +client = TestClient(app) + + +def test_form_default_url_encoded(): + response = client.post("/urlencoded", data={"age": ""}) + assert response.status_code == 200 + assert response.text == "null" + + +def test_form_default_multi_part(): + response = client.post("/multipart", data={"age": ""}) + assert response.status_code == 200 + assert response.json() == {"file": None, "age": None} From 740ec2787b25e59f7aa9e3df1db09d4489c22718 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 04:40:16 +0000 Subject: [PATCH 09/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index b5b3e5b0f..cdd807be6 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma). + ## 0.123.1 ### Fixes From 6cf40df24d1d199fd25d034fc87fcae284fc23a2 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Tue, 2 Dec 2025 05:49:32 +0100 Subject: [PATCH 10/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20parsing=20extra=20`F?= =?UTF-8?q?orm`=20parameter=20list=20(#14303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- fastapi/dependencies/utils.py | 8 ++++-- tests/test_forms_single_model.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 0f25a3c36..2b2e6c5af 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -903,9 +903,13 @@ async def _extract_form_body( if value is not None: values[field.alias] = value field_aliases = {field.alias for field in body_fields} - for key, value in received_body.items(): + for key in received_body.keys(): if key not in field_aliases: - values[key] = value + param_values = received_body.getlist(key) + if len(param_values) == 1: + values[key] = param_values[0] + else: + values[key] = param_values return values diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py index 880ab3820..1db63f021 100644 --- a/tests/test_forms_single_model.py +++ b/tests/test_forms_single_model.py @@ -2,6 +2,7 @@ from typing import List, Optional from dirty_equals import IsDict from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 from fastapi.testclient import TestClient from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -17,11 +18,27 @@ class FormModel(BaseModel): alias_with: str = Field(alias="with", default="nothing") +class FormModelExtraAllow(BaseModel): + param: str + + if PYDANTIC_V2: + model_config = {"extra": "allow"} + else: + + class Config: + extra = "allow" + + @app.post("/form/") def post_form(user: Annotated[FormModel, Form()]): return user +@app.post("/form-extra-allow/") +def post_form_extra_allow(params: Annotated[FormModelExtraAllow, Form()]): + return params + + client = TestClient(app) @@ -131,3 +148,33 @@ def test_no_data(): ] } ) + + +def test_extra_param_single(): + response = client.post( + "/form-extra-allow/", + data={ + "param": "123", + "extra_param": "456", + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "param": "123", + "extra_param": "456", + } + + +def test_extra_param_list(): + response = client.post( + "/form-extra-allow/", + data={ + "param": "123", + "extra_params": ["456", "789"], + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "param": "123", + "extra_params": ["456", "789"], + } From 2330e2de752c686d162c02dff6c831ec8c0b754a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 04:49:52 +0000 Subject: [PATCH 11/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index cdd807be6..4432328f6 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Fix parsing extra `Form` parameter list. PR [#14303](https://github.com/fastapi/fastapi/pull/14303) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma). ## 0.123.1 From de5bec637c6af5126382c1e604514964ba9e5093 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Tue, 2 Dec 2025 05:57:19 +0100 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20parsing=20extra=20no?= =?UTF-8?q?n-body=20parameter=20list=20(#14356)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sebastián Ramírez --- fastapi/dependencies/utils.py | 11 +- ..._query_cookie_header_model_extra_params.py | 111 ++++++++++++++++++ .../test_tutorial003.py | 2 +- 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 tests/test_query_cookie_header_model_extra_params.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 2b2e6c5af..20cb2c88c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -791,9 +791,16 @@ def request_params_to_args( processed_keys.add(alias or field.alias) processed_keys.add(field.name) - for key, value in received_params.items(): + for key in received_params.keys(): if key not in processed_keys: - params_to_process[key] = value + if hasattr(received_params, "getlist"): + value = received_params.getlist(key) + if isinstance(value, list) and (len(value) == 1): + params_to_process[key] = value[0] + else: + params_to_process[key] = value + else: + params_to_process[key] = received_params.get(key) if single_not_embedded_field: field_info = first_field.field_info diff --git a/tests/test_query_cookie_header_model_extra_params.py b/tests/test_query_cookie_header_model_extra_params.py new file mode 100644 index 000000000..f4ebefb3f --- /dev/null +++ b/tests/test_query_cookie_header_model_extra_params.py @@ -0,0 +1,111 @@ +from fastapi import Cookie, FastAPI, Header, Query +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class Model(BaseModel): + param: str + + if PYDANTIC_V2: + model_config = {"extra": "allow"} + else: + + class Config: + extra = "allow" + + +@app.get("/query") +async def query_model_with_extra(data: Model = Query()): + return data + + +@app.get("/header") +async def header_model_with_extra(data: Model = Header()): + return data + + +@app.get("/cookie") +async def cookies_model_with_extra(data: Model = Cookie()): + return data + + +def test_query_pass_extra_list(): + client = TestClient(app) + resp = client.get( + "/query", + params={ + "param": "123", + "param2": ["456", "789"], # Pass a list of values as extra parameter + }, + ) + assert resp.status_code == 200 + assert resp.json() == { + "param": "123", + "param2": ["456", "789"], + } + + +def test_query_pass_extra_single(): + client = TestClient(app) + resp = client.get( + "/query", + params={ + "param": "123", + "param2": "456", + }, + ) + assert resp.status_code == 200 + assert resp.json() == { + "param": "123", + "param2": "456", + } + + +def test_header_pass_extra_list(): + client = TestClient(app) + + resp = client.get( + "/header", + headers=[ + ("param", "123"), + ("param2", "456"), # Pass a list of values as extra parameter + ("param2", "789"), + ], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert "param2" in resp_json + assert resp_json["param2"] == ["456", "789"] + + +def test_header_pass_extra_single(): + client = TestClient(app) + + resp = client.get( + "/header", + headers=[ + ("param", "123"), + ("param2", "456"), + ], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert "param2" in resp_json + assert resp_json["param2"] == "456" + + +def test_cookie_pass_extra_list(): + client = TestClient(app) + client.cookies = [ + ("param", "123"), + ("param2", "456"), # Pass a list of values as extra parameter + ("param2", "789"), + ] + resp = client.get("/cookie") + assert resp.status_code == 200 + resp_json = resp.json() + assert "param2" in resp_json + assert resp_json["param2"] == "789" # Cookies only keep the last value diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial003.py b/tests/test_tutorial/test_header_param_models/test_tutorial003.py index 60940e1da..554a48d2e 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial003.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial003.py @@ -77,7 +77,7 @@ def test_header_param_model_no_underscore(client: TestClient): "user-agent": "testclient", "save-data": "true", "if-modified-since": "yesterday", - "x-tag": "two", + "x-tag": ["one", "two"], }, } ) From 10eed3806a2a22a39ccec780451288b5ec955db9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 04:57:45 +0000 Subject: [PATCH 13/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 4432328f6..1fbb810d2 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Fix parsing extra non-body parameter list. PR [#14356](https://github.com/fastapi/fastapi/pull/14356) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix parsing extra `Form` parameter list. PR [#14303](https://github.com/fastapi/fastapi/pull/14303) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma). From cb3792d39e1004947419a2a06b5764894730892d Mon Sep 17 00:00:00 2001 From: Alex Colby Date: Tue, 2 Dec 2025 05:01:11 +0000 Subject: [PATCH 14/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20unformatted=20`{type?= =?UTF-8?q?=5F}`=20in=20FastAPIError=20(#14416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alex Colby --- fastapi/utils.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fastapi/utils.py b/fastapi/utils.py index 2e79ee6b1..b3b89ed2b 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -110,7 +110,9 @@ def create_model_field( try: return v1.ModelField(**v1_kwargs) # type: ignore[no-any-return] except RuntimeError: - raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None + raise fastapi.exceptions.FastAPIError( + _invalid_args_message.format(type_=type_) + ) from None elif PYDANTIC_V2: from ._compat import v2 @@ -121,7 +123,9 @@ def create_model_field( try: return v2.ModelField(**kwargs) # type: ignore[return-value,arg-type] except PydanticSchemaGenerationError: - raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None + raise fastapi.exceptions.FastAPIError( + _invalid_args_message.format(type_=type_) + ) from None # Pydantic v2 is not installed, but it's not a Pydantic v1 ModelField, it could be # a Pydantic v1 type, like a constrained int from fastapi._compat import v1 @@ -129,7 +133,9 @@ def create_model_field( try: return v1.ModelField(**v1_kwargs) # type: ignore[no-any-return] except RuntimeError: - raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None + raise fastapi.exceptions.FastAPIError( + _invalid_args_message.format(type_=type_) + ) from None def create_cloned_field( From c6c7b720969252d994fe380e885ab49a246f068f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 05:01:37 +0000 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 1fbb810d2..5cb4ce279 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Fix unformatted `{type_}` in FastAPIError. PR [#14416](https://github.com/fastapi/fastapi/pull/14416) by [@Just-Helpful](https://github.com/Just-Helpful). * 🐛 Fix parsing extra non-body parameter list. PR [#14356](https://github.com/fastapi/fastapi/pull/14356) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix parsing extra `Form` parameter list. PR [#14303](https://github.com/fastapi/fastapi/pull/14303) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma). From cdafd64c15558ba5de69e7297e526640ff9f422f Mon Sep 17 00:00:00 2001 From: SaisakthiM Date: Tue, 2 Dec 2025 10:33:46 +0530 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=93=9D=20Clarify=20estimation=20not?= =?UTF-8?q?e=20in=20documentation=20(#14070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/en/docs/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26a6c32ae..a42cedae6 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The key features are: * **Robust**: Get production-ready code. With automatic interactive documentation. * **Standards-based**: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema. -* estimation based on tests on an internal development team, building production applications. +* estimation based on tests conducted by an internal development team, building production applications. ## Sponsors diff --git a/docs/en/docs/index.md b/docs/en/docs/index.md index 8a79b26a6..df03b7675 100644 --- a/docs/en/docs/index.md +++ b/docs/en/docs/index.md @@ -46,7 +46,7 @@ The key features are: * **Robust**: Get production-ready code. With automatic interactive documentation. * **Standards-based**: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema. -* estimation based on tests on an internal development team, building production applications. +* estimation based on tests conducted by an internal development team, building production applications. ## Sponsors { #sponsors } From 0f7ce0b78ad212e54a132e0a57996e3b09bdb87c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 05:04:09 +0000 Subject: [PATCH 17/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 5cb4ce279..6a8f009f8 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -14,6 +14,10 @@ hide: * 🐛 Fix parsing extra `Form` parameter list. PR [#14303](https://github.com/fastapi/fastapi/pull/14303) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma). +### Docs + +* 📝 Clarify estimation note in documentation. PR [#14070](https://github.com/fastapi/fastapi/pull/14070) by [@SaisakthiM](https://github.com/SaisakthiM). + ## 0.123.1 ### Fixes From bb05007f55ef3057d604a9f0861a80a54e642151 Mon Sep 17 00:00:00 2001 From: Flavius <55413297+FlaviusRaducu@users.noreply.github.com> Date: Tue, 2 Dec 2025 05:06:56 +0000 Subject: [PATCH 18/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20Primary=20Key=20n?= =?UTF-8?q?otes=20for=20the=20SQL=20databases=20tutorial=20to=20avoid=20co?= =?UTF-8?q?nfusion=20(#14120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/sql-databases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/docs/tutorial/sql-databases.md b/docs/en/docs/tutorial/sql-databases.md index cfa1c9073..b42e9ba2f 100644 --- a/docs/en/docs/tutorial/sql-databases.md +++ b/docs/en/docs/tutorial/sql-databases.md @@ -65,7 +65,7 @@ There are a few differences: * `Field(primary_key=True)` tells SQLModel that the `id` is the **primary key** in the SQL database (you can learn more about SQL primary keys in the SQLModel docs). - By having the type as `int | None`, SQLModel will know that this column should be an `INTEGER` in the SQL database and that it should be `NULLABLE`. + **Note:** We use `int | None` for the primary key field so that in Python code we can *create an object without an `id`* (`id=None`), assuming the database will *generate it when saving*. SQLModel understands that the database will provide the `id` and *defines the column as a non-null `INTEGER`* in the database schema. See SQLModel docs on primary keys for details. * `Field(index=True)` tells SQLModel that it should create a **SQL index** for this column, that would allow faster lookups in the database when reading data filtered by this column. From 2ca7709c24358d5ebe61c5fc69235bc030b10128 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 05:07:29 +0000 Subject: [PATCH 19/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 6a8f009f8..be1f56a64 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -16,6 +16,7 @@ hide: ### Docs +* 📝 Update Primary Key notes for the SQL databases tutorial to avoid confusion. PR [#14120](https://github.com/fastapi/fastapi/pull/14120) by [@FlaviusRaducu](https://github.com/FlaviusRaducu). * 📝 Clarify estimation note in documentation. PR [#14070](https://github.com/fastapi/fastapi/pull/14070) by [@SaisakthiM](https://github.com/SaisakthiM). ## 0.123.1 From 00b97526e753614e762cad9128df5df48cb0fd54 Mon Sep 17 00:00:00 2001 From: zadevhub <138465437+zadevhub@users.noreply.github.com> Date: Tue, 2 Dec 2025 05:09:25 +0000 Subject: [PATCH 20/31] =?UTF-8?q?=F0=9F=93=9D=20Add=20tip=20on=20how=20to?= =?UTF-8?q?=20install=20`pip`=20in=20case=20of=20`No=20module=20named=20pi?= =?UTF-8?q?p`=20error=20in=20`virtual-environments.md`=20(#14211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/virtual-environments.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/en/docs/virtual-environments.md b/docs/en/docs/virtual-environments.md index e9b0a9fc4..c02e43ab9 100644 --- a/docs/en/docs/virtual-environments.md +++ b/docs/en/docs/virtual-environments.md @@ -242,6 +242,26 @@ $ python -m pip install --upgrade pip +/// tip + +Sometimes, you might get a **`No module named pip`** error when trying to upgrade pip. + +If this happens, install and upgrade pip using the command below: + +
+ +```console +$ python -m ensurepip --upgrade + +---> 100% +``` + +
+ +This command will install pip if it is not already installed and also ensures that the installed version of pip is at least as recent as the one available in `ensurepip`. + +/// + ## Add `.gitignore` { #add-gitignore } If you are using **Git** (you should), add a `.gitignore` file to exclude everything in your `.venv` from Git. From e1a2933739097f2e01d93acd752ba537ca7786cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 05:09:48 +0000 Subject: [PATCH 21/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index be1f56a64..200e8cc89 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -16,6 +16,7 @@ hide: ### Docs +* 📝 Add tip on how to install `pip` in case of `No module named pip` error in `virtual-environments.md`. PR [#14211](https://github.com/fastapi/fastapi/pull/14211) by [@zadevhub](https://github.com/zadevhub). * 📝 Update Primary Key notes for the SQL databases tutorial to avoid confusion. PR [#14120](https://github.com/fastapi/fastapi/pull/14120) by [@FlaviusRaducu](https://github.com/FlaviusRaducu). * 📝 Clarify estimation note in documentation. PR [#14070](https://github.com/fastapi/fastapi/pull/14070) by [@SaisakthiM](https://github.com/SaisakthiM). From 3c54a8f07b70cdd40e3d81ea319e9fcccd2481d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 2 Dec 2025 06:31:27 +0100 Subject: [PATCH 22/31] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.123.?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 200e8cc89..2e64f0c0a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.123.2 + ### Fixes * 🐛 Fix unformatted `{type_}` in FastAPIError. PR [#14416](https://github.com/fastapi/fastapi/pull/14416) by [@Just-Helpful](https://github.com/Just-Helpful). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 4f6982035..7a6ee14f2 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.123.1" +__version__ = "0.123.2" from starlette import status as status From 0f613d9051ce8a84b19c3786647a6d0cfc7973f6 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:10:27 +0100 Subject: [PATCH 23/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20optional=20sequence?= =?UTF-8?q?=20handling=20in=20`serialize=20sequence=20value`=20with=20Pyda?= =?UTF-8?q?ntic=20V2=20(#14297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- fastapi/_compat/v2.py | 7 +++++++ tests/test_compat.py | 24 ++++++++++++++++++++++++ tests/test_optional_file_list.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 tests/test_optional_file_list.py diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 7196a6190..3d91814c0 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -371,6 +371,13 @@ def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo: def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: origin_type = get_origin(field.field_info.annotation) or field.field_info.annotation + if origin_type is Union: # Handle optional sequences + union_args = get_args(field.field_info.annotation) + for union_arg in union_args: + if union_arg is type(None): + continue + origin_type = get_origin(union_arg) or union_arg + break assert issubclass(origin_type, shared.sequence_types) # type: ignore[arg-type] return shared.sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return] diff --git a/tests/test_compat.py b/tests/test_compat.py index 0184c9a2e..c3a97209a 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -136,6 +136,30 @@ def test_is_uploadfile_sequence_annotation(): assert is_uploadfile_sequence_annotation(Union[List[str], List[UploadFile]]) +@needs_pydanticv2 +def test_serialize_sequence_value_with_optional_list(): + """Test that serialize_sequence_value handles optional lists correctly.""" + from fastapi._compat import v2 + + field_info = FieldInfo(annotation=Union[List[str], None]) + field = v2.ModelField(name="items", field_info=field_info) + result = v2.serialize_sequence_value(field=field, value=["a", "b", "c"]) + assert result == ["a", "b", "c"] + assert isinstance(result, list) + + +@needs_pydanticv2 +def test_serialize_sequence_value_with_none_first_in_union(): + """Test that serialize_sequence_value handles Union[None, List[...]] correctly.""" + from fastapi._compat import v2 + + field_info = FieldInfo(annotation=Union[None, List[str]]) + field = v2.ModelField(name="items", field_info=field_info) + result = v2.serialize_sequence_value(field=field, value=["x", "y"]) + assert result == ["x", "y"] + assert isinstance(result, list) + + @needs_py_lt_314 def test_is_pv1_scalar_field(): from fastapi._compat import v1 diff --git a/tests/test_optional_file_list.py b/tests/test_optional_file_list.py new file mode 100644 index 000000000..0228900cf --- /dev/null +++ b/tests/test_optional_file_list.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from fastapi import FastAPI, File +from fastapi.testclient import TestClient + +app = FastAPI() + + +@app.post("/files") +async def upload_files(files: Optional[List[bytes]] = File(None)): + if files is None: + return {"files_count": 0} + return {"files_count": len(files), "sizes": [len(f) for f in files]} + + +def test_optional_bytes_list(): + client = TestClient(app) + response = client.post( + "/files", + files=[("files", b"content1"), ("files", b"content2")], + ) + assert response.status_code == 200 + assert response.json() == {"files_count": 2, "sizes": [8, 8]} + + +def test_optional_bytes_list_no_files(): + client = TestClient(app) + response = client.post("/files") + assert response.status_code == 200 + assert response.json() == {"files_count": 0} From eead41bf4cf8587b4d86091dd3eabbb85796e717 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 07:10:50 +0000 Subject: [PATCH 24/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 2e64f0c0a..c0090b49a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix optional sequence handling in `serialize sequence value` with Pydantic V2. PR [#14297](https://github.com/fastapi/fastapi/pull/14297) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.123.2 ### Fixes From 015b4fae9ce949ce46b285dabce7aa050b370eba Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:24:09 +0100 Subject: [PATCH 25/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Query\Header\Cookie?= =?UTF-8?q?=20parameter=20model=20alias=20(#14360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- fastapi/dependencies/utils.py | 3 +- tests/test_request_param_model_by_alias.py | 76 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/test_request_param_model_by_alias.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 20cb2c88c..ef3f56417 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -787,9 +787,8 @@ def request_params_to_args( ) value = _get_multidict_value(field, received_params, alias=alias) if value is not None: - params_to_process[field.name] = value + params_to_process[field.alias] = value processed_keys.add(alias or field.alias) - processed_keys.add(field.name) for key in received_params.keys(): if key not in processed_keys: diff --git a/tests/test_request_param_model_by_alias.py b/tests/test_request_param_model_by_alias.py new file mode 100644 index 000000000..a6f759f23 --- /dev/null +++ b/tests/test_request_param_model_by_alias.py @@ -0,0 +1,76 @@ +from dirty_equals import IsPartialDict +from fastapi import Cookie, FastAPI, Header, Query +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field + +app = FastAPI() + + +class Model(BaseModel): + param: str = Field(alias="param_alias") + + +@app.get("/query") +async def query_model(data: Model = Query()): + return {"param": data.param} + + +@app.get("/header") +async def header_model(data: Model = Header()): + return {"param": data.param} + + +@app.get("/cookie") +async def cookie_model(data: Model = Cookie()): + return {"param": data.param} + + +def test_query_model_with_alias(): + client = TestClient(app) + response = client.get("/query", params={"param_alias": "value"}) + assert response.status_code == 200, response.text + assert response.json() == {"param": "value"} + + +def test_header_model_with_alias(): + client = TestClient(app) + response = client.get("/header", headers={"param_alias": "value"}) + assert response.status_code == 200, response.text + assert response.json() == {"param": "value"} + + +def test_cookie_model_with_alias(): + client = TestClient(app) + client.cookies.set("param_alias", "value") + response = client.get("/cookie") + assert response.status_code == 200, response.text + assert response.json() == {"param": "value"} + + +def test_query_model_with_alias_by_name(): + client = TestClient(app) + response = client.get("/query", params={"param": "value"}) + assert response.status_code == 422, response.text + details = response.json() + if PYDANTIC_V2: + assert details["detail"][0]["input"] == {"param": "value"} + + +def test_header_model_with_alias_by_name(): + client = TestClient(app) + response = client.get("/header", headers={"param": "value"}) + assert response.status_code == 422, response.text + details = response.json() + if PYDANTIC_V2: + assert details["detail"][0]["input"] == IsPartialDict({"param": "value"}) + + +def test_cookie_model_with_alias_by_name(): + client = TestClient(app) + client.cookies.set("param", "value") + response = client.get("/cookie") + assert response.status_code == 422, response.text + details = response.json() + if PYDANTIC_V2: + assert details["detail"][0]["input"] == {"param": "value"} From b49c05ec22d949dac549314183994f71c0c8c21f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 07:24:31 +0000 Subject: [PATCH 26/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index c0090b49a..3a82da71d 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Fix Query\Header\Cookie parameter model alias. PR [#14360](https://github.com/fastapi/fastapi/pull/14360) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix optional sequence handling in `serialize sequence value` with Pydantic V2. PR [#14297](https://github.com/fastapi/fastapi/pull/14297) by [@YuriiMotov](https://github.com/YuriiMotov). ## 0.123.2 From c516c9904bfd07a5b48aaac205740bc20e8ec21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 2 Dec 2025 08:42:22 +0100 Subject: [PATCH 27/31] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.123.?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 3a82da71d..1d42e180a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.123.3 + ### Fixes * 🐛 Fix Query\Header\Cookie parameter model alias. PR [#14360](https://github.com/fastapi/fastapi/pull/14360) by [@YuriiMotov](https://github.com/YuriiMotov). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 7a6ee14f2..a15326cc2 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.123.2" +__version__ = "0.123.3" from starlette import status as status From dcf0299195c7182b0b0f9d07118a157a05490b8d Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:11:29 +0100 Subject: [PATCH 28/31] =?UTF-8?q?=F0=9F=93=9D=20Fix=20docstring=20of=20`se?= =?UTF-8?q?rvers`=20parameter=20(#14405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- docs/en/docs/advanced/behind-a-proxy.md | 8 ++++++++ fastapi/applications.py | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/advanced/behind-a-proxy.md b/docs/en/docs/advanced/behind-a-proxy.md index f692a28e8..f4dbd4560 100644 --- a/docs/en/docs/advanced/behind-a-proxy.md +++ b/docs/en/docs/advanced/behind-a-proxy.md @@ -443,6 +443,14 @@ The docs UI will interact with the server that you select. /// +/// note | Technical Details + +The `servers` property in the OpenAPI specification is optional. + +If you don't specify the `servers` parameter and `root_path` is equal to `/`, the `servers` property in the generated OpenAPI schema will be omitted entirely by default, which is the equivalent of a single server with a `url` value of `/`. + +/// + ### Disable automatic server from `root_path` { #disable-automatic-server-from-root-path } If you don't want **FastAPI** to include an automatic server using the `root_path`, you can use the parameter `root_path_in_servers=False`: diff --git a/fastapi/applications.py b/fastapi/applications.py index 0a47699ae..02193312b 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -301,7 +301,12 @@ class FastAPI(Starlette): browser tabs open). Or if you want to leave fixed the possible URLs. If the servers `list` is not provided, or is an empty `list`, the - default value would be a `dict` with a `url` value of `/`. + `servers` property in the generated OpenAPI will be: + + * a `dict` with a `url` value of the application's mounting point + (`root_path`) if it's different from `/`. + * otherwise, the `servers` property will be omitted from the OpenAPI + schema. Each item in the `list` is a `dict` containing: From 5126e099bdda51f261fd5a101b5f914991d31897 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 09:11:52 +0000 Subject: [PATCH 29/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 1d42e180a..4eb02ea42 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Docs + +* 📝 Fix docstring of `servers` parameter. PR [#14405](https://github.com/fastapi/fastapi/pull/14405) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.123.3 ### Fixes From f95a174288c07182905b6742b973e6e174dac598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Graf=C3=A9?= Date: Tue, 2 Dec 2025 01:22:08 -0800 Subject: [PATCH 30/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20OpenAPI=20schema=20s?= =?UTF-8?q?upport=20for=20computed=20fields=20when=20using=20`separate=5Fi?= =?UTF-8?q?nput=5Foutput=5Fschemas=3DFalse`=20(#13207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sofie Van Landeghem Co-authored-by: svlandeg Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: svlandeg Co-authored-by: Sebastián Ramírez --- fastapi/_compat/v2.py | 14 ++++++++++++-- tests/test_computed_fields.py | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 3d91814c0..543a42dda 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -180,8 +180,13 @@ def get_schema_from_model_field( ], separate_input_output_schemas: bool = True, ) -> Dict[str, Any]: + computed_fields = field._type_adapter.core_schema.get("schema", {}).get( + "computed_fields", [] + ) override_mode: Union[Literal["validation"], None] = ( - None if separate_input_output_schemas else "validation" + None + if (separate_input_output_schemas or len(computed_fields) > 0) + else "validation" ) # This expects that GenerateJsonSchema was already used to generate the definitions json_schema = field_mapping[(field, override_mode or field.mode)] @@ -203,9 +208,14 @@ def get_definitions( Dict[Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue], Dict[str, Dict[str, Any]], ]: + has_computed_fields: bool = any( + field._type_adapter.core_schema.get("schema", {}).get("computed_fields", []) + for field in fields + ) + schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) override_mode: Union[Literal["validation"], None] = ( - None if separate_input_output_schemas else "validation" + None if (separate_input_output_schemas or has_computed_fields) else "validation" ) validation_fields = [field for field in fields if field.mode == "validation"] serialization_fields = [field for field in fields if field.mode == "serialization"] diff --git a/tests/test_computed_fields.py b/tests/test_computed_fields.py index a1b412168..f2e42999b 100644 --- a/tests/test_computed_fields.py +++ b/tests/test_computed_fields.py @@ -6,8 +6,9 @@ from .utils import needs_pydanticv2 @pytest.fixture(name="client") -def get_client(): - app = FastAPI() +def get_client(request): + separate_input_output_schemas = request.param + app = FastAPI(separate_input_output_schemas=separate_input_output_schemas) from pydantic import BaseModel, computed_field @@ -32,6 +33,7 @@ def get_client(): return client +@pytest.mark.parametrize("client", [True, False], indirect=True) @pytest.mark.parametrize("path", ["/", "/responses"]) @needs_pydanticv2 def test_get(client: TestClient, path: str): @@ -40,6 +42,7 @@ def test_get(client: TestClient, path: str): assert response.json() == {"width": 3, "length": 4, "area": 12} +@pytest.mark.parametrize("client", [True, False], indirect=True) @needs_pydanticv2 def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") From fb30cc2f50a896175f705e365430004049af0fe9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 09:22:35 +0000 Subject: [PATCH 31/31] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 4eb02ea42..6040c4930 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix OpenAPI schema support for computed fields when using `separate_input_output_schemas=False`. PR [#13207](https://github.com/fastapi/fastapi/pull/13207) by [@vgrafe](https://github.com/vgrafe). + ### Docs * 📝 Fix docstring of `servers` parameter. PR [#14405](https://github.com/fastapi/fastapi/pull/14405) by [@YuriiMotov](https://github.com/YuriiMotov).