2 changed files with 193 additions and 326 deletions
@ -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": []} |
||||
@ -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, |
|
||||
} |
|
||||
Loading…
Reference in new issue