From 978351dbe6f1465bffd61b226bb1a4d05cc45e8c Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 30 Oct 2025 17:34:43 +0100 Subject: [PATCH 1/7] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 1b8d0d73f86578863dbc0b9ac61f1bff293f5a3e Author: sneakers-the-rat Date: Wed Aug 20 03:24:25 2025 -0700 ok but seriously commit d3ccab49485108978f4221584d73e591a972ef3d Author: sneakers-the-rat Date: Tue Aug 19 23:38:25 2025 -0700 rm being able to determine the input format of a model commit fec0a068edc153b46f9ce26bad9fd2b36449e345 Merge: 3f2e0f57 cad08bbc Author: sneakers-the-rat Date: Mon Apr 14 20:03:14 2025 -0700 Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults commit 3f2e0f572f2bb940ad3763ef71452ea2db31b718 Author: sneakers-the-rat Date: Mon Apr 14 20:01:50 2025 -0700 lint commit cad08bbc4d82450fd7730fe4252512c621457065 Author: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue Apr 15 02:47:42 2025 +0000 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks commit f63e983b60cfd63a49ba03f53a7695e92e53436f Author: sneakers-the-rat Date: Mon Apr 14 19:46:34 2025 -0700 docs for handling default values, pass field to validation context commit 529d486a7bbe0150ce813e15ec794a9f96fefc27 Merge: a9acab81 159824ea Author: sneakers-the-rat Date: Sat Mar 8 17:45:40 2025 -0800 Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults commit a9acab81c401706da73219fead0ecf1ebec6b76f Author: sneakers-the-rat Date: Sat Mar 8 17:42:38 2025 -0800 lint commit 159824ea935a9c74caae694d4b72aa9138cf32b8 Author: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun Mar 9 01:42:31 2025 +0000 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks commit e76184380dc5bd96dc3ee4a4405a30ddac2949d9 Author: sneakers-the-rat Date: Sat Mar 8 17:42:21 2025 -0800 pydantic v1 compat commit 64f25284e84d7297cd7d803d5bf765e888358ade Author: sneakers-the-rat Date: Sat Mar 8 17:38:03 2025 -0800 fix handling form data with fields that are not annotated as Form() commit 7fade13ac8696e529c070a42349951cdba8b4987 Author: sneakers-the-rat Date: Sat Mar 8 17:37:32 2025 -0800 fix just the extra values problem (again, purposefully with failing tests to demonstrate the problem, fixing in next commit) commit 49f6b8397d10dd0eef8501c1f239656c018629a6 Author: sneakers-the-rat Date: Sat Mar 8 17:36:03 2025 -0800 add failing tests for empty input values to get a CI run baseline for them commit 15eb6782dcbebff40c46ae97fc17784c5f47c698 Author: sneakers-the-rat Date: Thu Mar 6 19:35:53 2025 -0800 mypy lint commit 1a58af44df5946e0a59ef2767b85107c3fdfcd98 Author: sneakers-the-rat Date: Thu Mar 6 19:31:28 2025 -0800 finish pydantic 1 compat commit a2ad8b187f162145fdb266276e3c238eddaa7ff5 Author: sneakers-the-rat Date: Thu Mar 6 19:22:11 2025 -0800 python 3.8 and pydantic 1 compat commit 76c4d317fd4119282141db787f7cd82a417de4b5 Author: sneakers-the-rat Date: Thu Mar 6 19:06:44 2025 -0800 don't prefill defaults in form input --- docs/en/docs/tutorial/request-form-models.md | 54 +++ docs_src/request_form_models/tutorial003.py | 46 +++ .../tutorial003_an_py39.py | 48 +++ docs_src/request_form_models/tutorial004.py | 57 +++ .../tutorial004_an_py39.py | 57 +++ .../request_form_models/tutorial004_pv1.py | 57 +++ .../tutorial004_pv1_an_py39.py | 57 +++ fastapi/dependencies/utils.py | 18 +- tests/test_forms_defaults.py | 326 ++++++++++++++++++ tests/test_forms_single_model.py | 4 +- 10 files changed, 716 insertions(+), 8 deletions(-) create mode 100644 docs_src/request_form_models/tutorial003.py create mode 100644 docs_src/request_form_models/tutorial003_an_py39.py create mode 100644 docs_src/request_form_models/tutorial004.py create mode 100644 docs_src/request_form_models/tutorial004_an_py39.py create mode 100644 docs_src/request_form_models/tutorial004_pv1.py create mode 100644 docs_src/request_form_models/tutorial004_pv1_an_py39.py create mode 100644 tests/test_forms_defaults.py diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 68bdf198e..900cd1afc 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -73,6 +73,60 @@ They will receive an error response telling them that the field `extra` is not a } ``` +## Default Fields + +Form-encoded data has some quirks that can make working with pydantic models counterintuitive. + +Say, for example, you were generating an HTML form from a model, +and that model had a boolean field in it that you wanted to display as a checkbox +with a default `True` value: + +{* ../../docs_src/request_form_models/tutorial003_an_py39.py hl[11,10:23] *} + +This works as expected when the checkbox remains checked, +the form encoded data in the request looks like this: + +```formencoded +checkbox=on +``` + +and the JSON response is also correct: + +```json +{"checkbox":true} +``` + +When the checkbox is *unchecked*, though, something strange happens. +The submitted form data is *empty*, +and the returned JSON data still shows `checkbox` still being `true`! + +This is because checkboxes in HTML forms don't work exactly like the boolean inputs we expect, +when a checkbox is checked, if there is no `value` attribute, the value will be `"on"`, +and [the field will be omitted altogether if unchecked](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox). + +When dealing with form models with defaults, +we need to take special care to handle cases where the field being *unset* has a specific meaning. + +We also don't want to just treat any time the value is unset as ``False`` - +that would defeat the purpose of the default! +We want to specifically correct the behavior when it is used in the context of a *form.* + +In some cases, we can resolve the problem by changing or removing the default, +but we don't always have that option - +particularly when the model is used in other places than the form + +The recommended approach is to duplicate your model: + +/// note + +Take care to ensure that your duplicate models don't diverge, +e.g. if you are using sqlmodel, +where you may end up with `MyModel`, `MyModelCreate`, and `MyModelCreateForm`. + +/// + +{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[7,13:25] *} + ## Summary { #summary } You can use Pydantic models to declare form fields in FastAPI. 😎 diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py new file mode 100644 index 000000000..b0cbd6ff3 --- /dev/null +++ b/docs_src/request_form_models/tutorial003.py @@ -0,0 +1,46 @@ +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel + + +class MyModel(BaseModel): + checkbox: bool = True + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: MyModel = Form()) -> MyModel: + return data diff --git a/docs_src/request_form_models/tutorial003_an_py39.py b/docs_src/request_form_models/tutorial003_an_py39.py new file mode 100644 index 000000000..2c7f6a4b7 --- /dev/null +++ b/docs_src/request_form_models/tutorial003_an_py39.py @@ -0,0 +1,48 @@ +from typing import Annotated + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel + + +class MyModel(BaseModel): + checkbox: bool = True + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: + return data diff --git a/docs_src/request_form_models/tutorial004.py b/docs_src/request_form_models/tutorial004.py new file mode 100644 index 000000000..57ce0ccf8 --- /dev/null +++ b/docs_src/request_form_models/tutorial004.py @@ -0,0 +1,57 @@ +from typing import cast + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel, model_validator + + +class MyModel(BaseModel): + checkbox: bool = True + + +class MyModelForm(MyModel): + @model_validator(mode="before") + def handle_defaults(cls, value: dict) -> dict: + if "checkbox" not in value: + value["checkbox"] = False + return value + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: MyModelForm = Form()) -> MyModel: + data = cast(MyModel, data) + return data diff --git a/docs_src/request_form_models/tutorial004_an_py39.py b/docs_src/request_form_models/tutorial004_an_py39.py new file mode 100644 index 000000000..42a65ece2 --- /dev/null +++ b/docs_src/request_form_models/tutorial004_an_py39.py @@ -0,0 +1,57 @@ +from typing import Annotated, cast + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel, ValidationInfo, model_validator + + +class MyModel(BaseModel): + checkbox: bool = True + + +class MyModelForm(MyModel): + @model_validator(mode="before") + def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: + if "checkbox" not in value: + value["checkbox"] = False + return value + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel: + data = cast(MyModel, data) + return data diff --git a/docs_src/request_form_models/tutorial004_pv1.py b/docs_src/request_form_models/tutorial004_pv1.py new file mode 100644 index 000000000..69f28aab7 --- /dev/null +++ b/docs_src/request_form_models/tutorial004_pv1.py @@ -0,0 +1,57 @@ +from typing import cast + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel, root_validator + + +class MyModel(BaseModel): + checkbox: bool = True + + +class MyModelForm(BaseModel): + @root_validator(pre=True) + def handle_defaults(cls, value: dict) -> dict: + if "checkbox" not in value: + value["checkbox"] = False + return value + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: MyModelForm = Form()) -> MyModel: + data = cast(MyModel, data) + return data diff --git a/docs_src/request_form_models/tutorial004_pv1_an_py39.py b/docs_src/request_form_models/tutorial004_pv1_an_py39.py new file mode 100644 index 000000000..28bbc7b32 --- /dev/null +++ b/docs_src/request_form_models/tutorial004_pv1_an_py39.py @@ -0,0 +1,57 @@ +from typing import Annotated, cast + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment +from pydantic import BaseModel, root_validator + + +class MyModel(BaseModel): + checkbox: bool = True + + +class MyModelForm(MyModel): + @root_validator(pre=True) + def handle_defaults(cls, value: dict) -> dict: + if "checkbox" not in value: + value["checkbox"] = False + return value + + +form_template = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +loader = DictLoader({"form.html": form_template}) +templates = Jinja2Templates(env=Environment(loader=loader)) + +app = FastAPI() + + +@app.get("/form", response_class=HTMLResponse) +async def show_form(request: Request): + return templates.TemplateResponse( + request=request, name="form.html", context={"model": MyModel} + ) + + +@app.post("/form") +async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel: + data = cast(MyModel, data) + return data diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 081b63a8b..b0ecd1cd1 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -714,7 +714,10 @@ def _validate_value_with_model_field( def _get_multidict_value( - field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None + field: ModelField, + values: Mapping[str, Any], + alias: Union[str, None] = None, + form_input: bool = False, ) -> Any: alias = alias or field.alias if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): @@ -724,14 +727,14 @@ def _get_multidict_value( if ( value is None or ( - isinstance(field.field_info, params.Form) + (isinstance(field.field_info, params.Form) or form_input) and isinstance(value, str) # For type checks and value == "" ) or (is_sequence_field(field) and len(value) == 0) ): - if field.required: - return + if form_input or field.required: + return None else: return deepcopy(field.default) return value @@ -864,11 +867,12 @@ async def _extract_form_body( received_body: FormData, ) -> Dict[str, Any]: values = {} + field_aliases = {field.alias for field in body_fields} first_field = body_fields[0] first_field_info = first_field.field_info for field in body_fields: - value = _get_multidict_value(field, received_body) + value = _get_multidict_value(field, received_body, form_input=True) if ( isinstance(first_field_info, params.File) and is_bytes_field(field) @@ -896,8 +900,10 @@ async def _extract_form_body( value = serialize_sequence_value(field=field, value=results) if value is not None: values[field.alias] = value + + # preserve extra keys not in model body fields for validation 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_forms_defaults.py b/tests/test_forms_defaults.py new file mode 100644 index 000000000..8a4dfc89b --- /dev/null +++ b/tests/test_forms_defaults.py @@ -0,0 +1,326 @@ +from typing import Optional + +import pytest +from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 +from pydantic import BaseModel, Field +from starlette.testclient import TestClient +from typing_extensions import Annotated + +from .utils import needs_pydanticv2 + +if PYDANTIC_V2: + from pydantic import model_validator +else: + from pydantic import root_validator + + +def _validate_input(value: dict) -> dict: + """ + model validators in before mode should receive values passed + to model instantiation before any further validation + """ + # we should not be double-instantiating the models + assert isinstance(value, dict) + value["init_input"] = value.copy() + + # differentiate between explicit Nones and unpassed values + if "true_if_unset" not in value: + value["true_if_unset"] = True + return value + + +class Parent(BaseModel): + init_input: dict + # importantly, no default here + + if PYDANTIC_V2: + + @model_validator(mode="before") + def validate_inputs(cls, value: dict) -> dict: + return _validate_input(value) + else: + + @root_validator(pre=True) + def validate_inputs(cls, value: dict) -> dict: + return _validate_input(value) + + +class StandardModel(Parent): + default_true: bool = True + default_false: bool = False + default_none: Optional[bool] = None + default_zero: int = 0 + default_str: str = "foo" + true_if_unset: Optional[bool] = None + + +class FieldModel(Parent): + default_true: bool = Field(default=True) + default_false: bool = Field(default=False) + default_none: Optional[bool] = Field(default=None) + default_zero: int = Field(default=0) + default_str: str = Field(default="foo") + true_if_unset: Optional[bool] = Field(default=None) + + +if PYDANTIC_V2: + + class AnnotatedFieldModel(Parent): + default_true: Annotated[bool, Field(default=True)] + default_false: Annotated[bool, Field(default=False)] + default_none: Annotated[Optional[bool], Field(default=None)] + default_zero: Annotated[int, Field(default=0)] + default_str: Annotated[str, Field(default="foo")] + true_if_unset: Annotated[Optional[bool], Field(default=None)] + + class AnnotatedFormModel(Parent): + default_true: Annotated[bool, Form(default=True)] + default_false: Annotated[bool, Form(default=False)] + default_none: Annotated[Optional[bool], Form(default=None)] + default_zero: Annotated[int, Form(default=0)] + default_str: Annotated[str, Form(default="foo")] + true_if_unset: Annotated[Optional[bool], Form(default=None)] + + class SimpleForm(BaseModel): + """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" + + foo: Annotated[str, Form(default="bar")] + alias_with: Annotated[str, Form(alias="with", default="nothing")] + + +class ResponseModel(BaseModel): + fields_set: list = Field(default_factory=list) + dumped_fields_no_exclude: dict = Field(default_factory=dict) + dumped_fields_exclude_default: dict = Field(default_factory=dict) + dumped_fields_exclude_unset: dict = Field(default_factory=dict) + dumped_fields_no_meta: dict = Field(default_factory=dict) + init_input: dict + + @classmethod + def from_value(cls, value: Parent) -> "ResponseModel": + if PYDANTIC_V2: + return ResponseModel( + init_input=value.init_input, + fields_set=list(value.model_fields_set), + dumped_fields_no_exclude=value.model_dump(), + dumped_fields_exclude_default=value.model_dump(exclude_defaults=True), + dumped_fields_exclude_unset=value.model_dump(exclude_unset=True), + dumped_fields_no_meta=value.model_dump( + exclude={"init_input", "fields_set"} + ), + ) + else: + return ResponseModel( + init_input=value.init_input, + fields_set=list(value.__fields_set__), + dumped_fields_no_exclude=value.dict(), + dumped_fields_exclude_default=value.dict(exclude_defaults=True), + dumped_fields_exclude_unset=value.dict(exclude_unset=True), + dumped_fields_no_meta=value.dict(exclude={"init_input", "fields_set"}), + ) + + +app = FastAPI() + + +@app.post("/form/standard") +async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel: + return ResponseModel.from_value(value) + + +@app.post("/form/field") +async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel: + return ResponseModel.from_value(value) + + +if PYDANTIC_V2: + + @app.post("/form/annotated-field") + async def form_annotated_field( + value: Annotated[AnnotatedFieldModel, Form()], + ) -> ResponseModel: + return ResponseModel.from_value(value) + + @app.post("/form/annotated-form") + async def form_annotated_form( + value: Annotated[AnnotatedFormModel, Form()], + ) -> ResponseModel: + return ResponseModel.from_value(value) + + +@app.post("/form/inlined") +async def form_inlined( + default_true: Annotated[bool, Form()] = True, + default_false: Annotated[bool, Form()] = False, + default_none: Annotated[Optional[bool], Form()] = None, + default_zero: Annotated[int, Form()] = 0, + default_str: Annotated[str, Form()] = "foo", + true_if_unset: Annotated[Optional[bool], Form()] = None, +): + """ + Rather than using a model, inline the fields in the endpoint. + + This doesn't use the `ResponseModel` pattern, since that is just to + test the instantiation behavior prior to the endpoint function. + Since we are receiving the values of the fields here (and thus, + having the defaults is correct behavior), we just return the values. + """ + if true_if_unset is None: + true_if_unset = True + + return { + "default_true": default_true, + "default_false": default_false, + "default_none": default_none, + "default_zero": default_zero, + "default_str": default_str, + "true_if_unset": true_if_unset, + } + + +@app.post("/json/standard") +async def json_standard(value: StandardModel) -> ResponseModel: + return ResponseModel.from_value(value) + + +@app.post("/json/field") +async def json_field(value: FieldModel) -> ResponseModel: + return ResponseModel.from_value(value) + + +if PYDANTIC_V2: + + @app.post("/json/annotated-field") + async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel: + return ResponseModel.from_value(value) + + @app.post("/json/annotated-form") + async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel: + return ResponseModel.from_value(value) + + @app.post("/simple-form") + def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict: + """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" + return model.model_dump() + + +if PYDANTIC_V2: + MODEL_TYPES = { + "standard": StandardModel, + "field": FieldModel, + "annotated-field": AnnotatedFieldModel, + "annotated-form": AnnotatedFormModel, + } +else: + MODEL_TYPES = { + "standard": StandardModel, + "field": FieldModel, + } +ENCODINGS = ("form", "json") + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as test_client: + yield test_client + + +@pytest.mark.parametrize("encoding", ENCODINGS) +@pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) +def test_no_prefill_defaults_all_unset(encoding, model_type, client): + """ + When the model is instantiated by the server, it should not have its defaults prefilled + """ + + endpoint = f"/{encoding}/{model_type}" + if encoding == "form": + res = client.post(endpoint, data={}) + else: + res = client.post(endpoint, json={}) + + assert res.status_code == 200 + response_model = ResponseModel(**res.json()) + assert response_model.init_input == {} + assert len(response_model.fields_set) == 2 + assert response_model.dumped_fields_no_exclude["true_if_unset"] is True + + +@pytest.mark.parametrize("encoding", ENCODINGS) +@pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) +def test_no_prefill_defaults_partially_set(encoding, model_type, client): + """ + When the model is instantiated by the server, it should not have its defaults prefilled, + and pydantic should be able to differentiate between unset and default values when some are passed + """ + endpoint = f"/{encoding}/{model_type}" + if encoding == "form": + data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"} + res = client.post(endpoint, data=data) + else: + data = {"true_if_unset": False, "default_false": True, "default_zero": 0} + res = client.post(endpoint, json=data) + + if PYDANTIC_V2: + dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump( + exclude_unset=True + ) + dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump( + exclude_defaults=True + ) + else: + dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True) + dumped_exclude_default = MODEL_TYPES[model_type](**data).dict( + exclude_defaults=True + ) + + assert res.status_code == 200 + response_model = ResponseModel(**res.json()) + assert response_model.init_input == data + assert len(response_model.fields_set) == 4 + assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset + assert response_model.dumped_fields_no_exclude["true_if_unset"] is False + assert "default_zero" not in dumped_exclude_default + assert "default_zero" not in response_model.dumped_fields_exclude_default + + +@needs_pydanticv2 +def test_casted_empty_defaults(client: TestClient): + """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" + form_content = {"foo": "", "with": ""} + response = client.post("/simple-form", data=form_content) + response_content = response.json() + assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :'' + assert response_content["alias_with"] == "nothing" # ok + + +@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"]) +def test_empty_string_inputs(model_type, client): + """ + Form inputs with no input are empty strings, + these should be treated as being unset. + """ + data = { + "default_true": "", + "default_false": "", + "default_none": "", + "default_str": "", + "default_zero": "", + "true_if_unset": "", + } + response = client.post(f"/form/{model_type}", data=data) + assert response.status_code == 200 + if model_type != "inlined": + response_model = ResponseModel(**response.json()) + assert set(response_model.fields_set) == {"true_if_unset", "init_input"} + response_data = response_model.dumped_fields_no_meta + else: + response_data = response.json() + assert response_data == { + "default_true": True, + "default_false": False, + "default_none": None, + "default_zero": 0, + "default_str": "foo", + "true_if_unset": True, + } diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py index 880ab3820..c57ee973a 100644 --- a/tests/test_forms_single_model.py +++ b/tests/test_forms_single_model.py @@ -104,13 +104,13 @@ def test_no_data(): "type": "missing", "loc": ["body", "username"], "msg": "Field required", - "input": {"tags": ["foo", "bar"], "with": "nothing"}, + "input": {}, }, { "type": "missing", "loc": ["body", "lastname"], "msg": "Field required", - "input": {"tags": ["foo", "bar"], "with": "nothing"}, + "input": {}, }, ] } From 2ceaa96981f42cf01a06598b870e4ef206361f9b Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 30 Oct 2025 21:46:42 +0100 Subject: [PATCH 2/7] Reverted the changes in docs unrelated to the main idea of PR --- docs/en/docs/tutorial/request-form-models.md | 54 ------------------ docs_src/request_form_models/tutorial003.py | 46 --------------- .../tutorial003_an_py39.py | 48 ---------------- docs_src/request_form_models/tutorial004.py | 57 ------------------- .../tutorial004_an_py39.py | 57 ------------------- .../request_form_models/tutorial004_pv1.py | 57 ------------------- .../tutorial004_pv1_an_py39.py | 57 ------------------- 7 files changed, 376 deletions(-) delete mode 100644 docs_src/request_form_models/tutorial003.py delete mode 100644 docs_src/request_form_models/tutorial003_an_py39.py delete mode 100644 docs_src/request_form_models/tutorial004.py delete mode 100644 docs_src/request_form_models/tutorial004_an_py39.py delete mode 100644 docs_src/request_form_models/tutorial004_pv1.py delete mode 100644 docs_src/request_form_models/tutorial004_pv1_an_py39.py diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 900cd1afc..68bdf198e 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -73,60 +73,6 @@ They will receive an error response telling them that the field `extra` is not a } ``` -## Default Fields - -Form-encoded data has some quirks that can make working with pydantic models counterintuitive. - -Say, for example, you were generating an HTML form from a model, -and that model had a boolean field in it that you wanted to display as a checkbox -with a default `True` value: - -{* ../../docs_src/request_form_models/tutorial003_an_py39.py hl[11,10:23] *} - -This works as expected when the checkbox remains checked, -the form encoded data in the request looks like this: - -```formencoded -checkbox=on -``` - -and the JSON response is also correct: - -```json -{"checkbox":true} -``` - -When the checkbox is *unchecked*, though, something strange happens. -The submitted form data is *empty*, -and the returned JSON data still shows `checkbox` still being `true`! - -This is because checkboxes in HTML forms don't work exactly like the boolean inputs we expect, -when a checkbox is checked, if there is no `value` attribute, the value will be `"on"`, -and [the field will be omitted altogether if unchecked](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox). - -When dealing with form models with defaults, -we need to take special care to handle cases where the field being *unset* has a specific meaning. - -We also don't want to just treat any time the value is unset as ``False`` - -that would defeat the purpose of the default! -We want to specifically correct the behavior when it is used in the context of a *form.* - -In some cases, we can resolve the problem by changing or removing the default, -but we don't always have that option - -particularly when the model is used in other places than the form - -The recommended approach is to duplicate your model: - -/// note - -Take care to ensure that your duplicate models don't diverge, -e.g. if you are using sqlmodel, -where you may end up with `MyModel`, `MyModelCreate`, and `MyModelCreateForm`. - -/// - -{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[7,13:25] *} - ## Summary { #summary } You can use Pydantic models to declare form fields in FastAPI. 😎 diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py deleted file mode 100644 index b0cbd6ff3..000000000 --- a/docs_src/request_form_models/tutorial003.py +++ /dev/null @@ -1,46 +0,0 @@ -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel - - -class MyModel(BaseModel): - checkbox: bool = True - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: MyModel = Form()) -> MyModel: - return data diff --git a/docs_src/request_form_models/tutorial003_an_py39.py b/docs_src/request_form_models/tutorial003_an_py39.py deleted file mode 100644 index 2c7f6a4b7..000000000 --- a/docs_src/request_form_models/tutorial003_an_py39.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Annotated - -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel - - -class MyModel(BaseModel): - checkbox: bool = True - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: - return data diff --git a/docs_src/request_form_models/tutorial004.py b/docs_src/request_form_models/tutorial004.py deleted file mode 100644 index 57ce0ccf8..000000000 --- a/docs_src/request_form_models/tutorial004.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import cast - -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel, model_validator - - -class MyModel(BaseModel): - checkbox: bool = True - - -class MyModelForm(MyModel): - @model_validator(mode="before") - def handle_defaults(cls, value: dict) -> dict: - if "checkbox" not in value: - value["checkbox"] = False - return value - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: MyModelForm = Form()) -> MyModel: - data = cast(MyModel, data) - return data diff --git a/docs_src/request_form_models/tutorial004_an_py39.py b/docs_src/request_form_models/tutorial004_an_py39.py deleted file mode 100644 index 42a65ece2..000000000 --- a/docs_src/request_form_models/tutorial004_an_py39.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Annotated, cast - -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel, ValidationInfo, model_validator - - -class MyModel(BaseModel): - checkbox: bool = True - - -class MyModelForm(MyModel): - @model_validator(mode="before") - def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: - if "checkbox" not in value: - value["checkbox"] = False - return value - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel: - data = cast(MyModel, data) - return data diff --git a/docs_src/request_form_models/tutorial004_pv1.py b/docs_src/request_form_models/tutorial004_pv1.py deleted file mode 100644 index 69f28aab7..000000000 --- a/docs_src/request_form_models/tutorial004_pv1.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import cast - -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel, root_validator - - -class MyModel(BaseModel): - checkbox: bool = True - - -class MyModelForm(BaseModel): - @root_validator(pre=True) - def handle_defaults(cls, value: dict) -> dict: - if "checkbox" not in value: - value["checkbox"] = False - return value - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: MyModelForm = Form()) -> MyModel: - data = cast(MyModel, data) - return data diff --git a/docs_src/request_form_models/tutorial004_pv1_an_py39.py b/docs_src/request_form_models/tutorial004_pv1_an_py39.py deleted file mode 100644 index 28bbc7b32..000000000 --- a/docs_src/request_form_models/tutorial004_pv1_an_py39.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Annotated, cast - -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import DictLoader, Environment -from pydantic import BaseModel, root_validator - - -class MyModel(BaseModel): - checkbox: bool = True - - -class MyModelForm(MyModel): - @root_validator(pre=True) - def handle_defaults(cls, value: dict) -> dict: - if "checkbox" not in value: - value["checkbox"] = False - return value - - -form_template = """ -
-{% for field_name, field in model.model_fields.items() %} -

- - {% if field.annotation.__name__ == "bool" %} - - {% else %} - - {% endif %} -

-{% endfor %} - -
-""" -loader = DictLoader({"form.html": form_template}) -templates = Jinja2Templates(env=Environment(loader=loader)) - -app = FastAPI() - - -@app.get("/form", response_class=HTMLResponse) -async def show_form(request: Request): - return templates.TemplateResponse( - request=request, name="form.html", context={"model": MyModel} - ) - - -@app.post("/form") -async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel: - data = cast(MyModel, data) - return data From 218d06c765f6645b3c889cca3b3916d5d5c37af7 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 31 Oct 2025 14:16:54 +0100 Subject: [PATCH 3/7] Refactor `_get_multidict_value` for readability --- fastapi/dependencies/utils.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 1ea74cc83..26d4cefc9 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -731,24 +731,24 @@ def _get_multidict_value( value = values.getlist(alias) else: value = values.get(alias, None) - if ( - value is None - or ( - ( - isinstance( - field.field_info, - (params.Form, temp_pydantic_v1_params.Form) - ) or form_input - ) - and isinstance(value, str) # For type checks - and value == "" - ) - or (is_sequence_field(field) and len(value) == 0) - ): - if form_input or field.required: + + if form_input: + # Special handling for form inputs: + # Treat empty strings or empty lists as missing values + if ( + (isinstance(value, str) and value == "") + or (is_sequence_field(field) and len(value) == 0) + ): return None - else: - return deepcopy(field.default) + else: + # For non-form inputs: + # If value is None or an empty sequence, use the default (if not required) or + # treat as missing (if required) + if value is None or (is_sequence_field(field) and len(value) == 0): + if field.required: + return None + else: + return deepcopy(field.default) return value From 265a0fec60fed61722a8b05544dba429444e520f Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 31 Oct 2025 14:38:06 +0100 Subject: [PATCH 4/7] Refactor and clarify comment in `_extract_form_body` --- fastapi/dependencies/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 26d4cefc9..dcb2cfe62 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -879,7 +879,6 @@ async def _extract_form_body( received_body: FormData, ) -> Dict[str, Any]: values = {} - field_aliases = {field.alias for field in body_fields} for field in body_fields: value = _get_multidict_value(field, received_body, form_input=True) @@ -912,7 +911,8 @@ async def _extract_form_body( if value is not None: values[field.alias] = value - # preserve extra keys not in model body fields for validation + # Include extra form fields (not defined as body parameters, but received in body) + field_aliases = {field.alias for field in body_fields} for key, value in received_body.items(): if key not in field_aliases: values[key] = value From 8033b02f645b07b2affdd4b334f1dfe52dceafb6 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 2 Nov 2025 09:52:35 +0100 Subject: [PATCH 5/7] Format --- fastapi/dependencies/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dcb2cfe62..0d734a267 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -735,9 +735,8 @@ def _get_multidict_value( if form_input: # Special handling for form inputs: # Treat empty strings or empty lists as missing values - if ( - (isinstance(value, str) and value == "") - or (is_sequence_field(field) and len(value) == 0) + if (isinstance(value, str) and value == "") or ( + is_sequence_field(field) and len(value) == 0 ): return None else: From 4c68df1804d874508d4043b8efd5d70ca0ceff3f Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 2 Nov 2025 09:52:50 +0100 Subject: [PATCH 6/7] Update tests --- tests/test_form_default.py | 193 +++++++++++++++++++++ tests/test_forms_defaults.py | 326 ----------------------------------- 2 files changed, 193 insertions(+), 326 deletions(-) create mode 100644 tests/test_form_default.py delete mode 100644 tests/test_forms_defaults.py diff --git a/tests/test_form_default.py b/tests/test_form_default.py new file mode 100644 index 000000000..7cad33a87 --- /dev/null +++ b/tests/test_form_default.py @@ -0,0 +1,193 @@ +from typing import Any, List, Optional + +import pytest +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from .utils import needs_pydanticv2 + + +class FormParams(BaseModel): + param_1: str = "default" + param_2: Annotated[str, Field(alias="param_2_alias")] = "default" + param_3: Annotated[str, Form(alias="param_3_alias")] = "default" + upload_file: Optional[UploadFile] = None + file: Annotated[Optional[bytes], File()] = None + + +app = FastAPI() + + +@app.post("/form-param") +def form_param( + param_1: Annotated[str, Form()] = "default", + param_2: Annotated[str, Form(alias="param_2_alias")] = "default", + upload_file: Optional[UploadFile] = None, + file: Annotated[Optional[bytes], File()] = None, +): + return { + "param_1": param_1, + "param_2": param_2, + "upload_file": upload_file, + "file": file, + } + + +@app.post("/form-model") +def form_model(params: Annotated[FormParams, Form()]): + return { + "param_1": params.param_1, + "param_2": params.param_2, + "param_3": params.param_3, + "upload_file": params.upload_file, + "file": params.file, + } + + +@app.post("/form-param-list-with-default") +def form_param_list_with_default( + params: Annotated[List[str], Form(default_factory=list)], +): + return {"params": params} + + +@app.post("/form-param-optional-list") +def form_param_optional_list( + params: Annotated[Optional[List[str]], Form()] = None, +): + return {"params": params} + + +@app.post("/form-model-fields-set") +def form_model_fields_set(params: Annotated[FormParams, Form()]): + return {"fields_set": params.model_fields_set} + + +# ==================================================================================== +# Tests + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as test_client: + yield test_client + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + {"param_1": "", "param_2_alias": "", "upload_file": "", "file": ""}, + id="empty_strings_sent", + ), + ], +) +def test_defaults_form_param(client: TestClient, data: dict): + """ + Empty string or no input data - default value is used. + For parameters declared as single Form fields. + """ + response = client.post("/form-param", data=data) + assert response.json() == { + "param_1": "default", + "param_2": "default", + "upload_file": None, + "file": None, + } + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + { + "param_1": "", + "param_2_alias": "", + "param_3_alias": "", + "upload_file": "", + "file": "", + }, + id="empty_strings_sent", + ), + ], +) +def test_defaults_form_model(client: TestClient, data: dict): + """ + Empty string or no data - default value is used. + For parameters declared as Form model. + """ + response = client.post("/form-model", data=data) + assert response.json() == { + "param_1": "default", + "param_2": "default", + "param_3": "default", + "upload_file": None, + "file": None, + } + + +@pytest.mark.parametrize( + ("url", "expected_res"), + [ + ("/form-param-list-with-default", []), + ("/form-param-optional-list", None), + ], +) +def test_form_list_param_no_input(client: TestClient, url: str, expected_res: Any): + """ + No input data passed to list parameter - default value is used. + """ + response = client.post(url) + assert response.json() == {"params": expected_res} + + +@pytest.mark.parametrize( + ("data", "expected_res"), + [ + ({"params": ""}, [""]), + ({"params": [""]}, [""]), + ({"params": ["", "a"]}, ["", "a"]), + ({"params": ["a", ""]}, ["a", ""]), + ], +) +@pytest.mark.parametrize( + "url", ["/form-param-list-with-default", "/form-param-optional-list"] +) +def test_form_list_param_empty_str( + client: TestClient, url: str, data: dict, expected_res: Any +): + """ + Empty strings passed to list parameter treated as empty strings. + """ + response = client.post(url, data=data) + assert response.json() == {"params": expected_res} + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + { + "param_1": "", + "param_2_alias": "", + "param_3_alias": "", + "upload_file": "", + "file": "", + }, + id="empty_strings_sent", + ), + ], +) +@needs_pydanticv2 +def test_form_model_fields_set(client: TestClient, data: dict): + """ + Check that fields are not pre-populated with default values when no data sent or + empty string sent. + """ + response = client.post("/form-model-fields-set", data=data) + assert response.json() == {"fields_set": []} diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py deleted file mode 100644 index 8a4dfc89b..000000000 --- a/tests/test_forms_defaults.py +++ /dev/null @@ -1,326 +0,0 @@ -from typing import Optional - -import pytest -from fastapi import FastAPI, Form -from fastapi._compat import PYDANTIC_V2 -from pydantic import BaseModel, Field -from starlette.testclient import TestClient -from typing_extensions import Annotated - -from .utils import needs_pydanticv2 - -if PYDANTIC_V2: - from pydantic import model_validator -else: - from pydantic import root_validator - - -def _validate_input(value: dict) -> dict: - """ - model validators in before mode should receive values passed - to model instantiation before any further validation - """ - # we should not be double-instantiating the models - assert isinstance(value, dict) - value["init_input"] = value.copy() - - # differentiate between explicit Nones and unpassed values - if "true_if_unset" not in value: - value["true_if_unset"] = True - return value - - -class Parent(BaseModel): - init_input: dict - # importantly, no default here - - if PYDANTIC_V2: - - @model_validator(mode="before") - def validate_inputs(cls, value: dict) -> dict: - return _validate_input(value) - else: - - @root_validator(pre=True) - def validate_inputs(cls, value: dict) -> dict: - return _validate_input(value) - - -class StandardModel(Parent): - default_true: bool = True - default_false: bool = False - default_none: Optional[bool] = None - default_zero: int = 0 - default_str: str = "foo" - true_if_unset: Optional[bool] = None - - -class FieldModel(Parent): - default_true: bool = Field(default=True) - default_false: bool = Field(default=False) - default_none: Optional[bool] = Field(default=None) - default_zero: int = Field(default=0) - default_str: str = Field(default="foo") - true_if_unset: Optional[bool] = Field(default=None) - - -if PYDANTIC_V2: - - class AnnotatedFieldModel(Parent): - default_true: Annotated[bool, Field(default=True)] - default_false: Annotated[bool, Field(default=False)] - default_none: Annotated[Optional[bool], Field(default=None)] - default_zero: Annotated[int, Field(default=0)] - default_str: Annotated[str, Field(default="foo")] - true_if_unset: Annotated[Optional[bool], Field(default=None)] - - class AnnotatedFormModel(Parent): - default_true: Annotated[bool, Form(default=True)] - default_false: Annotated[bool, Form(default=False)] - default_none: Annotated[Optional[bool], Form(default=None)] - default_zero: Annotated[int, Form(default=0)] - default_str: Annotated[str, Form(default="foo")] - true_if_unset: Annotated[Optional[bool], Form(default=None)] - - class SimpleForm(BaseModel): - """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" - - foo: Annotated[str, Form(default="bar")] - alias_with: Annotated[str, Form(alias="with", default="nothing")] - - -class ResponseModel(BaseModel): - fields_set: list = Field(default_factory=list) - dumped_fields_no_exclude: dict = Field(default_factory=dict) - dumped_fields_exclude_default: dict = Field(default_factory=dict) - dumped_fields_exclude_unset: dict = Field(default_factory=dict) - dumped_fields_no_meta: dict = Field(default_factory=dict) - init_input: dict - - @classmethod - def from_value(cls, value: Parent) -> "ResponseModel": - if PYDANTIC_V2: - return ResponseModel( - init_input=value.init_input, - fields_set=list(value.model_fields_set), - dumped_fields_no_exclude=value.model_dump(), - dumped_fields_exclude_default=value.model_dump(exclude_defaults=True), - dumped_fields_exclude_unset=value.model_dump(exclude_unset=True), - dumped_fields_no_meta=value.model_dump( - exclude={"init_input", "fields_set"} - ), - ) - else: - return ResponseModel( - init_input=value.init_input, - fields_set=list(value.__fields_set__), - dumped_fields_no_exclude=value.dict(), - dumped_fields_exclude_default=value.dict(exclude_defaults=True), - dumped_fields_exclude_unset=value.dict(exclude_unset=True), - dumped_fields_no_meta=value.dict(exclude={"init_input", "fields_set"}), - ) - - -app = FastAPI() - - -@app.post("/form/standard") -async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel: - return ResponseModel.from_value(value) - - -@app.post("/form/field") -async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel: - return ResponseModel.from_value(value) - - -if PYDANTIC_V2: - - @app.post("/form/annotated-field") - async def form_annotated_field( - value: Annotated[AnnotatedFieldModel, Form()], - ) -> ResponseModel: - return ResponseModel.from_value(value) - - @app.post("/form/annotated-form") - async def form_annotated_form( - value: Annotated[AnnotatedFormModel, Form()], - ) -> ResponseModel: - return ResponseModel.from_value(value) - - -@app.post("/form/inlined") -async def form_inlined( - default_true: Annotated[bool, Form()] = True, - default_false: Annotated[bool, Form()] = False, - default_none: Annotated[Optional[bool], Form()] = None, - default_zero: Annotated[int, Form()] = 0, - default_str: Annotated[str, Form()] = "foo", - true_if_unset: Annotated[Optional[bool], Form()] = None, -): - """ - Rather than using a model, inline the fields in the endpoint. - - This doesn't use the `ResponseModel` pattern, since that is just to - test the instantiation behavior prior to the endpoint function. - Since we are receiving the values of the fields here (and thus, - having the defaults is correct behavior), we just return the values. - """ - if true_if_unset is None: - true_if_unset = True - - return { - "default_true": default_true, - "default_false": default_false, - "default_none": default_none, - "default_zero": default_zero, - "default_str": default_str, - "true_if_unset": true_if_unset, - } - - -@app.post("/json/standard") -async def json_standard(value: StandardModel) -> ResponseModel: - return ResponseModel.from_value(value) - - -@app.post("/json/field") -async def json_field(value: FieldModel) -> ResponseModel: - return ResponseModel.from_value(value) - - -if PYDANTIC_V2: - - @app.post("/json/annotated-field") - async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel: - return ResponseModel.from_value(value) - - @app.post("/json/annotated-form") - async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel: - return ResponseModel.from_value(value) - - @app.post("/simple-form") - def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict: - """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" - return model.model_dump() - - -if PYDANTIC_V2: - MODEL_TYPES = { - "standard": StandardModel, - "field": FieldModel, - "annotated-field": AnnotatedFieldModel, - "annotated-form": AnnotatedFormModel, - } -else: - MODEL_TYPES = { - "standard": StandardModel, - "field": FieldModel, - } -ENCODINGS = ("form", "json") - - -@pytest.fixture(scope="module") -def client() -> TestClient: - with TestClient(app) as test_client: - yield test_client - - -@pytest.mark.parametrize("encoding", ENCODINGS) -@pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) -def test_no_prefill_defaults_all_unset(encoding, model_type, client): - """ - When the model is instantiated by the server, it should not have its defaults prefilled - """ - - endpoint = f"/{encoding}/{model_type}" - if encoding == "form": - res = client.post(endpoint, data={}) - else: - res = client.post(endpoint, json={}) - - assert res.status_code == 200 - response_model = ResponseModel(**res.json()) - assert response_model.init_input == {} - assert len(response_model.fields_set) == 2 - assert response_model.dumped_fields_no_exclude["true_if_unset"] is True - - -@pytest.mark.parametrize("encoding", ENCODINGS) -@pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) -def test_no_prefill_defaults_partially_set(encoding, model_type, client): - """ - When the model is instantiated by the server, it should not have its defaults prefilled, - and pydantic should be able to differentiate between unset and default values when some are passed - """ - endpoint = f"/{encoding}/{model_type}" - if encoding == "form": - data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"} - res = client.post(endpoint, data=data) - else: - data = {"true_if_unset": False, "default_false": True, "default_zero": 0} - res = client.post(endpoint, json=data) - - if PYDANTIC_V2: - dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump( - exclude_unset=True - ) - dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump( - exclude_defaults=True - ) - else: - dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True) - dumped_exclude_default = MODEL_TYPES[model_type](**data).dict( - exclude_defaults=True - ) - - assert res.status_code == 200 - response_model = ResponseModel(**res.json()) - assert response_model.init_input == data - assert len(response_model.fields_set) == 4 - assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset - assert response_model.dumped_fields_no_exclude["true_if_unset"] is False - assert "default_zero" not in dumped_exclude_default - assert "default_zero" not in response_model.dumped_fields_exclude_default - - -@needs_pydanticv2 -def test_casted_empty_defaults(client: TestClient): - """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" - form_content = {"foo": "", "with": ""} - response = client.post("/simple-form", data=form_content) - response_content = response.json() - assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :'' - assert response_content["alias_with"] == "nothing" # ok - - -@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"]) -def test_empty_string_inputs(model_type, client): - """ - Form inputs with no input are empty strings, - these should be treated as being unset. - """ - data = { - "default_true": "", - "default_false": "", - "default_none": "", - "default_str": "", - "default_zero": "", - "true_if_unset": "", - } - response = client.post(f"/form/{model_type}", data=data) - assert response.status_code == 200 - if model_type != "inlined": - response_model = ResponseModel(**response.json()) - assert set(response_model.fields_set) == {"true_if_unset", "init_input"} - response_data = response_model.dumped_fields_no_meta - else: - response_data = response.json() - assert response_data == { - "default_true": True, - "default_false": False, - "default_none": None, - "default_zero": 0, - "default_str": "foo", - "true_if_unset": True, - } From fb17bbd07407641cc2810c2375c777d953a49499 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 2 Nov 2025 15:33:14 +0100 Subject: [PATCH 7/7] Fix test - use default value instead of `default_factory` --- tests/test_form_default.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_form_default.py b/tests/test_form_default.py index 7cad33a87..7525df9e0 100644 --- a/tests/test_form_default.py +++ b/tests/test_form_default.py @@ -48,7 +48,10 @@ def form_model(params: Annotated[FormParams, Form()]): @app.post("/form-param-list-with-default") def form_param_list_with_default( - params: Annotated[List[str], Form(default_factory=list)], + params: Annotated[ + List[str], + Form(), + ] = [], # noqa: B006 (default_factory doesn't work with Pydantic V1) ): return {"params": params}