Browse Source
When a Pydantic BaseModel is used as a Form() parameter, fields that
were absent from the submission or submitted as blank strings ("") were
being pre-filled with their default values before model construction.
Pydantic then treated those pre-filled defaults as explicitly-set values,
making model_fields_set incorrect and preventing model validators from
distinguishing unset fields from fields set to their default.
Two cases needed fixing:
1. Absent fields - the field key is not in the form data at all
(e.g. an unchecked HTML checkbox, which browsers omit entirely).
2. Blank string fields - the field key is present but with value ""
(e.g. a cleared text input, which browsers send as key=).
Fix: add form_input=True parameter to _get_multidict_value. When set,
both absent and blank-string values return None rather than the field
default. _extract_form_body now calls _get_multidict_value(form_input=True)
so neither case gets pre-populated into the values dict. Pydantic then
applies defaults through its own mechanism, which correctly leaves those
fields out of model_fields_set.
Required fields are unaffected: Pydantic still raises a validation error
when a required field is absent from the submitted data.
Closes #13399
Co-authored-by: sneakers-the-rat <[email protected]>
pull/15258/head
3 changed files with 232 additions and 7 deletions
@ -0,0 +1,222 @@ |
|||||
|
""" |
||||
|
Tests for correct model_fields_set and default handling in Form endpoints. |
||||
|
|
||||
|
Covers: |
||||
|
- Absent fields (never sent by the client) |
||||
|
- Blank strings (HTML forms send "" for empty text inputs) |
||||
|
- Required fields still return 422 when absent |
||||
|
- Behaviour matches JSON body endpoints |
||||
|
- Aliases work correctly |
||||
|
|
||||
|
Related: https://github.com/fastapi/fastapi/issues/13399 |
||||
|
""" |
||||
|
|
||||
|
from typing import Annotated |
||||
|
|
||||
|
import pytest |
||||
|
from fastapi import FastAPI, Form |
||||
|
from fastapi.testclient import TestClient |
||||
|
from pydantic import BaseModel, Field |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Models |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
class BoolModel(BaseModel): |
||||
|
"""Checkbox-style: bool with non-falsy default.""" |
||||
|
|
||||
|
checkbox: bool = True |
||||
|
|
||||
|
|
||||
|
class StringModel(BaseModel): |
||||
|
"""Text input with a non-empty default.""" |
||||
|
|
||||
|
text: str = "default" |
||||
|
alias_field: str = Field(alias="with", default="nothing") |
||||
|
|
||||
|
|
||||
|
class MixedModel(BaseModel): |
||||
|
field_bool: bool = True |
||||
|
field_str: str = "hello" |
||||
|
field_nullable: str | None = None |
||||
|
|
||||
|
|
||||
|
class RequiredModel(BaseModel): |
||||
|
required_field: str |
||||
|
optional_field: str = "default" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Endpoints |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
@app.post("/bool") |
||||
|
async def bool_endpoint(model: Annotated[BoolModel, Form()]): |
||||
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} |
||||
|
|
||||
|
|
||||
|
@app.post("/string") |
||||
|
async def string_endpoint(model: Annotated[StringModel, Form()]): |
||||
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump(by_alias=True)} |
||||
|
|
||||
|
|
||||
|
@app.post("/mixed") |
||||
|
async def mixed_endpoint(model: Annotated[MixedModel, Form()]): |
||||
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} |
||||
|
|
||||
|
|
||||
|
@app.post("/required") |
||||
|
async def required_endpoint(model: Annotated[RequiredModel, Form()]): |
||||
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} |
||||
|
|
||||
|
|
||||
|
# JSON body counterpart for parity checks |
||||
|
@app.post("/mixed-json") |
||||
|
async def mixed_json_endpoint(model: MixedModel): |
||||
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} |
||||
|
|
||||
|
|
||||
|
client = TestClient(app) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Absent fields — never sent by the client |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_absent_bool_field_not_in_fields_set(): |
||||
|
"""Unchecked checkbox (absent from form) must NOT appear in model_fields_set.""" |
||||
|
resp = client.post("/bool", data={}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["fields_set"] == [] |
||||
|
|
||||
|
|
||||
|
def test_absent_bool_field_default_still_applied(): |
||||
|
"""Default value is still present in the response even when field not submitted.""" |
||||
|
resp = client.post("/bool", data={}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["values"]["checkbox"] is True |
||||
|
|
||||
|
|
||||
|
def test_submitted_bool_field_in_fields_set(): |
||||
|
"""Submitted checkbox appears in model_fields_set.""" |
||||
|
resp = client.post("/bool", data={"checkbox": "false"}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["fields_set"] == ["checkbox"] |
||||
|
assert resp.json()["values"]["checkbox"] is False |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Blank string fields — HTML forms send "" for empty text inputs |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_blank_string_treated_as_unset(): |
||||
|
"""Empty text input ('') must NOT appear in model_fields_set; default applied.""" |
||||
|
resp = client.post("/string", data={"text": "", "with": ""}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
body = resp.json() |
||||
|
assert body["fields_set"] == [] |
||||
|
assert body["values"]["text"] == "default" |
||||
|
assert body["values"]["with"] == "nothing" |
||||
|
|
||||
|
|
||||
|
def test_non_empty_string_in_fields_set(): |
||||
|
"""Non-empty submission must appear in model_fields_set.""" |
||||
|
resp = client.post("/string", data={"text": "hello"}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
body = resp.json() |
||||
|
assert "text" in body["fields_set"] |
||||
|
assert body["values"]["text"] == "hello" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Mixed model — no data submitted at all |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_no_data_fields_set_empty(): |
||||
|
"""Submitting no form data produces an empty model_fields_set.""" |
||||
|
resp = client.post("/mixed", data={}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["fields_set"] == [] |
||||
|
|
||||
|
|
||||
|
def test_partial_data_only_submitted_fields_in_set(): |
||||
|
"""Only submitted fields appear in model_fields_set.""" |
||||
|
resp = client.post("/mixed", data={"field_bool": "false"}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["fields_set"] == ["field_bool"] |
||||
|
|
||||
|
|
||||
|
def test_all_data_all_fields_in_set(): |
||||
|
"""All submitted fields appear in model_fields_set.""" |
||||
|
resp = client.post( |
||||
|
"/mixed", |
||||
|
data={"field_bool": "true", "field_str": "world", "field_nullable": "x"}, |
||||
|
) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
assert resp.json()["fields_set"] == ["field_bool", "field_nullable", "field_str"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Required fields — must still return 422 when absent |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_required_field_absent_returns_422(): |
||||
|
"""Required field absent from form must still return a 422 validation error.""" |
||||
|
resp = client.post("/required", data={}) |
||||
|
assert resp.status_code == 422, resp.text |
||||
|
detail = resp.json()["detail"] |
||||
|
locs = [e["loc"] for e in detail] |
||||
|
assert ["body", "required_field"] in locs |
||||
|
|
||||
|
|
||||
|
def test_required_field_present_optional_absent(): |
||||
|
"""Required field present, optional absent → only required field in fields_set.""" |
||||
|
resp = client.post("/required", data={"required_field": "value"}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
body = resp.json() |
||||
|
assert body["fields_set"] == ["required_field"] |
||||
|
assert body["values"]["optional_field"] == "default" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Parity with JSON body |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
"submitted, expected_set", |
||||
|
[ |
||||
|
({}, []), |
||||
|
({"field_bool": "true"}, ["field_bool"]), |
||||
|
({"field_str": "world"}, ["field_str"]), |
||||
|
( |
||||
|
{"field_bool": "false", "field_str": "hi", "field_nullable": "x"}, |
||||
|
["field_bool", "field_nullable", "field_str"], |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_form_fields_set_matches_json(submitted, expected_set): |
||||
|
"""Form and JSON endpoints produce the same model_fields_set for equivalent input.""" |
||||
|
form_resp = client.post("/mixed", data=submitted) |
||||
|
json_payload = {} |
||||
|
if "field_bool" in submitted: |
||||
|
json_payload["field_bool"] = submitted["field_bool"].lower() == "true" |
||||
|
if "field_str" in submitted: |
||||
|
json_payload["field_str"] = submitted["field_str"] |
||||
|
if "field_nullable" in submitted: |
||||
|
json_payload["field_nullable"] = submitted["field_nullable"] |
||||
|
|
||||
|
json_resp = client.post("/mixed-json", json=json_payload) |
||||
|
|
||||
|
assert form_resp.status_code == 200, form_resp.text |
||||
|
assert json_resp.status_code == 200, json_resp.text |
||||
|
assert form_resp.json()["fields_set"] == json_resp.json()["fields_set"] == expected_set |
||||
Loading…
Reference in new issue