Squashed commit of the following:

commit 1b8d0d73f8
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Wed Aug 20 03:24:25 2025 -0700

    ok but seriously

commit d3ccab4948
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Tue Aug 19 23:38:25 2025 -0700

    rm being able to determine the input format of a model

commit fec0a068ed
Merge: 3f2e0f57 cad08bbc
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Mon Apr 14 20:03:14 2025 -0700

    Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults

commit 3f2e0f572f
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Mon Apr 14 20:01:50 2025 -0700

    lint

commit cad08bbc4d
Author: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date:   Tue Apr 15 02:47:42 2025 +0000

    🎨 [pre-commit.ci] Auto format from pre-commit.com hooks

commit f63e983b60
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Mon Apr 14 19:46:34 2025 -0700

    docs for handling default values, pass field to validation context

commit 529d486a7b
Merge: a9acab81 159824ea
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:45:40 2025 -0800

    Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults

commit a9acab81c4
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:42:38 2025 -0800

    lint

commit 159824ea93
Author: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date:   Sun Mar 9 01:42:31 2025 +0000

    🎨 [pre-commit.ci] Auto format from pre-commit.com hooks

commit e76184380d
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:42:21 2025 -0800

    pydantic v1 compat

commit 64f25284e8
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:38:03 2025 -0800

    fix handling form data with fields that are not annotated as Form()

commit 7fade13ac8
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:37:32 2025 -0800

    fix just the extra values problem (again, purposefully with failing tests to demonstrate the problem, fixing in next commit)

commit 49f6b8397d
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Sat Mar 8 17:36:03 2025 -0800

    add failing tests for empty input values to get a CI run baseline for them

commit 15eb6782dc
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Thu Mar 6 19:35:53 2025 -0800

    mypy lint

commit 1a58af44df
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Thu Mar 6 19:31:28 2025 -0800

    finish pydantic 1 compat

commit a2ad8b187f
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Thu Mar 6 19:22:11 2025 -0800

    python 3.8 and pydantic 1 compat

commit 76c4d317fd
Author: sneakers-the-rat <sneakers-the-rat@protonmail.com>
Date:   Thu Mar 6 19:06:44 2025 -0800

    don't prefill defaults in form input
This commit is contained in:
sneakers-the-rat 2025-10-30 17:34:43 +01:00 committed by Yurii Motov
parent a372edf7e8
commit 978351dbe6
10 changed files with 716 additions and 8 deletions

View File

@ -73,6 +73,60 @@ They will receive an error response telling them that the field `extra` is not a
}
```
## Default Fields
Form-encoded data has some quirks that can make working with pydantic models counterintuitive.
Say, for example, you were generating an HTML form from a model,
and that model had a boolean field in it that you wanted to display as a checkbox
with a default `True` value:
{* ../../docs_src/request_form_models/tutorial003_an_py39.py hl[11,10:23] *}
This works as expected when the checkbox remains checked,
the form encoded data in the request looks like this:
```formencoded
checkbox=on
```
and the JSON response is also correct:
```json
{"checkbox":true}
```
When the checkbox is *unchecked*, though, something strange happens.
The submitted form data is *empty*,
and the returned JSON data still shows `checkbox` still being `true`!
This is because checkboxes in HTML forms don't work exactly like the boolean inputs we expect,
when a checkbox is checked, if there is no `value` attribute, the value will be `"on"`,
and [the field will be omitted altogether if unchecked](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox).
When dealing with form models with defaults,
we need to take special care to handle cases where the field being *unset* has a specific meaning.
We also don't want to just treat any time the value is unset as ``False`` -
that would defeat the purpose of the default!
We want to specifically correct the behavior when it is used in the context of a *form.*
In some cases, we can resolve the problem by changing or removing the default,
but we don't always have that option -
particularly when the model is used in other places than the form
The recommended approach is to duplicate your model:
/// note
Take care to ensure that your duplicate models don't diverge,
e.g. if you are using sqlmodel,
where you may end up with `MyModel`, `MyModelCreate`, and `MyModelCreateForm`.
///
{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[7,13:25] *}
## Summary { #summary }
You can use Pydantic models to declare form fields in FastAPI. 😎

View File

@ -0,0 +1,46 @@
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel
class MyModel(BaseModel):
checkbox: bool = True
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
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

View File

@ -0,0 +1,48 @@
from typing import Annotated
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel
class MyModel(BaseModel):
checkbox: bool = True
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
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

View File

@ -0,0 +1,57 @@
from typing import cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, model_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(MyModel):
@model_validator(mode="before")
def handle_defaults(cls, value: dict) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: MyModelForm = Form()) -> MyModel:
data = cast(MyModel, data)
return data

View File

@ -0,0 +1,57 @@
from typing import Annotated, cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, ValidationInfo, model_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(MyModel):
@model_validator(mode="before")
def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel:
data = cast(MyModel, data)
return data

View File

@ -0,0 +1,57 @@
from typing import cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, root_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(BaseModel):
@root_validator(pre=True)
def handle_defaults(cls, value: dict) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: MyModelForm = Form()) -> MyModel:
data = cast(MyModel, data)
return data

View File

@ -0,0 +1,57 @@
from typing import Annotated, cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, root_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(MyModel):
@root_validator(pre=True)
def handle_defaults(cls, value: dict) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel:
data = cast(MyModel, data)
return data

View File

@ -714,7 +714,10 @@ def _validate_value_with_model_field(
def _get_multidict_value(
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
field: ModelField,
values: Mapping[str, Any],
alias: Union[str, None] = None,
form_input: bool = False,
) -> Any:
alias = alias or field.alias
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
@ -724,14 +727,14 @@ def _get_multidict_value(
if (
value is None
or (
isinstance(field.field_info, params.Form)
(isinstance(field.field_info, params.Form) or form_input)
and isinstance(value, str) # For type checks
and value == ""
)
or (is_sequence_field(field) and len(value) == 0)
):
if field.required:
return
if form_input or field.required:
return None
else:
return deepcopy(field.default)
return value
@ -864,11 +867,12 @@ async def _extract_form_body(
received_body: FormData,
) -> Dict[str, Any]:
values = {}
field_aliases = {field.alias for field in body_fields}
first_field = body_fields[0]
first_field_info = first_field.field_info
for field in body_fields:
value = _get_multidict_value(field, received_body)
value = _get_multidict_value(field, received_body, form_input=True)
if (
isinstance(first_field_info, params.File)
and is_bytes_field(field)
@ -896,8 +900,10 @@ async def _extract_form_body(
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[field.alias] = value
# preserve extra keys not in model body fields for validation
for key, value in received_body.items():
if key not in values:
if key not in field_aliases:
values[key] = value
return values

View File

@ -0,0 +1,326 @@
from typing import Optional
import pytest
from fastapi import FastAPI, Form
from fastapi._compat import PYDANTIC_V2
from pydantic import BaseModel, Field
from starlette.testclient import TestClient
from typing_extensions import Annotated
from .utils import needs_pydanticv2
if PYDANTIC_V2:
from pydantic import model_validator
else:
from pydantic import root_validator
def _validate_input(value: dict) -> dict:
"""
model validators in before mode should receive values passed
to model instantiation before any further validation
"""
# we should not be double-instantiating the models
assert isinstance(value, dict)
value["init_input"] = value.copy()
# differentiate between explicit Nones and unpassed values
if "true_if_unset" not in value:
value["true_if_unset"] = True
return value
class Parent(BaseModel):
init_input: dict
# importantly, no default here
if PYDANTIC_V2:
@model_validator(mode="before")
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
else:
@root_validator(pre=True)
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
class StandardModel(Parent):
default_true: bool = True
default_false: bool = False
default_none: Optional[bool] = None
default_zero: int = 0
default_str: str = "foo"
true_if_unset: Optional[bool] = None
class FieldModel(Parent):
default_true: bool = Field(default=True)
default_false: bool = Field(default=False)
default_none: Optional[bool] = Field(default=None)
default_zero: int = Field(default=0)
default_str: str = Field(default="foo")
true_if_unset: Optional[bool] = Field(default=None)
if PYDANTIC_V2:
class AnnotatedFieldModel(Parent):
default_true: Annotated[bool, Field(default=True)]
default_false: Annotated[bool, Field(default=False)]
default_none: Annotated[Optional[bool], Field(default=None)]
default_zero: Annotated[int, Field(default=0)]
default_str: Annotated[str, Field(default="foo")]
true_if_unset: Annotated[Optional[bool], Field(default=None)]
class AnnotatedFormModel(Parent):
default_true: Annotated[bool, Form(default=True)]
default_false: Annotated[bool, Form(default=False)]
default_none: Annotated[Optional[bool], Form(default=None)]
default_zero: Annotated[int, Form(default=0)]
default_str: Annotated[str, Form(default="foo")]
true_if_unset: Annotated[Optional[bool], Form(default=None)]
class SimpleForm(BaseModel):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
foo: Annotated[str, Form(default="bar")]
alias_with: Annotated[str, Form(alias="with", default="nothing")]
class ResponseModel(BaseModel):
fields_set: list = Field(default_factory=list)
dumped_fields_no_exclude: dict = Field(default_factory=dict)
dumped_fields_exclude_default: dict = Field(default_factory=dict)
dumped_fields_exclude_unset: dict = Field(default_factory=dict)
dumped_fields_no_meta: dict = Field(default_factory=dict)
init_input: dict
@classmethod
def from_value(cls, value: Parent) -> "ResponseModel":
if PYDANTIC_V2:
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.model_fields_set),
dumped_fields_no_exclude=value.model_dump(),
dumped_fields_exclude_default=value.model_dump(exclude_defaults=True),
dumped_fields_exclude_unset=value.model_dump(exclude_unset=True),
dumped_fields_no_meta=value.model_dump(
exclude={"init_input", "fields_set"}
),
)
else:
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.__fields_set__),
dumped_fields_no_exclude=value.dict(),
dumped_fields_exclude_default=value.dict(exclude_defaults=True),
dumped_fields_exclude_unset=value.dict(exclude_unset=True),
dumped_fields_no_meta=value.dict(exclude={"init_input", "fields_set"}),
)
app = FastAPI()
@app.post("/form/standard")
async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/field")
async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
if PYDANTIC_V2:
@app.post("/form/annotated-field")
async def form_annotated_field(
value: Annotated[AnnotatedFieldModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/annotated-form")
async def form_annotated_form(
value: Annotated[AnnotatedFormModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/inlined")
async def form_inlined(
default_true: Annotated[bool, Form()] = True,
default_false: Annotated[bool, Form()] = False,
default_none: Annotated[Optional[bool], Form()] = None,
default_zero: Annotated[int, Form()] = 0,
default_str: Annotated[str, Form()] = "foo",
true_if_unset: Annotated[Optional[bool], Form()] = None,
):
"""
Rather than using a model, inline the fields in the endpoint.
This doesn't use the `ResponseModel` pattern, since that is just to
test the instantiation behavior prior to the endpoint function.
Since we are receiving the values of the fields here (and thus,
having the defaults is correct behavior), we just return the values.
"""
if true_if_unset is None:
true_if_unset = True
return {
"default_true": default_true,
"default_false": default_false,
"default_none": default_none,
"default_zero": default_zero,
"default_str": default_str,
"true_if_unset": true_if_unset,
}
@app.post("/json/standard")
async def json_standard(value: StandardModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/field")
async def json_field(value: FieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
if PYDANTIC_V2:
@app.post("/json/annotated-field")
async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/annotated-form")
async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/simple-form")
def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict:
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
return model.model_dump()
if PYDANTIC_V2:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
"annotated-field": AnnotatedFieldModel,
"annotated-form": AnnotatedFormModel,
}
else:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
}
ENCODINGS = ("form", "json")
@pytest.fixture(scope="module")
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_all_unset(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
res = client.post(endpoint, data={})
else:
res = client.post(endpoint, json={})
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == {}
assert len(response_model.fields_set) == 2
assert response_model.dumped_fields_no_exclude["true_if_unset"] is True
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_partially_set(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled,
and pydantic should be able to differentiate between unset and default values when some are passed
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"}
res = client.post(endpoint, data=data)
else:
data = {"true_if_unset": False, "default_false": True, "default_zero": 0}
res = client.post(endpoint, json=data)
if PYDANTIC_V2:
dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump(
exclude_unset=True
)
dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump(
exclude_defaults=True
)
else:
dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True)
dumped_exclude_default = MODEL_TYPES[model_type](**data).dict(
exclude_defaults=True
)
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == data
assert len(response_model.fields_set) == 4
assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset
assert response_model.dumped_fields_no_exclude["true_if_unset"] is False
assert "default_zero" not in dumped_exclude_default
assert "default_zero" not in response_model.dumped_fields_exclude_default
@needs_pydanticv2
def test_casted_empty_defaults(client: TestClient):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
form_content = {"foo": "", "with": ""}
response = client.post("/simple-form", data=form_content)
response_content = response.json()
assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :''
assert response_content["alias_with"] == "nothing" # ok
@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"])
def test_empty_string_inputs(model_type, client):
"""
Form inputs with no input are empty strings,
these should be treated as being unset.
"""
data = {
"default_true": "",
"default_false": "",
"default_none": "",
"default_str": "",
"default_zero": "",
"true_if_unset": "",
}
response = client.post(f"/form/{model_type}", data=data)
assert response.status_code == 200
if model_type != "inlined":
response_model = ResponseModel(**response.json())
assert set(response_model.fields_set) == {"true_if_unset", "init_input"}
response_data = response_model.dumped_fields_no_meta
else:
response_data = response.json()
assert response_data == {
"default_true": True,
"default_false": False,
"default_none": None,
"default_zero": 0,
"default_str": "foo",
"true_if_unset": True,
}

View File

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