From 4c68df1804d874508d4043b8efd5d70ca0ceff3f Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 2 Nov 2025 09:52:50 +0100 Subject: [PATCH] Update tests --- tests/test_form_default.py | 193 +++++++++++++++++++++ tests/test_forms_defaults.py | 326 ----------------------------------- 2 files changed, 193 insertions(+), 326 deletions(-) create mode 100644 tests/test_form_default.py delete mode 100644 tests/test_forms_defaults.py diff --git a/tests/test_form_default.py b/tests/test_form_default.py new file mode 100644 index 000000000..7cad33a87 --- /dev/null +++ b/tests/test_form_default.py @@ -0,0 +1,193 @@ +from typing import Any, List, Optional + +import pytest +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from .utils import needs_pydanticv2 + + +class FormParams(BaseModel): + param_1: str = "default" + param_2: Annotated[str, Field(alias="param_2_alias")] = "default" + param_3: Annotated[str, Form(alias="param_3_alias")] = "default" + upload_file: Optional[UploadFile] = None + file: Annotated[Optional[bytes], File()] = None + + +app = FastAPI() + + +@app.post("/form-param") +def form_param( + param_1: Annotated[str, Form()] = "default", + param_2: Annotated[str, Form(alias="param_2_alias")] = "default", + upload_file: Optional[UploadFile] = None, + file: Annotated[Optional[bytes], File()] = None, +): + return { + "param_1": param_1, + "param_2": param_2, + "upload_file": upload_file, + "file": file, + } + + +@app.post("/form-model") +def form_model(params: Annotated[FormParams, Form()]): + return { + "param_1": params.param_1, + "param_2": params.param_2, + "param_3": params.param_3, + "upload_file": params.upload_file, + "file": params.file, + } + + +@app.post("/form-param-list-with-default") +def form_param_list_with_default( + params: Annotated[List[str], Form(default_factory=list)], +): + return {"params": params} + + +@app.post("/form-param-optional-list") +def form_param_optional_list( + params: Annotated[Optional[List[str]], Form()] = None, +): + return {"params": params} + + +@app.post("/form-model-fields-set") +def form_model_fields_set(params: Annotated[FormParams, Form()]): + return {"fields_set": params.model_fields_set} + + +# ==================================================================================== +# Tests + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as test_client: + yield test_client + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + {"param_1": "", "param_2_alias": "", "upload_file": "", "file": ""}, + id="empty_strings_sent", + ), + ], +) +def test_defaults_form_param(client: TestClient, data: dict): + """ + Empty string or no input data - default value is used. + For parameters declared as single Form fields. + """ + response = client.post("/form-param", data=data) + assert response.json() == { + "param_1": "default", + "param_2": "default", + "upload_file": None, + "file": None, + } + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + { + "param_1": "", + "param_2_alias": "", + "param_3_alias": "", + "upload_file": "", + "file": "", + }, + id="empty_strings_sent", + ), + ], +) +def test_defaults_form_model(client: TestClient, data: dict): + """ + Empty string or no data - default value is used. + For parameters declared as Form model. + """ + response = client.post("/form-model", data=data) + assert response.json() == { + "param_1": "default", + "param_2": "default", + "param_3": "default", + "upload_file": None, + "file": None, + } + + +@pytest.mark.parametrize( + ("url", "expected_res"), + [ + ("/form-param-list-with-default", []), + ("/form-param-optional-list", None), + ], +) +def test_form_list_param_no_input(client: TestClient, url: str, expected_res: Any): + """ + No input data passed to list parameter - default value is used. + """ + response = client.post(url) + assert response.json() == {"params": expected_res} + + +@pytest.mark.parametrize( + ("data", "expected_res"), + [ + ({"params": ""}, [""]), + ({"params": [""]}, [""]), + ({"params": ["", "a"]}, ["", "a"]), + ({"params": ["a", ""]}, ["a", ""]), + ], +) +@pytest.mark.parametrize( + "url", ["/form-param-list-with-default", "/form-param-optional-list"] +) +def test_form_list_param_empty_str( + client: TestClient, url: str, data: dict, expected_res: Any +): + """ + Empty strings passed to list parameter treated as empty strings. + """ + response = client.post(url, data=data) + assert response.json() == {"params": expected_res} + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="no_data_sent"), + pytest.param( + { + "param_1": "", + "param_2_alias": "", + "param_3_alias": "", + "upload_file": "", + "file": "", + }, + id="empty_strings_sent", + ), + ], +) +@needs_pydanticv2 +def test_form_model_fields_set(client: TestClient, data: dict): + """ + Check that fields are not pre-populated with default values when no data sent or + empty string sent. + """ + response = client.post("/form-model-fields-set", data=data) + assert response.json() == {"fields_set": []} diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py deleted file mode 100644 index 8a4dfc89b..000000000 --- a/tests/test_forms_defaults.py +++ /dev/null @@ -1,326 +0,0 @@ -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, - }