From bec5e359e8fffeaac1b532d12c0663248dd4fbfa Mon Sep 17 00:00:00 2001 From: Michiel <918128+mvanderlee@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:45:48 +0100 Subject: [PATCH 1/3] Add support for Sentinel values --- fastapi/dependencies/utils.py | 10 +- tests/test_sentinel.py | 266 ++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/test_sentinel.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 45e1ff3ed1..23455344d0 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -67,6 +67,14 @@ from starlette.responses import Response from starlette.websockets import WebSocket from typing_extensions import Literal, get_args, get_origin +try: + # This was added in 4.14.0 (June 2, 2025) We want to be able to support older versions + from typing_extensions import Sentinel +except ImportError: + class Sentinel: + pass + + multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' 'You can install "python-multipart" with: \n\n' @@ -736,7 +744,7 @@ def _get_multidict_value( if field.required: return else: - return deepcopy(field.default) + return field.default if isinstance(field.default, Sentinel) else deepcopy(field.default) return value diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py new file mode 100644 index 0000000000..b1e53e7f82 --- /dev/null +++ b/tests/test_sentinel.py @@ -0,0 +1,266 @@ +from typing import Union + +import pydantic_core +import pytest +from fastapi import Body, Cookie, FastAPI, Header, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel + +try: + # We support older pydantic versions, so do a safe import + from pydantic_core import MISSING +except ImportError: + + class MISSING: + pass + + +def create_app(): + app = FastAPI() + + class Item(BaseModel): + data: Union[str, MISSING, None] = MISSING # pyright: ignore[reportInvalidTypeForm] - requires pyright option: enableExperimentalFeatures = true + # see https://docs.pydantic.dev/latest/concepts/experimental/#missing-sentinel + + @app.post("/sentinel/") + def sentinel( + item: Item = Body(), + ): + return item + + @app.get("/query_sentinel/") + def query_sentinel( + data: Union[str, MISSING, None] = Query(default=MISSING), # pyright: ignore[reportInvalidTypeForm] + ): + return data + + @app.get("/header_sentinel/") + def header_sentinel( + data: Union[str, MISSING, None] = Header(default=MISSING), # pyright: ignore[reportInvalidTypeForm] + ): + return data + + @app.get("/cookie_sentinel/") + def cookie_sentinel( + data: Union[str, MISSING, None] = Cookie(default=MISSING), # pyright: ignore[reportInvalidTypeForm] + ): + return data + + return app + + +@pytest.mark.skipif( + pydantic_core.__version__ < "2.37.0", + reason="This pydantic_core version doesn't support MISSING", +) +def test_call_api(): + app = create_app() + client = TestClient(app) + response = client.post("/sentinel/", json={}) + assert response.status_code == 200, response.text + response = client.post("/sentinel/", json={"data": "Foo"}) + assert response.status_code == 200, response.text + response = client.get("/query_sentinel/") + assert response.status_code == 200, response.text + response = client.get("/query_sentinel/", params={"data": "Foo"}) + assert response.status_code == 200, response.text + response = client.get("/header_sentinel/") + assert response.status_code == 200, response.text + response = client.get("/header_sentinel/", headers={"data": "Foo"}) + assert response.status_code == 200, response.text + response = client.get("/cookie_sentinel/") + assert response.status_code == 200, response.text + client.cookies = {"data": "Foo"} + response = client.get("/cookie_sentinel/") + assert response.status_code == 200, response.text + + +@pytest.mark.skipif( + pydantic_core.__version__ < "2.37.0", + reason="This pydantic_core version doesn't support MISSING", +) +def test_openapi_schema(): + """ + Test that example overrides work: + + * pydantic model schema_extra is included + * Body(example={}) overrides schema_extra in pydantic model + * Body(examples{}) overrides Body(example={}) and schema_extra in pydantic model + """ + app = create_app() + client = TestClient(app) + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/sentinel/": { + "post": { + "summary": "Sentinel", + "operationId": "sentinel_sentinel__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item", + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/query_sentinel/": { + "get": { + "summary": "Query Sentinel", + "operationId": "query_sentinel_query_sentinel__get", + "parameters": [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + }, + "name": "data", + "in": "query", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/header_sentinel/": { + "get": { + "summary": "Header Sentinel", + "operationId": "header_sentinel_header_sentinel__get", + "parameters": [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + }, + "name": "data", + "in": "header", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/cookie_sentinel/": { + "get": { + "summary": "Cookie Sentinel", + "operationId": "cookie_sentinel_cookie_sentinel__get", + "parameters": [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + }, + "name": "data", + "in": "cookie", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "Item": { + "title": "Item", + "type": "object", + "properties": {"data": {"title": "Data", "anyOf": [{"type": "string"}, {"type": "null"}]}}, + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + } + }, + } From a5d28b0bc4cf29a9ae76e8afe608dc129d2b7cb9 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, 6 Jan 2026 21:48:16 +0000 Subject: [PATCH 2/3] =?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 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 23455344d0..03191b8b28 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -71,6 +71,7 @@ try: # This was added in 4.14.0 (June 2, 2025) We want to be able to support older versions from typing_extensions import Sentinel except ImportError: + class Sentinel: pass @@ -744,7 +745,11 @@ def _get_multidict_value( if field.required: return else: - return field.default if isinstance(field.default, Sentinel) else deepcopy(field.default) + return ( + field.default + if isinstance(field.default, Sentinel) + else deepcopy(field.default) + ) return value From 5a254e566fc96c858f4c5ae9e2ee4cf44377c279 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, 6 Jan 2026 22:07:02 +0000 Subject: [PATCH 3/3] =?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_sentinel.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index b1e53e7f82..c3e898e129 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -243,7 +243,12 @@ def test_openapi_schema(): "Item": { "title": "Item", "type": "object", - "properties": {"data": {"title": "Data", "anyOf": [{"type": "string"}, {"type": "null"}]}}, + "properties": { + "data": { + "title": "Data", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + }, }, "ValidationError": { "title": "ValidationError",