Browse Source

Fix form model losing model_fields_set for unsubmitted fields

When a Pydantic BaseModel is used as a Form parameter, fields absent from
the form submission were being pre-filled with their default values before
the model was constructed. This caused Pydantic to treat those defaults as
explicitly-set values, so model_fields_set incorrectly included fields the
client never sent.

The fix skips absent fields in _extract_form_body entirely, letting Pydantic
apply defaults through its own mechanism (which correctly leaves them out of
model_fields_set), matching the behaviour of JSON body endpoints.

Fixes #13399
pull/15244/head
Sai Tarrun Pitta 4 months ago
parent
commit
158d52945a
  1. 11
      fastapi/dependencies/utils.py
  2. 81
      tests/test_form_model_fields_set.py
  3. 4
      tests/test_forms_single_model.py

11
fastapi/dependencies/utils.py

@ -916,8 +916,15 @@ async def _extract_form_body(
values = {}
for field in body_fields:
value = _get_multidict_value(field, received_body)
alias = get_validation_alias(field)
field_info = field.field_info
# Skip fields not present in the form data so that Pydantic uses its
# own default mechanism. This preserves correct model_fields_set
# semantics: fields the client didn't submit should not appear as
# "set" on the resulting model (fixes #13399).
if alias not in received_body:
continue
value = _get_multidict_value(field, received_body)
if (
isinstance(field_info, params.File)
and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation)
@ -936,7 +943,7 @@ async def _extract_form_body(
results.append(await sub_value.read())
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[get_validation_alias(field)] = value
values[alias] = value
field_aliases = {get_validation_alias(field) for field in body_fields}
for key in received_body.keys():
if key not in field_aliases:

81
tests/test_form_model_fields_set.py

@ -0,0 +1,81 @@
"""
Tests for issue #13399: Form models should preserve correct model_fields_set.
Fields not submitted in the form data must NOT appear in model_fields_set,
matching the behaviour of JSON body endpoints.
"""
from typing import Annotated
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class ExampleModel(BaseModel):
field_1: bool = True
field_2: str = "default"
field_3: int | None = None
@app.post("/body")
async def body_endpoint(model: ExampleModel):
return {"fields_set": sorted(model.model_fields_set)}
@app.post("/form")
async def form_endpoint(model: Annotated[ExampleModel, Form()]):
return {"fields_set": sorted(model.model_fields_set)}
client = TestClient(app)
def test_json_empty_body_no_fields_set():
"""Baseline: JSON body with no fields → fields_set should be empty."""
resp = client.post("/body", json={})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == []
def test_form_empty_submission_no_fields_set():
"""Form with no fields submitted → fields_set should be empty (like JSON)."""
resp = client.post("/form", data={})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == []
def test_form_partial_submission_only_submitted_fields_set():
"""Only fields actually submitted appear in fields_set."""
resp = client.post("/form", data={"field_1": "false"})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == ["field_1"]
def test_form_all_fields_submitted():
"""All submitted fields appear in fields_set."""
resp = client.post(
"/form", data={"field_1": "false", "field_2": "hello", "field_3": "42"}
)
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == ["field_1", "field_2", "field_3"]
def test_form_defaults_still_applied():
"""Default values are still applied for fields not submitted."""
resp = client.post("/form", data={})
assert resp.status_code == 200, resp.text
# The endpoint just returns fields_set; check defaults via a richer endpoint
# by confirming 200 and correct fields_set — defaults are tested in
# test_forms_single_model.py.
def test_form_fields_set_matches_json_behavior():
"""Form and JSON endpoints must agree on fields_set for equivalent inputs."""
json_resp = client.post("/body", json={"field_2": "hi"})
form_resp = client.post("/form", data={"field_2": "hi"})
assert json_resp.status_code == 200
assert form_resp.status_code == 200
assert json_resp.json()["fields_set"] == form_resp.json()["fields_set"]

4
tests/test_forms_single_model.py

@ -99,13 +99,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": {},
},
]
}

Loading…
Cancel
Save