From 76c4d317fd4119282141db787f7cd82a417de4b5 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 6 Mar 2025 19:06:44 -0800 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=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""" From a9acab81c401706da73219fead0ecf1ebec6b76f Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Sat, 8 Mar 2025 17:42:38 -0800 Subject: [PATCH 10/13] lint --- 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""" From f63e983b60cfd63a49ba03f53a7695e92e53436f Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 14 Apr 2025 19:46:34 -0700 Subject: [PATCH 11/13] docs for handling default values, pass field to validation context --- docs/en/docs/tutorial/request-form-models.md | 62 +++++++++++++++++++ docs_src/request_form_models/tutorial003.py | 42 +++++++++++++ .../tutorial003_an_py39.py | 44 +++++++++++++ docs_src/request_form_models/tutorial004.py | 57 +++++++++++++++++ .../tutorial004_an_py39.py | 59 ++++++++++++++++++ .../request_form_models/tutorial004_pv1.py | 51 +++++++++++++++ .../tutorial004_pv1_an_py39.py | 53 ++++++++++++++++ fastapi/_compat.py | 2 +- 8 files changed, 369 insertions(+), 1 deletion(-) 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 diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 79046a3f6..00ab1c341 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -73,6 +73,68 @@ 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[10,18:22] *} + +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. + +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 +(model reuse is one of the benefits of building FastAPI on top of pydantic, after all!). + +To do this, you can use a [`model_validator`](https://docs.pydantic.dev/latest/concepts/validators/#model-validators) +in the `before` mode - before the defaults from the model are applied, +to differentiate between an explicit `False` value and an unset value. + +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.* + +So we can additionally use the `'fastapi_field'` passed to the +[validation context](https://docs.pydantic.dev/latest/concepts/validators/#validation-context) +to determine whether our model is being validated from form input. + +/// note + +Validation context is a pydantic v2 only feature! + +/// + +{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[3,12:24] *} + +And with that, our form model should behave as expected when it is used with a form, +JSON input, or elsewhere in the program! + ## 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..5c2b3dd31 --- /dev/null +++ b/docs_src/request_form_models/tutorial003.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +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..f8e9668b4 --- /dev/null +++ b/docs_src/request_form_models/tutorial003_an_py39.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from pydantic import BaseModel +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +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..26582986b --- /dev/null +++ b/docs_src/request_form_models/tutorial004.py @@ -0,0 +1,57 @@ +from pydantic import BaseModel, ValidationInfo, model_validator +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +class MyModel(BaseModel): + checkbox: bool = True + + @model_validator(mode="before") + def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: + # if this model is being used outside of fastapi, return normally + if info.context is None or 'fastapi_field' not in info.context: + return value + + # check if we are being validated from form input, + # and if so, treat the unset checkbox as False + field_info = info.context['fastapi_field'].field_info + is_form = type(field_info).__name__ == "Form" + if is_form and '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: MyModel = Form()) -> MyModel: + 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..2529576b8 --- /dev/null +++ b/docs_src/request_form_models/tutorial004_an_py39.py @@ -0,0 +1,59 @@ +from typing import Annotated + +from pydantic import BaseModel, ValidationInfo, model_validator +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +class MyModel(BaseModel): + checkbox: bool = True + + @model_validator(mode="before") + def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: + # if this model is being used outside of fastapi, return normally + if info.context is None or 'fastapi_field' not in info.context: + return value + + # check if we are being validated from form input, + # and if so, treat the unset checkbox as False + field_info = info.context['fastapi_field'].field_info + is_form = type(field_info).__name__ == "Form" + if is_form and '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[MyModel, Form()]) -> MyModel: + 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..b0b9de62e --- /dev/null +++ b/docs_src/request_form_models/tutorial004_pv1.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel, model_validator +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +class MyModel(BaseModel): + checkbox: bool = True + + @model_validator(mode="before") + def handle_defaults(cls, value: dict) -> dict: + # We can't tell if we're being validated by fastAPI, + # so we have to just YOLO this. + 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: MyModel = Form()) -> MyModel: + 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..ac80f53dd --- /dev/null +++ b/docs_src/request_form_models/tutorial004_pv1_an_py39.py @@ -0,0 +1,53 @@ +from typing import Annotated + +from pydantic import BaseModel, model_validator +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from jinja2 import DictLoader, Environment + +class MyModel(BaseModel): + checkbox: bool = True + + @model_validator(mode="before") + def handle_defaults(cls, value: dict) -> dict: + # We can't tell if we're being validated by fastAPI, + # so we have to just YOLO this. + 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[MyModel, Form()]) -> MyModel: + return data diff --git a/fastapi/_compat.py b/fastapi/_compat.py index c07e4a3b0..a6ec1764d 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -126,7 +126,7 @@ if PYDANTIC_V2: ) -> Tuple[Any, Union[List[Dict[str, Any]], None]]: try: return ( - self._type_adapter.validate_python(value, from_attributes=True), + self._type_adapter.validate_python(value, from_attributes=True, context={"fastapi_field": self}), None, ) except ValidationError as exc: From cad08bbc4d82450fd7730fe4252512c621457065 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 02:47:42 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/request-form-models.md | 8 ++++---- docs_src/request_form_models/tutorial003.py | 8 ++++++-- .../request_form_models/tutorial003_an_py39.py | 10 +++++++--- docs_src/request_form_models/tutorial004.py | 17 ++++++++++------- .../request_form_models/tutorial004_an_py39.py | 17 ++++++++++------- docs_src/request_form_models/tutorial004_pv1.py | 13 ++++++++----- .../tutorial004_pv1_an_py39.py | 13 ++++++++----- fastapi/_compat.py | 4 +++- 8 files changed, 56 insertions(+), 34 deletions(-) diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 00ab1c341..885dfc96e 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -97,7 +97,7 @@ and the JSON response is also correct: ``` When the checkbox is *unchecked*, though, something strange happens. -The submitted form data is *empty*, +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, @@ -108,7 +108,7 @@ 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. In some cases, we can resolve the problem by changing or removing the default, -but we don't always have that option - +but we don't always have that option - particularly when the model is used in other places than the form (model reuse is one of the benefits of building FastAPI on top of pydantic, after all!). @@ -116,8 +116,8 @@ To do this, you can use a [`model_validator`](https://docs.pydantic.dev/latest/c in the `before` mode - before the defaults from the model are applied, to differentiate between an explicit `False` value and an unset value. -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 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.* So we can additionally use the `'fastapi_field'` passed to the diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py index 5c2b3dd31..b0cbd6ff3 100644 --- a/docs_src/request_form_models/tutorial003.py +++ b/docs_src/request_form_models/tutorial003.py @@ -1,12 +1,14 @@ -from pydantic import BaseModel 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() %} @@ -31,12 +33,14 @@ 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') + +@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 index f8e9668b4..2c7f6a4b7 100644 --- a/docs_src/request_form_models/tutorial003_an_py39.py +++ b/docs_src/request_form_models/tutorial003_an_py39.py @@ -1,14 +1,16 @@ from typing import Annotated -from pydantic import BaseModel 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() %} @@ -16,7 +18,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -33,12 +35,14 @@ 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') + +@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 index 26582986b..1dfc6f80d 100644 --- a/docs_src/request_form_models/tutorial004.py +++ b/docs_src/request_form_models/tutorial004.py @@ -1,8 +1,9 @@ -from pydantic import BaseModel, ValidationInfo, model_validator 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 @@ -10,15 +11,15 @@ class MyModel(BaseModel): @model_validator(mode="before") def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: # if this model is being used outside of fastapi, return normally - if info.context is None or 'fastapi_field' not in info.context: + if info.context is None or "fastapi_field" not in info.context: return value # check if we are being validated from form input, # and if so, treat the unset checkbox as False - field_info = info.context['fastapi_field'].field_info + field_info = info.context["fastapi_field"].field_info is_form = type(field_info).__name__ == "Form" - if is_form and 'checkbox' not in value: - value['checkbox'] = False + if is_form and "checkbox" not in value: + value["checkbox"] = False return value @@ -29,7 +30,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -46,12 +47,14 @@ 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') + +@app.post("/form") async def submit_form(data: MyModel = Form()) -> MyModel: return data diff --git a/docs_src/request_form_models/tutorial004_an_py39.py b/docs_src/request_form_models/tutorial004_an_py39.py index 2529576b8..d1d1fb64f 100644 --- a/docs_src/request_form_models/tutorial004_an_py39.py +++ b/docs_src/request_form_models/tutorial004_an_py39.py @@ -1,10 +1,11 @@ from typing import Annotated -from pydantic import BaseModel, ValidationInfo, model_validator 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 @@ -12,15 +13,15 @@ class MyModel(BaseModel): @model_validator(mode="before") def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: # if this model is being used outside of fastapi, return normally - if info.context is None or 'fastapi_field' not in info.context: + if info.context is None or "fastapi_field" not in info.context: return value # check if we are being validated from form input, # and if so, treat the unset checkbox as False - field_info = info.context['fastapi_field'].field_info + field_info = info.context["fastapi_field"].field_info is_form = type(field_info).__name__ == "Form" - if is_form and 'checkbox' not in value: - value['checkbox'] = False + if is_form and "checkbox" not in value: + value["checkbox"] = False return value @@ -31,7 +32,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -48,12 +49,14 @@ 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') + +@app.post("/form") async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: return data diff --git a/docs_src/request_form_models/tutorial004_pv1.py b/docs_src/request_form_models/tutorial004_pv1.py index b0b9de62e..8c7f35670 100644 --- a/docs_src/request_form_models/tutorial004_pv1.py +++ b/docs_src/request_form_models/tutorial004_pv1.py @@ -1,8 +1,9 @@ -from pydantic import BaseModel, model_validator 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 @@ -11,8 +12,8 @@ class MyModel(BaseModel): def handle_defaults(cls, value: dict) -> dict: # We can't tell if we're being validated by fastAPI, # so we have to just YOLO this. - if 'checkbox' not in value: - value['checkbox'] = False + if "checkbox" not in value: + value["checkbox"] = False return value @@ -23,7 +24,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -40,12 +41,14 @@ 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') + +@app.post("/form") async def submit_form(data: MyModel = Form()) -> MyModel: 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 index ac80f53dd..8c51553cb 100644 --- a/docs_src/request_form_models/tutorial004_pv1_an_py39.py +++ b/docs_src/request_form_models/tutorial004_pv1_an_py39.py @@ -1,10 +1,11 @@ from typing import Annotated -from pydantic import BaseModel, model_validator 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 @@ -13,8 +14,8 @@ class MyModel(BaseModel): def handle_defaults(cls, value: dict) -> dict: # We can't tell if we're being validated by fastAPI, # so we have to just YOLO this. - if 'checkbox' not in value: - value['checkbox'] = False + if "checkbox" not in value: + value["checkbox"] = False return value @@ -25,7 +26,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -42,12 +43,14 @@ 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') + +@app.post("/form") async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: return data diff --git a/fastapi/_compat.py b/fastapi/_compat.py index a6ec1764d..072aa5658 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -126,7 +126,9 @@ if PYDANTIC_V2: ) -> Tuple[Any, Union[List[Dict[str, Any]], None]]: try: return ( - self._type_adapter.validate_python(value, from_attributes=True, context={"fastapi_field": self}), + self._type_adapter.validate_python( + value, from_attributes=True, context={"fastapi_field": self} + ), None, ) except ValidationError as exc: From 3f2e0f572f2bb940ad3763ef71452ea2db31b718 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 14 Apr 2025 20:01:50 -0700 Subject: [PATCH 13/13] lint --- docs/en/docs/tutorial/request-form-models.md | 4 ++-- docs_src/request_form_models/tutorial003.py | 8 ++++++-- .../request_form_models/tutorial003_an_py39.py | 10 +++++++--- docs_src/request_form_models/tutorial004.py | 17 ++++++++++------- .../request_form_models/tutorial004_an_py39.py | 17 ++++++++++------- docs_src/request_form_models/tutorial004_pv1.py | 15 +++++++++------ .../tutorial004_pv1_an_py39.py | 15 +++++++++------ fastapi/_compat.py | 4 +++- 8 files changed, 56 insertions(+), 34 deletions(-) diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 00ab1c341..7f1ddd57c 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -81,7 +81,7 @@ 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[10,18:22] *} +{* ../../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: @@ -130,7 +130,7 @@ Validation context is a pydantic v2 only feature! /// -{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[3,12:24] *} +{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[7,13:25] *} And with that, our form model should behave as expected when it is used with a form, JSON input, or elsewhere in the program! diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py index 5c2b3dd31..b0cbd6ff3 100644 --- a/docs_src/request_form_models/tutorial003.py +++ b/docs_src/request_form_models/tutorial003.py @@ -1,12 +1,14 @@ -from pydantic import BaseModel 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() %} @@ -31,12 +33,14 @@ 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') + +@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 index f8e9668b4..2c7f6a4b7 100644 --- a/docs_src/request_form_models/tutorial003_an_py39.py +++ b/docs_src/request_form_models/tutorial003_an_py39.py @@ -1,14 +1,16 @@ from typing import Annotated -from pydantic import BaseModel 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() %} @@ -16,7 +18,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -33,12 +35,14 @@ 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') + +@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 index 26582986b..1dfc6f80d 100644 --- a/docs_src/request_form_models/tutorial004.py +++ b/docs_src/request_form_models/tutorial004.py @@ -1,8 +1,9 @@ -from pydantic import BaseModel, ValidationInfo, model_validator 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 @@ -10,15 +11,15 @@ class MyModel(BaseModel): @model_validator(mode="before") def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: # if this model is being used outside of fastapi, return normally - if info.context is None or 'fastapi_field' not in info.context: + if info.context is None or "fastapi_field" not in info.context: return value # check if we are being validated from form input, # and if so, treat the unset checkbox as False - field_info = info.context['fastapi_field'].field_info + field_info = info.context["fastapi_field"].field_info is_form = type(field_info).__name__ == "Form" - if is_form and 'checkbox' not in value: - value['checkbox'] = False + if is_form and "checkbox" not in value: + value["checkbox"] = False return value @@ -29,7 +30,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -46,12 +47,14 @@ 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') + +@app.post("/form") async def submit_form(data: MyModel = Form()) -> MyModel: return data diff --git a/docs_src/request_form_models/tutorial004_an_py39.py b/docs_src/request_form_models/tutorial004_an_py39.py index 2529576b8..d1d1fb64f 100644 --- a/docs_src/request_form_models/tutorial004_an_py39.py +++ b/docs_src/request_form_models/tutorial004_an_py39.py @@ -1,10 +1,11 @@ from typing import Annotated -from pydantic import BaseModel, ValidationInfo, model_validator 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 @@ -12,15 +13,15 @@ class MyModel(BaseModel): @model_validator(mode="before") def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict: # if this model is being used outside of fastapi, return normally - if info.context is None or 'fastapi_field' not in info.context: + if info.context is None or "fastapi_field" not in info.context: return value # check if we are being validated from form input, # and if so, treat the unset checkbox as False - field_info = info.context['fastapi_field'].field_info + field_info = info.context["fastapi_field"].field_info is_form = type(field_info).__name__ == "Form" - if is_form and 'checkbox' not in value: - value['checkbox'] = False + if is_form and "checkbox" not in value: + value["checkbox"] = False return value @@ -31,7 +32,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -48,12 +49,14 @@ 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') + +@app.post("/form") async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: return data diff --git a/docs_src/request_form_models/tutorial004_pv1.py b/docs_src/request_form_models/tutorial004_pv1.py index b0b9de62e..0340d7d7d 100644 --- a/docs_src/request_form_models/tutorial004_pv1.py +++ b/docs_src/request_form_models/tutorial004_pv1.py @@ -1,18 +1,19 @@ -from pydantic import BaseModel, model_validator 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 - @model_validator(mode="before") + @root_validator(pre=True) def handle_defaults(cls, value: dict) -> dict: # We can't tell if we're being validated by fastAPI, # so we have to just YOLO this. - if 'checkbox' not in value: - value['checkbox'] = False + if "checkbox" not in value: + value["checkbox"] = False return value @@ -23,7 +24,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -40,12 +41,14 @@ 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') + +@app.post("/form") async def submit_form(data: MyModel = Form()) -> MyModel: 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 index ac80f53dd..d0b45003f 100644 --- a/docs_src/request_form_models/tutorial004_pv1_an_py39.py +++ b/docs_src/request_form_models/tutorial004_pv1_an_py39.py @@ -1,20 +1,21 @@ from typing import Annotated -from pydantic import BaseModel, model_validator 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 - @model_validator(mode="before") + @root_validator(pre=True) def handle_defaults(cls, value: dict) -> dict: # We can't tell if we're being validated by fastAPI, # so we have to just YOLO this. - if 'checkbox' not in value: - value['checkbox'] = False + if "checkbox" not in value: + value["checkbox"] = False return value @@ -25,7 +26,7 @@ form_template = """ {% if field.annotation.__name__ == "bool" %} @@ -42,12 +43,14 @@ 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') + +@app.post("/form") async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel: return data diff --git a/fastapi/_compat.py b/fastapi/_compat.py index a6ec1764d..072aa5658 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -126,7 +126,9 @@ if PYDANTIC_V2: ) -> Tuple[Any, Union[List[Dict[str, Any]], None]]: try: return ( - self._type_adapter.validate_python(value, from_attributes=True, context={"fastapi_field": self}), + self._type_adapter.validate_python( + value, from_attributes=True, context={"fastapi_field": self} + ), None, ) except ValidationError as exc: