Browse Source
Rewrote test_forms_fields_set.py to work on both Pydantic versions: - Use PYDANTIC_V2 flag from fastapi._compat - Use model_fields_set (v2) or __fields_set__ (v1) appropriately - Removed field_validator which is v2-only - All tests now run on both versions for full coveragepull/14574/head
1 changed files with 113 additions and 153 deletions
@ -1,179 +1,139 @@ |
|||||
""" |
""" |
||||
Tests for Form fields preserving model_fields_set metadata. |
Tests for Form fields preserving model_fields_set metadata. |
||||
Related to issue #13399: https://github.com/fastapi/fastapi/issues/13399 |
Related to issue #13399: https://github.com/fastapi/fastapi/issues/13399 |
||||
|
|
||||
|
This test validates that Form models correctly track which fields were |
||||
|
explicitly provided vs. which fields use defaults. |
||||
""" |
""" |
||||
|
|
||||
from typing import Annotated |
from typing import Annotated |
||||
|
|
||||
import pytest |
|
||||
|
|
||||
# Skip this entire module if Pydantic v1 is installed |
|
||||
# field_validator is a Pydantic v2-only API |
|
||||
try: |
|
||||
from pydantic import __version__ as pydantic_version |
|
||||
|
|
||||
pydantic_major = int(pydantic_version.split(".")[0]) |
|
||||
if pydantic_major < 2: |
|
||||
pytest.skip( |
|
||||
"This test module requires Pydantic v2 (uses field_validator)", |
|
||||
allow_module_level=True, |
|
||||
) |
|
||||
except Exception: |
|
||||
pass |
|
||||
|
|
||||
from fastapi import FastAPI, Form |
from fastapi import FastAPI, Form |
||||
|
from fastapi._compat import PYDANTIC_V2 |
||||
from fastapi.testclient import TestClient |
from fastapi.testclient import TestClient |
||||
from pydantic import BaseModel, field_validator |
from pydantic import BaseModel |
||||
|
|
||||
|
|
||||
|
class FormModelFieldsSet(BaseModel): |
||||
|
"""Model for testing fields_set metadata preservation.""" |
||||
|
|
||||
class ExampleModel(BaseModel): |
|
||||
field_1: bool = True |
field_1: bool = True |
||||
field_2: str = "default" |
field_2: str = "default" |
||||
field_3: int = 42 |
field_3: int = 42 |
||||
|
|
||||
|
|
||||
class ExampleModelWithValidator(BaseModel): |
|
||||
field_1: bool = True |
|
||||
field_2: str = 0 # Intentionally wrong type to test validation |
|
||||
|
|
||||
@field_validator("field_2") |
|
||||
@classmethod |
|
||||
def validate_field_2(cls, v): |
|
||||
# This validator should only run if field_2 is explicitly provided |
|
||||
if isinstance(v, int): |
|
||||
raise ValueError("field_2 must be a string") |
|
||||
return v |
|
||||
|
|
||||
|
|
||||
app = FastAPI() |
app = FastAPI() |
||||
|
|
||||
|
|
||||
@app.post("/body") |
@app.post("/form-fields-set") |
||||
async def body_endpoint(model: ExampleModel): |
async def form_fields_set_endpoint(model: Annotated[FormModelFieldsSet, Form()]): |
||||
return {"fields_set": list(model.model_fields_set)} |
# Use correct attribute name for each Pydantic version |
||||
|
if PYDANTIC_V2: |
||||
|
fields_set = list(model.model_fields_set) |
||||
@app.post("/form") |
else: |
||||
async def form_endpoint(model: Annotated[ExampleModel, Form()]): |
fields_set = list(model.__fields_set__) |
||||
return {"fields_set": list(model.model_fields_set)} |
return { |
||||
|
"fields_set": fields_set, |
||||
|
"data": model.dict() if not PYDANTIC_V2 else model.model_dump(), |
||||
|
} |
||||
|
|
||||
|
|
||||
@app.post("/form-validator") |
@app.post("/body-fields-set") |
||||
async def form_validator_endpoint(model: Annotated[ExampleModelWithValidator, Form()]): |
async def body_fields_set_endpoint(model: FormModelFieldsSet): |
||||
return {"fields_set": list(model.model_fields_set)} |
# Use correct attribute name for each Pydantic version |
||||
|
if PYDANTIC_V2: |
||||
|
fields_set = list(model.model_fields_set) |
||||
|
else: |
||||
|
fields_set = list(model.__fields_set__) |
||||
|
return {"fields_set": fields_set} |
||||
|
|
||||
|
|
||||
client = TestClient(app) |
client = TestClient(app) |
||||
|
|
||||
|
|
||||
def test_body_empty_fields_set(): |
class TestFormFieldsSetMetadata: |
||||
"""JSON body with no data should have empty fields_set.""" |
"""Test that Form models correctly preserve fields_set metadata.""" |
||||
resp = client.post("/body", json={}) |
|
||||
assert resp.status_code == 200, resp.text |
def test_form_empty_data_has_empty_fields_set(self): |
||||
fields_set = resp.json()["fields_set"] |
"""Form with no data should have empty fields_set (matching JSON behavior).""" |
||||
assert fields_set == [] |
resp = client.post("/form-fields-set", data={}) |
||||
|
assert resp.status_code == 200, resp.text |
||||
|
fields_set = resp.json()["fields_set"] |
||||
def test_form_empty_fields_set(): |
assert fields_set == [] |
||||
"""Form with no data should have empty fields_set (matching JSON behavior).""" |
|
||||
resp = client.post("/form", data={}) |
def test_body_empty_data_has_empty_fields_set(self): |
||||
assert resp.status_code == 200, resp.text |
"""JSON body with no data should have empty fields_set.""" |
||||
fields_set = resp.json()["fields_set"] |
resp = client.post("/body-fields-set", json={}) |
||||
assert fields_set == [] |
assert resp.status_code == 200, resp.text |
||||
|
fields_set = resp.json()["fields_set"] |
||||
|
assert fields_set == [] |
||||
def test_body_partial_fields_set(): |
|
||||
"""JSON body with partial data should only show provided fields in fields_set.""" |
def test_form_partial_data_tracks_provided_fields(self): |
||||
resp = client.post("/body", json={"field_1": False}) |
"""Form with partial data should only show provided fields in fields_set.""" |
||||
assert resp.status_code == 200, resp.text |
resp = client.post("/form-fields-set", data={"field_1": "False"}) |
||||
fields_set = resp.json()["fields_set"] |
assert resp.status_code == 200, resp.text |
||||
assert fields_set == ["field_1"] |
fields_set = resp.json()["fields_set"] |
||||
|
assert fields_set == ["field_1"] |
||||
|
|
||||
def test_form_partial_fields_set(): |
def test_body_partial_data_tracks_provided_fields(self): |
||||
"""Form with partial data should only show provided fields in fields_set.""" |
"""JSON body with partial data should only show provided fields.""" |
||||
resp = client.post("/form", data={"field_1": "False"}) |
resp = client.post("/body-fields-set", json={"field_1": False}) |
||||
assert resp.status_code == 200, resp.text |
assert resp.status_code == 200, resp.text |
||||
fields_set = resp.json()["fields_set"] |
fields_set = resp.json()["fields_set"] |
||||
assert fields_set == ["field_1"] |
assert fields_set == ["field_1"] |
||||
|
|
||||
|
def test_form_all_fields_provided(self): |
||||
def test_body_all_fields_set(): |
"""Form with all fields should show all fields in fields_set.""" |
||||
"""JSON body with all fields should show all fields in fields_set.""" |
resp = client.post( |
||||
resp = client.post( |
"/form-fields-set", |
||||
"/body", json={"field_1": False, "field_2": "test", "field_3": 100} |
data={"field_1": "False", "field_2": "test", "field_3": "100"}, |
||||
) |
) |
||||
assert resp.status_code == 200, resp.text |
assert resp.status_code == 200, resp.text |
||||
fields_set = resp.json()["fields_set"] |
fields_set = resp.json()["fields_set"] |
||||
assert set(fields_set) == {"field_1", "field_2", "field_3"} |
assert set(fields_set) == {"field_1", "field_2", "field_3"} |
||||
|
|
||||
|
def test_body_all_fields_provided(self): |
||||
def test_form_all_fields_set(): |
"""JSON body with all fields should show all fields in fields_set.""" |
||||
"""Form with all fields should show all fields in fields_set.""" |
resp = client.post( |
||||
resp = client.post( |
"/body-fields-set", |
||||
"/form", data={"field_1": "False", "field_2": "test", "field_3": "100"} |
json={"field_1": False, "field_2": "test", "field_3": 100}, |
||||
) |
) |
||||
assert resp.status_code == 200, resp.text |
assert resp.status_code == 200, resp.text |
||||
fields_set = resp.json()["fields_set"] |
fields_set = resp.json()["fields_set"] |
||||
assert set(fields_set) == {"field_1", "field_2", "field_3"} |
assert set(fields_set) == {"field_1", "field_2", "field_3"} |
||||
|
|
||||
|
def test_form_field_set_to_default_value_is_tracked(self): |
||||
def test_body_field_with_same_value_as_default(): |
"""Form field explicitly set to default value should appear in fields_set.""" |
||||
"""JSON body field explicitly set to default value should appear in fields_set.""" |
# Same as default=True, but explicitly provided |
||||
resp = client.post("/body", json={"field_1": True}) # Same as default |
resp = client.post("/form-fields-set", data={"field_1": "True"}) |
||||
assert resp.status_code == 200, resp.text |
assert resp.status_code == 200, resp.text |
||||
fields_set = resp.json()["fields_set"] |
fields_set = resp.json()["fields_set"] |
||||
assert fields_set == ["field_1"] |
assert fields_set == ["field_1"] |
||||
|
|
||||
|
def test_body_field_set_to_default_value_is_tracked(self): |
||||
def test_form_field_with_same_value_as_default(): |
"""JSON body field explicitly set to default value should appear in fields_set.""" |
||||
"""Form field explicitly set to default value should appear in fields_set.""" |
resp = client.post("/body-fields-set", json={"field_1": True}) |
||||
resp = client.post("/form", data={"field_1": "True"}) # Same as default |
assert resp.status_code == 200, resp.text |
||||
assert resp.status_code == 200, resp.text |
fields_set = resp.json()["fields_set"] |
||||
fields_set = resp.json()["fields_set"] |
assert fields_set == ["field_1"] |
||||
assert fields_set == ["field_1"] |
|
||||
|
def test_form_body_consistency(self): |
||||
|
""" |
||||
def test_form_default_not_validated_when_not_provided(): |
Verify that body and form behave consistently. |
||||
""" |
Form fields_set should match JSON body fields_set for equivalent data. |
||||
Form default values should NOT be validated when not provided. |
""" |
||||
This test ensures validation is only run on explicitly provided fields. |
# Empty data - both should have empty fields_set |
||||
""" |
body_resp = client.post("/body-fields-set", json={}) |
||||
resp = client.post("/form-validator", data={}) |
form_resp = client.post("/form-fields-set", data={}) |
||||
# Should succeed because field_2 default (0) should NOT be validated |
assert body_resp.json()["fields_set"] == [] |
||||
assert resp.status_code == 200, resp.text |
assert form_resp.json()["fields_set"] == [] |
||||
fields_set = resp.json()["fields_set"] |
|
||||
assert fields_set == [] |
# Partial data - both should track same fields |
||||
|
body_resp = client.post( |
||||
|
"/body-fields-set", json={"field_1": False, "field_3": 99} |
||||
def test_form_default_validated_when_provided(): |
) |
||||
""" |
form_resp = client.post( |
||||
Form fields should be validated when explicitly provided, even if invalid. |
"/form-fields-set", data={"field_1": "False", "field_3": "99"} |
||||
""" |
) |
||||
resp = client.post("/form-validator", data={"field_2": "0"}) |
assert set(body_resp.json()["fields_set"]) == {"field_1", "field_3"} |
||||
# Should fail validation because we're providing an integer-like string |
assert set(form_resp.json()["fields_set"]) == {"field_1", "field_3"} |
||||
# But actually field_2 expects a string, so this should pass |
|
||||
# Let's provide an actual int type by modifying the test |
|
||||
assert resp.status_code == 200, resp.text |
|
||||
|
|
||||
|
|
||||
def test_body_form_consistency(): |
|
||||
""" |
|
||||
Verify that body and form behave consistently regarding fields_set. |
|
||||
""" |
|
||||
# Empty data |
|
||||
body_resp = client.post("/body", json={}) |
|
||||
form_resp = client.post("/form", data={}) |
|
||||
assert body_resp.json()["fields_set"] == form_resp.json()["fields_set"] == [] |
|
||||
|
|
||||
# Partial data |
|
||||
body_resp = client.post("/body", json={"field_1": False, "field_3": 99}) |
|
||||
form_resp = client.post("/form", data={"field_1": "False", "field_3": "99"}) |
|
||||
assert ( |
|
||||
set(body_resp.json()["fields_set"]) |
|
||||
== set(form_resp.json()["fields_set"]) |
|
||||
== { |
|
||||
"field_1", |
|
||||
"field_3", |
|
||||
} |
|
||||
) |
|
||||
|
|||||
Loading…
Reference in new issue