From 76c4d317fd4119282141db787f7cd82a417de4b5 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 6 Mar 2025 19:06:44 -0800 Subject: [PATCH 1/9] don't prefill defaults in form input --- fastapi/dependencies/utils.py | 13 ++- tests/test_forms_defaults.py | 192 +++++++++++++++++++++++++++++++ tests/test_forms_single_model.py | 4 +- 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 tests/test_forms_defaults.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index d205d17fa..dbab0b523 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, + return_default=True, ) -> Any: alias = alias or field.alias if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): @@ -730,10 +733,10 @@ def _get_multidict_value( ) or (is_sequence_field(field) and len(value) == 0) ): - if field.required: - return - else: + if return_default and not field.required: return deepcopy(field.default) + else: + return None return value @@ -839,7 +842,7 @@ async def _extract_form_body( 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, return_default=False) if ( isinstance(first_field_info, params.File) and is_bytes_field(field) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py new file mode 100644 index 000000000..2fc0976e6 --- /dev/null +++ b/tests/test_forms_defaults.py @@ -0,0 +1,192 @@ +from typing import Annotated, Optional + +import pytest +from fastapi import FastAPI, Form +from pydantic import BaseModel, Field, model_validator +from starlette.testclient import TestClient + + +class Parent(BaseModel): + init_input: dict + # importantly, no default here + + @model_validator(mode="before") + def validate_inputs(cls, 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 StandardModel(Parent): + default_true: bool = True + default_false: bool = False + default_none: Optional[bool] = None + default_zero: int = 0 + 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) + true_if_unset: Optional[bool] = Field(default=None) + + +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)] + 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)] + true_if_unset: Annotated[Optional[bool], Form(default=None)] + + +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) + init_input: dict + + @classmethod + def from_value(cls, value: Parent) -> "ResponseModel": + 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), + ) + + +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) + + +@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("/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) + + +@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) + + +MODEL_TYPES = { + "standard": StandardModel, + "field": FieldModel, + "annotated-field": AnnotatedFieldModel, + "annotated-form": AnnotatedFormModel, +} +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, monkeypatch): + """ + 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, monkeypatch): + """ + 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) + + data_with_init_input = data.copy() + data_with_init_input["init_input"] = data.copy() + + assert res.status_code == 200 + response_model = ResponseModel(**res.json()) + assert response_model.init_input == data + assert len(response_model.fields_set) == 4 + dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump( + exclude_unset=True + ) + assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset + assert response_model.dumped_fields_no_exclude["true_if_unset"] is False + dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump( + exclude_defaults=True + ) + assert "default_zero" not in dumped_exclude_default + assert "default_zero" not in response_model.dumped_fields_exclude_default 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 a2ad8b187f162145fdb266276e3c238eddaa7ff5 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 6 Mar 2025 19:22:11 -0800 Subject: [PATCH 2/9] python 3.8 and pydantic 1 compat --- tests/test_forms_defaults.py | 52 +++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py index 2fc0976e6..2beda62da 100644 --- a/tests/test_forms_defaults.py +++ b/tests/test_forms_defaults.py @@ -1,29 +1,49 @@ -from typing import Annotated, Optional +from importlib.metadata import version +from typing import Optional import pytest from fastapi import FastAPI, Form -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field from starlette.testclient import TestClient +from typing_extensions import Annotated + +PYDANTIC_V2 = int(version("pydantic")[0]) >= 2 + +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 - @model_validator(mode="before") - def validate_inputs(cls, 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 + 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): From 1a58af44df5946e0a59ef2767b85107c3fdfcd98 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 6 Mar 2025 19:31:28 -0800 Subject: [PATCH 3/9] finish pydantic 1 compat --- tests/test_forms_defaults.py | 124 ++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py index 2beda62da..765893336 100644 --- a/tests/test_forms_defaults.py +++ b/tests/test_forms_defaults.py @@ -1,14 +1,12 @@ -from importlib.metadata import version 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 -PYDANTIC_V2 = int(version("pydantic")[0]) >= 2 - if PYDANTIC_V2: from pydantic import model_validator else: @@ -62,20 +60,21 @@ class FieldModel(Parent): true_if_unset: Optional[bool] = Field(default=None) -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)] - true_if_unset: Annotated[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)] + 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)] - true_if_unset: Annotated[Optional[bool], Form(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)] + true_if_unset: Annotated[Optional[bool], Form(default=None)] class ResponseModel(BaseModel): @@ -87,13 +86,22 @@ class ResponseModel(BaseModel): @classmethod def from_value(cls, value: Parent) -> "ResponseModel": - 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), - ) + 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), + ) + 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), + ) app = FastAPI() @@ -109,18 +117,19 @@ async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel: return ResponseModel.from_value(value) -@app.post("/form/annotated-field") -async def form_annotated_field( - value: Annotated[AnnotatedFieldModel, 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/annotated-form") + async def form_annotated_form( + value: Annotated[AnnotatedFormModel, Form()], + ) -> ResponseModel: + return ResponseModel.from_value(value) @app.post("/json/standard") @@ -133,22 +142,29 @@ async def json_field(value: FieldModel) -> ResponseModel: return ResponseModel.from_value(value) -@app.post("/json/annotated-field") -async def json_annotated_field(value: AnnotatedFieldModel) -> 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("/json/annotated-form") + async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel: + return ResponseModel.from_value(value) -MODEL_TYPES = { - "standard": StandardModel, - "field": FieldModel, - "annotated-field": AnnotatedFieldModel, - "annotated-form": AnnotatedFormModel, -} +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") @@ -193,20 +209,24 @@ def test_no_prefill_defaults_partially_set(encoding, model_type, client, monkeyp data = {"true_if_unset": False, "default_false": True, "default_zero": 0} res = client.post(endpoint, json=data) - data_with_init_input = data.copy() - data_with_init_input["init_input"] = data.copy() + 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 - dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump( - exclude_unset=True - ) assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset assert response_model.dumped_fields_no_exclude["true_if_unset"] is False - dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump( - exclude_defaults=True - ) assert "default_zero" not in dumped_exclude_default assert "default_zero" not in response_model.dumped_fields_exclude_default From 15eb6782dcbebff40c46ae97fc17784c5f47c698 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 6 Mar 2025 19:35:53 -0800 Subject: [PATCH 4/9] mypy lint --- fastapi/dependencies/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dbab0b523..e2a668222 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -717,7 +717,7 @@ def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None, - return_default=True, + return_default: bool = True, ) -> Any: alias = alias or field.alias if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): From 49f6b8397d10dd0eef8501c1f239656c018629a6 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Sat, 8 Mar 2025 17:36:03 -0800 Subject: [PATCH 5/9] add failing tests for empty input values to get a CI run baseline for them --- tests/test_forms_defaults.py | 97 +++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py index 765893336..41cf740dd 100644 --- a/tests/test_forms_defaults.py +++ b/tests/test_forms_defaults.py @@ -49,6 +49,7 @@ class StandardModel(Parent): default_false: bool = False default_none: Optional[bool] = None default_zero: int = 0 + default_str: str = "foo" true_if_unset: Optional[bool] = None @@ -57,6 +58,7 @@ class FieldModel(Parent): 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) @@ -67,6 +69,7 @@ if PYDANTIC_V2: 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): @@ -74,14 +77,23 @@ if PYDANTIC_V2: 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 @@ -93,6 +105,9 @@ class ResponseModel(BaseModel): 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( @@ -101,6 +116,7 @@ class ResponseModel(BaseModel): 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"}), ) @@ -132,6 +148,36 @@ if PYDANTIC_V2: 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) @@ -153,6 +199,12 @@ if PYDANTIC_V2: 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, @@ -176,7 +228,7 @@ def client() -> TestClient: @pytest.mark.parametrize("encoding", ENCODINGS) @pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) -def test_no_prefill_defaults_all_unset(encoding, model_type, client, monkeypatch): +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 """ @@ -196,7 +248,7 @@ def test_no_prefill_defaults_all_unset(encoding, model_type, client, monkeypatch @pytest.mark.parametrize("encoding", ENCODINGS) @pytest.mark.parametrize("model_type", MODEL_TYPES.keys()) -def test_no_prefill_defaults_partially_set(encoding, model_type, client, monkeypatch): +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 @@ -230,3 +282,44 @@ def test_no_prefill_defaults_partially_set(encoding, model_type, client, monkeyp 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 + + +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 7fade13ac8696e529c070a42349951cdba8b4987 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Sat, 8 Mar 2025 17:37:32 -0800 Subject: [PATCH 6/9] fix just the extra values problem (again, purposefully with failing tests to demonstrate the problem, fixing in next commit) --- fastapi/dependencies/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e2a668222..20d4c5a2f 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -838,6 +838,7 @@ 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 @@ -870,8 +871,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 From 64f25284e84d7297cd7d803d5bf765e888358ade Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Sat, 8 Mar 2025 17:38:03 -0800 Subject: [PATCH 7/9] fix handling form data with fields that are not annotated as Form() --- fastapi/dependencies/utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 20d4c5a2f..e91c2b794 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -717,7 +717,7 @@ def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None, - return_default: bool = True, + form_input: bool = False, ) -> Any: alias = alias or field.alias if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): @@ -727,16 +727,16 @@ 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 return_default and not field.required: - return deepcopy(field.default) - else: + if form_input or field.required: return None + else: + return deepcopy(field.default) return value @@ -843,7 +843,7 @@ async def _extract_form_body( first_field_info = first_field.field_info for field in body_fields: - value = _get_multidict_value(field, received_body, return_default=False) + value = _get_multidict_value(field, received_body, form_input=True) if ( isinstance(first_field_info, params.File) and is_bytes_field(field) From e76184380dc5bd96dc3ee4a4405a30ddac2949d9 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Sat, 8 Mar 2025 17:42:21 -0800 Subject: [PATCH 8/9] pydantic v1 compat --- tests/test_forms_defaults.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py index 41cf740dd..d9876c6dc 100644 --- a/tests/test_forms_defaults.py +++ b/tests/test_forms_defaults.py @@ -7,6 +7,8 @@ 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: @@ -81,11 +83,11 @@ if PYDANTIC_V2: true_if_unset: Annotated[Optional[bool], Form(default=None)] -class SimpleForm(BaseModel): - """https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172""" + 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")] + foo: Annotated[str, Form(default="bar")] + alias_with: Annotated[str, Form(alias="with", default="nothing")] class ResponseModel(BaseModel): @@ -199,10 +201,10 @@ if PYDANTIC_V2: 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() + @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: @@ -283,7 +285,7 @@ def test_no_prefill_defaults_partially_set(encoding, model_type, client): 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": ""} From 159824ea935a9c74caae694d4b72aa9138cf32b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 9 Mar 2025 01:42:31 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_forms_defaults.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py index d9876c6dc..8a4dfc89b 100644 --- a/tests/test_forms_defaults.py +++ b/tests/test_forms_defaults.py @@ -82,7 +82,6 @@ if PYDANTIC_V2: 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""" @@ -200,7 +199,6 @@ if PYDANTIC_V2: 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""" @@ -285,6 +283,7 @@ def test_no_prefill_defaults_partially_set(encoding, model_type, client): 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"""