From 5122db7eb603a8d4d9bbbcaf754a4c138ef4abc9 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:32:14 -0400 Subject: [PATCH 01/10] Pass None instead of the default value to parameters that accept it when null is given Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- fastapi/dependencies/utils.py | 36 ++++++++++++---- tests/test_none_passed_when_null_received.py | 44 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 tests/test_none_passed_when_null_received.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e9..e0a2a1b33 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,6 +1,7 @@ import dataclasses import inspect import sys +import types from collections.abc import Coroutine, Mapping, Sequence from contextlib import AsyncExitStack, contextmanager from copy import copy, deepcopy @@ -21,6 +22,7 @@ from fastapi._compat import ( ModelField, RequiredParam, Undefined, + UndefinedType, copy_field_info, create_body_model, evaluate_forwardref, @@ -570,7 +572,7 @@ async def solve_dependencies( *, request: Union[Request, WebSocket], dependant: Dependant, - body: Optional[Union[dict[str, Any], FormData]] = None, + body: Optional[Union[dict[str, Any], FormData, UndefinedType]] = Undefined, background_tasks: Optional[StarletteBackgroundTasks] = None, response: Optional[Response] = None, dependency_overrides_provider: Optional[Any] = None, @@ -706,10 +708,24 @@ async def solve_dependencies( ) +def _allows_none(field: ModelField) -> bool: + origin = get_origin(field.type_) + return (origin is Union or origin is types.UnionType) and type(None) in get_args( + field.type_ + ) + + def _validate_value_with_model_field( *, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...] ) -> tuple[Any, list[Any]]: + if value is Undefined: + if field.required: + return None, [get_missing_field_error(loc=loc)] + else: + return deepcopy(field.default), [] if value is None: + if _allows_none(field): + return value, [] if field.field_info.is_required(): return None, [get_missing_field_error(loc=loc)] else: @@ -732,9 +748,9 @@ def _get_multidict_value( ): value = values.getlist(alias) else: - value = values.get(alias, None) + value = values.get(alias, Undefined) if ( - value is None + value is Undefined or ( isinstance(field.field_info, params.Form) and isinstance(value, str) # For type checks @@ -746,7 +762,7 @@ def _get_multidict_value( ) ): if field.field_info.is_required(): - return + return Undefined else: return deepcopy(field.default) return value @@ -914,7 +930,7 @@ async def _extract_form_body( for sub_value in value: tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) - if value is not None: + if value is not Undefined and value is not None: values[get_validation_alias(field)] = value field_aliases = {get_validation_alias(field) for field in body_fields} for key in received_body.keys(): @@ -929,7 +945,7 @@ async def _extract_form_body( async def request_body_to_args( body_fields: list[ModelField], - received_body: Optional[Union[dict[str, Any], FormData]], + received_body: Optional[Union[dict[str, Any], FormData, UndefinedType]], embed_body_fields: bool, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: values: dict[str, Any] = {} @@ -959,10 +975,12 @@ async def request_body_to_args( return {first_field.name: v_}, errors_ for field in body_fields: loc = ("body", get_validation_alias(field)) - value: Optional[Any] = None - if body_to_process is not None: + value: Optional[Any] = Undefined + if body_to_process is not None and not isinstance( + body_to_process, UndefinedType + ): try: - value = body_to_process.get(get_validation_alias(field)) + value = body_to_process.get(get_validation_alias(field), Undefined) # If the received body is a list, not a dict except AttributeError: errors.append(get_missing_field_error(loc)) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py new file mode 100644 index 000000000..f4e171202 --- /dev/null +++ b/tests/test_none_passed_when_null_received.py @@ -0,0 +1,44 @@ +from typing import Annotated, Optional, Union + +import pytest +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +DEFAULT = 1234567890 + + +@app.post("/api1") +def api1(integer_or_null: Annotated[int | None, Body(embed=True)] = DEFAULT) -> dict: + return {"received": integer_or_null} + + +@app.post("/api2") +def api2(integer_or_null: Annotated[Optional[int], Body(embed=True)] = DEFAULT) -> dict: + return {"received": integer_or_null} + + +@app.post("/api3") +def api3( + integer_or_null: Annotated[Union[int, None], Body(embed=True)] = DEFAULT, +) -> dict: + return {"received": integer_or_null} + + +@app.post("/api4") +def api4(integer_or_null: Optional[int] = Body(embed=True, default=DEFAULT)) -> dict: + return {"received": integer_or_null} + + +client = TestClient(app) + + +@pytest.mark.parametrize("api", ["/api1", "/api2", "/api3", "/api4"]) +def test_api1_integer(api): + response = client.post(api, json={"integer_or_null": 100}) + assert response.status_code == 200, response.text + assert response.json() == {"received": 100} + + response = client.post(api, json={"integer_or_null": None}) + assert response.status_code == 200, response.text + assert response.json() == {"received": None} From 76f0bb33730da9ce1e313cd91c9c2f604d660ca3 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Thu, 5 Sep 2024 21:36:10 -0400 Subject: [PATCH 02/10] make tests compatible with Python 3.8 and 3.9 Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- tests/test_none_passed_when_null_received.py | 46 ++++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index f4e171202..4d1b3b0a8 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -1,4 +1,5 @@ -from typing import Annotated, Optional, Union +import sys +from typing import Optional, Union import pytest from fastapi import Body, FastAPI @@ -7,22 +8,38 @@ from fastapi.testclient import TestClient app = FastAPI() DEFAULT = 1234567890 +endpoints = [] -@app.post("/api1") -def api1(integer_or_null: Annotated[int | None, Body(embed=True)] = DEFAULT) -> dict: - return {"received": integer_or_null} +if sys.hexversion >= 0x31000000: + from typing import Annotated + + @app.post("/api1") + def api1( + integer_or_null: Annotated[int | None, Body(embed=True)] = DEFAULT, + ) -> dict: + return {"received": integer_or_null} + + endpoints.append("/api1") -@app.post("/api2") -def api2(integer_or_null: Annotated[Optional[int], Body(embed=True)] = DEFAULT) -> dict: - return {"received": integer_or_null} +if sys.hexversion >= 0x30900000: + from typing import Annotated + @app.post("/api2") + def api2( + integer_or_null: Annotated[Optional[int], Body(embed=True)] = DEFAULT, + ) -> dict: + return {"received": integer_or_null} -@app.post("/api3") -def api3( - integer_or_null: Annotated[Union[int, None], Body(embed=True)] = DEFAULT, -) -> dict: - return {"received": integer_or_null} + endpoints.append("/api2") + + @app.post("/api3") + def api3( + integer_or_null: Annotated[Union[int, None], Body(embed=True)] = DEFAULT, + ) -> dict: + return {"received": integer_or_null} + + endpoints.append("/api3") @app.post("/api4") @@ -30,10 +47,13 @@ def api4(integer_or_null: Optional[int] = Body(embed=True, default=DEFAULT)) -> return {"received": integer_or_null} +endpoints.append("/api4") + + client = TestClient(app) -@pytest.mark.parametrize("api", ["/api1", "/api2", "/api3", "/api4"]) +@pytest.mark.parametrize("api", endpoints) def test_api1_integer(api): response = client.post(api, json={"integer_or_null": 100}) assert response.status_code == 200, response.text From 87b338099c418784bbb6bd983b9e6e8b6750dfaa Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Thu, 5 Sep 2024 21:58:36 -0400 Subject: [PATCH 03/10] make compatible with Python <3.10 and Pydantic v1 Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- fastapi/dependencies/utils.py | 22 +++++++++++++++----- tests/test_none_passed_when_null_received.py | 4 ++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e0a2a1b33..b27ef1fba 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -708,11 +708,23 @@ async def solve_dependencies( ) -def _allows_none(field: ModelField) -> bool: - origin = get_origin(field.type_) - return (origin is Union or origin is types.UnionType) and type(None) in get_args( - field.type_ - ) +if PYDANTIC_V2: + if sys.hexversion >= 0x30A00000: + + def _allows_none(field: ModelField) -> bool: + origin = get_origin(field.type_) + return (origin is Union or origin is types.UnionType) and type( + None + ) in get_args(field.type_) + else: + + def _allows_none(field: ModelField) -> bool: + origin = get_origin(field.type_) + return origin is Union and type(None) in get_args(field.type_) +else: + + def _allows_none(field: ModelField) -> bool: + return field.allow_none def _validate_value_with_model_field( diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index 4d1b3b0a8..51d3991f0 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -10,7 +10,7 @@ DEFAULT = 1234567890 endpoints = [] -if sys.hexversion >= 0x31000000: +if sys.hexversion >= 0x30A0000: from typing import Annotated @app.post("/api1") @@ -22,7 +22,7 @@ if sys.hexversion >= 0x31000000: endpoints.append("/api1") -if sys.hexversion >= 0x30900000: +if sys.hexversion >= 0x3090000: from typing import Annotated @app.post("/api2") From b927677760f8d25e0026d935ff69afcf8d3bd390 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Thu, 5 Sep 2024 22:10:42 -0400 Subject: [PATCH 04/10] fix Python version check Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- fastapi/dependencies/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index b27ef1fba..1ab4a8851 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -709,7 +709,7 @@ async def solve_dependencies( if PYDANTIC_V2: - if sys.hexversion >= 0x30A00000: + if sys.hexversion >= 0x30A0000: def _allows_none(field: ModelField) -> bool: origin = get_origin(field.type_) @@ -724,7 +724,7 @@ if PYDANTIC_V2: else: def _allows_none(field: ModelField) -> bool: - return field.allow_none + return field.allow_none # type: ignore def _validate_value_with_model_field( From 571b8a8db0bf6985b04825b49bce7b0556a48e01 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Thu, 5 Sep 2024 23:04:10 -0400 Subject: [PATCH 05/10] add test for required field and passing null Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- tests/test_none_passed_when_null_received.py | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index 51d3991f0..1a0be42fd 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -50,11 +50,16 @@ def api4(integer_or_null: Optional[int] = Body(embed=True, default=DEFAULT)) -> endpoints.append("/api4") +@app.post("/api5") +def api5(integer: int = Body(embed=True)) -> dict: + return {"received": integer} + + client = TestClient(app) @pytest.mark.parametrize("api", endpoints) -def test_api1_integer(api): +def test_apis(api): response = client.post(api, json={"integer_or_null": 100}) assert response.status_code == 200, response.text assert response.json() == {"received": 100} @@ -62,3 +67,18 @@ def test_api1_integer(api): response = client.post(api, json={"integer_or_null": None}) assert response.status_code == 200, response.text assert response.json() == {"received": None} + + +def test_required_field(): + response = client.post("/api5", json={"integer": None}) + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "loc": ["body", "integer"], + "msg": "Field required", + "type": "missing", + "input": None, + } + ] + } From 4b1ca3e9a1d3ca9d66b7320f7cd7036250f6f18f Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Fri, 6 Sep 2024 06:57:14 -0400 Subject: [PATCH 06/10] update required field test for Pydantic v1 Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- tests/test_none_passed_when_null_received.py | 33 ++++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index 1a0be42fd..868dd3350 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -2,6 +2,7 @@ import sys from typing import Optional, Union import pytest +from dirty_equals import IsDict from fastapi import Body, FastAPI from fastapi.testclient import TestClient @@ -72,13 +73,25 @@ def test_apis(api): def test_required_field(): response = client.post("/api5", json={"integer": None}) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", "integer"], - "msg": "Field required", - "type": "missing", - "input": None, - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "loc": ["body", "integer"], + "msg": "Field required", + "type": "missing", + "input": None, + } + ] + } + ) | IsDict( + { + "detail": [ + { + "loc": ["body", "integer"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) From 6d9130a02a80b061e7239d16a111ef0e647aa1e5 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:04:14 -0400 Subject: [PATCH 07/10] add test for full coverage Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- tests/test_none_passed_when_null_received.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index 868dd3350..3da094c6a 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -71,6 +71,10 @@ def test_apis(api): def test_required_field(): + response = client.post("/api5", json={"integer": 100}) + assert response.status_code == 200, response.text + assert response.json() == {"received": 100} + response = client.post("/api5", json={"integer": None}) assert response.status_code == 422, response.text assert response.json() == IsDict( From 6e3c519002e612896941dc8043eeb3ffae784567 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Sat, 12 Oct 2024 21:59:00 -0400 Subject: [PATCH 08/10] update to make work with latest master changes Signed-off-by: merlinz01 <158784988+merlinz01@users.noreply.github.com> --- fastapi/_compat/__init__.py | 1 + fastapi/_compat/v2.py | 3 ++- fastapi/dependencies/utils.py | 27 ++++++++++++--------------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index 4581c38c8..84076e164 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -23,6 +23,7 @@ from .v2 import ModelField as ModelField from .v2 import PydanticSchemaGenerationError as PydanticSchemaGenerationError from .v2 import RequiredParam as RequiredParam from .v2 import Undefined as Undefined +from .v2 import UndefinedType as UndefinedType from .v2 import Url as Url from .v2 import copy_field_info as copy_field_info from .v2 import create_body_model as create_body_model diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 87b9fb47f..9c90976d6 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -27,7 +27,7 @@ from pydantic.fields import FieldInfo as FieldInfo from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue from pydantic_core import CoreSchema as CoreSchema -from pydantic_core import PydanticUndefined +from pydantic_core import PydanticUndefined, PydanticUndefinedType from pydantic_core import Url as Url from pydantic_core.core_schema import ( with_info_plain_validator_function as with_info_plain_validator_function, @@ -36,6 +36,7 @@ from typing_extensions import Literal, get_args, get_origin RequiredParam = PydanticUndefined Undefined = PydanticUndefined +UndefinedType = PydanticUndefinedType evaluate_forwardref = eval_type_lenient # TODO: remove when dropping support for Pydantic < v2.12.3 diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 1ab4a8851..2ce789025 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -708,30 +708,26 @@ async def solve_dependencies( ) -if PYDANTIC_V2: - if sys.hexversion >= 0x30A0000: +if sys.hexversion >= 0x030A0000 and sys.hexversion < 0x030E0000: - def _allows_none(field: ModelField) -> bool: - origin = get_origin(field.type_) - return (origin is Union or origin is types.UnionType) and type( - None - ) in get_args(field.type_) - else: + def _allows_none(field: ModelField) -> bool: + origin = get_origin(field.field_info.annotation) + return (origin is Union or origin is types.UnionType) and type( + None + ) in get_args(field.field_info.annotation) - def _allows_none(field: ModelField) -> bool: - origin = get_origin(field.type_) - return origin is Union and type(None) in get_args(field.type_) else: def _allows_none(field: ModelField) -> bool: - return field.allow_none # type: ignore + origin = get_origin(field.field_info.annotation) + return origin is Union and type(None) in get_args(field.field_info.annotation) def _validate_value_with_model_field( *, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...] ) -> tuple[Any, list[Any]]: if value is Undefined: - if field.required: + if field.field_info.is_required(): return None, [get_missing_field_error(loc=loc)] else: return deepcopy(field.default), [] @@ -753,6 +749,7 @@ def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: alias = alias or get_validation_alias(field) + value: Any if ( (not _is_json_field(field)) and field_annotation_is_sequence(field.field_info.annotation) @@ -822,7 +819,7 @@ def request_params_to_args( if alias == field.name: alias = alias.replace("_", "-") value = _get_multidict_value(field, received_params, alias=alias) - if value is not None: + if value is not Undefined and value is not None: params_to_process[get_validation_alias(field)] = value processed_keys.add(alias or get_validation_alias(field)) @@ -987,7 +984,7 @@ async def request_body_to_args( return {first_field.name: v_}, errors_ for field in body_fields: loc = ("body", get_validation_alias(field)) - value: Optional[Any] = Undefined + value: Any = Undefined if body_to_process is not None and not isinstance( body_to_process, UndefinedType ): From ca0d16d98383a612e742c768ed5686550b25012f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:48:42 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 1d75f37ed..2b0c9d580 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -2,7 +2,6 @@ import dataclasses import inspect import sys import types -from collections.abc import Coroutine, Mapping, Sequence from collections.abc import Mapping, Sequence from contextlib import AsyncExitStack, contextmanager from copy import copy, deepcopy From fa9af08f8df86b0073ca789448c0bdd1b45b668f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:42:07 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_none_passed_when_null_received.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_none_passed_when_null_received.py b/tests/test_none_passed_when_null_received.py index 3da094c6a..5c431af61 100644 --- a/tests/test_none_passed_when_null_received.py +++ b/tests/test_none_passed_when_null_received.py @@ -1,5 +1,4 @@ import sys -from typing import Optional, Union import pytest from dirty_equals import IsDict @@ -28,7 +27,7 @@ if sys.hexversion >= 0x3090000: @app.post("/api2") def api2( - integer_or_null: Annotated[Optional[int], Body(embed=True)] = DEFAULT, + integer_or_null: Annotated[int | None, Body(embed=True)] = DEFAULT, ) -> dict: return {"received": integer_or_null} @@ -36,7 +35,7 @@ if sys.hexversion >= 0x3090000: @app.post("/api3") def api3( - integer_or_null: Annotated[Union[int, None], Body(embed=True)] = DEFAULT, + integer_or_null: Annotated[int | None, Body(embed=True)] = DEFAULT, ) -> dict: return {"received": integer_or_null} @@ -44,7 +43,7 @@ if sys.hexversion >= 0x3090000: @app.post("/api4") -def api4(integer_or_null: Optional[int] = Body(embed=True, default=DEFAULT)) -> dict: +def api4(integer_or_null: int | None = Body(embed=True, default=DEFAULT)) -> dict: return {"received": integer_or_null}