From dd9ccda8419c9bf5fb31b34636c3b9b630a91903 Mon Sep 17 00:00:00 2001 From: Matthew Batema Date: Fri, 11 Apr 2025 10:04:49 -0700 Subject: [PATCH 1/4] Fix downcast from Starlette UploadFile to FastAPI UploadFile --- fastapi/datastructures.py | 12 +++++ fastapi/routing.py | 16 ++++++- tests/test_request_uploadfile_type.py | 63 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/test_request_uploadfile_type.py diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index cf8406b0f..47412ab1f 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -5,6 +5,7 @@ from typing import ( Dict, Iterable, Optional, + Self, Type, TypeVar, cast, @@ -72,6 +73,17 @@ class UploadFile(StarletteUploadFile): Optional[str], Doc("The content type of the request, from the headers.") ] + @classmethod + def from_starlette( + cls: type[Self], starlette_uploadfile: StarletteUploadFile + ) -> Self: + return cls( + file=starlette_uploadfile.file, + size=starlette_uploadfile.size, + filename=starlette_uploadfile.filename, + headers=starlette_uploadfile.headers, + ) + async def write( self, data: Annotated[ diff --git a/fastapi/routing.py b/fastapi/routing.py index f620ced5f..8909e4639 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -31,7 +31,7 @@ from fastapi._compat import ( _normalize_errors, lenient_issubclass, ) -from fastapi.datastructures import Default, DefaultPlaceholder +from fastapi.datastructures import Default, DefaultPlaceholder, UploadFile from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( _should_embed_body_fields, @@ -60,6 +60,7 @@ from fastapi.utils import ( from pydantic import BaseModel from starlette import routing from starlette.concurrency import run_in_threadpool +from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -215,6 +216,19 @@ async def run_endpoint_function( # facilitate profiling endpoints, since inner functions are harder to profile. assert dependant.call is not None, "dependant.call must be a function" + # Convert all Starlette UploadFiles to FastAPI UploadFiles + for key, value in values.items(): + if isinstance(value, StarletteUploadFile) and not isinstance(value, UploadFile): + values[key] = UploadFile.from_starlette(value) + elif isinstance(value, list): + values[key] = [ + UploadFile.from_starlette(item) + if isinstance(item, StarletteUploadFile) + and not isinstance(item, UploadFile) + else item + for item in value + ] + if is_coroutine: return await dependant.call(**values) else: diff --git a/tests/test_request_uploadfile_type.py b/tests/test_request_uploadfile_type.py new file mode 100644 index 000000000..adeca0ed6 --- /dev/null +++ b/tests/test_request_uploadfile_type.py @@ -0,0 +1,63 @@ +import io +from typing import Any + +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from starlette.datastructures import UploadFile as StarletteUploadFile + +app = FastAPI() + + +@app.post("/uploadfile") +async def uploadfile(uploadfile: UploadFile = File(...)) -> dict[str, Any]: + return { + "filename": uploadfile.filename, + "is_fastapi_uploadfile": isinstance(uploadfile, UploadFile), + "is_starlette_uploadfile": isinstance(uploadfile, StarletteUploadFile), + "class": f"{uploadfile.__class__.__module__}.{uploadfile.__class__.__name__}", + } + + +@app.post("/uploadfiles") +async def uploadfiles( + uploadfiles: list[UploadFile] = File(...), +) -> list[dict[str, Any]]: + return [ + { + "filename": uploadfile.filename, + "is_fastapi_uploadfile": isinstance(uploadfile, UploadFile), + "is_starlette_uploadfile": isinstance(uploadfile, StarletteUploadFile), + "class": f"{uploadfile.__class__.__module__}.{uploadfile.__class__.__name__}", + } + for uploadfile in uploadfiles + ] + + +def test_uploadfile_type() -> None: + client = TestClient(app) + files = {"uploadfile": ("example.txt", io.BytesIO(b"test content"), "text/plain")} + response = client.post("/uploadfile/", files=files) + data = response.json() + + assert data["filename"] == "example.txt" + assert data["is_fastapi_uploadfile"] is True + assert data["is_starlette_uploadfile"] is True + assert data["class"].startswith("fastapi.") + + +def test_uploadfiles_type() -> None: + client = TestClient(app) + files = [ + ("uploadfiles", ("example.txt", io.BytesIO(b"test content"), "text/plain")) + ] + response = client.post("/uploadfiles/", files=files) + files_data = response.json() + + assert len(files_data) == 1 + + data = files_data[0] + + assert data["filename"] == "example.txt" + assert data["is_fastapi_uploadfile"] is True + assert data["is_starlette_uploadfile"] is True + assert data["class"].startswith("fastapi.") From 59b5245ed406e0c37e14ff6b67286adf965bf3fd Mon Sep 17 00:00:00 2001 From: Matthew Batema Date: Fri, 11 Apr 2025 10:15:50 -0700 Subject: [PATCH 2/4] Use forward reference instead of py311+ `Self` --- fastapi/datastructures.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index 47412ab1f..403b0d470 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -5,7 +5,6 @@ from typing import ( Dict, Iterable, Optional, - Self, Type, TypeVar, cast, @@ -75,8 +74,8 @@ class UploadFile(StarletteUploadFile): @classmethod def from_starlette( - cls: type[Self], starlette_uploadfile: StarletteUploadFile - ) -> Self: + cls: type["UploadFile"], starlette_uploadfile: StarletteUploadFile + ) -> "UploadFile": return cls( file=starlette_uploadfile.file, size=starlette_uploadfile.size, From ecfbcc2b3d69fdb42e39b223399cb36b3548e06b Mon Sep 17 00:00:00 2001 From: Matthew Batema Date: Thu, 17 Apr 2025 18:11:12 -0700 Subject: [PATCH 3/4] Use `Dict`/`List`/`Type` instead of `dict`/`list`/`type` for py38 --- fastapi/datastructures.py | 2 +- tests/test_request_uploadfile_type.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index 403b0d470..587e4e146 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -74,7 +74,7 @@ class UploadFile(StarletteUploadFile): @classmethod def from_starlette( - cls: type["UploadFile"], starlette_uploadfile: StarletteUploadFile + cls: Type["UploadFile"], starlette_uploadfile: StarletteUploadFile ) -> "UploadFile": return cls( file=starlette_uploadfile.file, diff --git a/tests/test_request_uploadfile_type.py b/tests/test_request_uploadfile_type.py index adeca0ed6..47dec7817 100644 --- a/tests/test_request_uploadfile_type.py +++ b/tests/test_request_uploadfile_type.py @@ -1,5 +1,5 @@ import io -from typing import Any +from typing import Any, Dict, List from fastapi import FastAPI, File, UploadFile from fastapi.testclient import TestClient @@ -9,7 +9,7 @@ app = FastAPI() @app.post("/uploadfile") -async def uploadfile(uploadfile: UploadFile = File(...)) -> dict[str, Any]: +async def uploadfile(uploadfile: UploadFile = File(...)) -> Dict[str, Any]: return { "filename": uploadfile.filename, "is_fastapi_uploadfile": isinstance(uploadfile, UploadFile), @@ -20,8 +20,8 @@ async def uploadfile(uploadfile: UploadFile = File(...)) -> dict[str, Any]: @app.post("/uploadfiles") async def uploadfiles( - uploadfiles: list[UploadFile] = File(...), -) -> list[dict[str, Any]]: + uploadfiles: List[UploadFile] = File(...), +) -> List[Dict[str, Any]]: return [ { "filename": uploadfile.filename, From fcdad3a183ec42e149d0f29e5cb654081ea64714 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 24 Nov 2025 14:15:36 +0100 Subject: [PATCH 4/4] Moved conversion logic to `_extract_form_body`, added test to cover case with dependency --- fastapi/dependencies/utils.py | 45 ++++++++++------- fastapi/routing.py | 16 +----- tests/test_request_uploadfile_type.py | 70 ++++++++++++++++++++++----- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 1e92c1ba2..7733ba1a2 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -54,6 +54,7 @@ from fastapi.concurrency import ( asynccontextmanager, contextmanager_in_threadpool, ) +from fastapi.datastructures import UploadFile as FastAPIUploadFile from fastapi.dependencies.models import Dependant, SecurityRequirement from fastapi.exceptions import DependencyScopeError from fastapi.logger import logger @@ -875,31 +876,39 @@ async def _extract_form_body( for field in body_fields: value = _get_multidict_value(field, received_body) field_info = field.field_info - if ( + if ( # fmt: skip isinstance(field_info, (params.File, temp_pydantic_v1_params.File)) - and is_bytes_field(field) and isinstance(value, UploadFile) ): - value = await value.read() - elif ( - is_bytes_sequence_field(field) - and isinstance(field_info, (params.File, temp_pydantic_v1_params.File)) + if is_bytes_field(field): + value = await value.read() + else: + value = FastAPIUploadFile.from_starlette(value) + elif ( # fmt: skip + isinstance(field_info, (params.File, temp_pydantic_v1_params.File)) and value_is_sequence(value) ): - # For types - assert isinstance(value, sequence_types) # type: ignore[arg-type] - results: List[Union[bytes, str]] = [] + if is_bytes_sequence_field(field): + # For types + assert isinstance(value, sequence_types) # type: ignore[arg-type] + results: List[Union[bytes, str]] = [] - async def process_fn( - fn: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - result = await fn() - results.append(result) # noqa: B023 + async def process_fn( + fn: Callable[[], Coroutine[Any, Any, Any]], + ) -> None: + result = await fn() + results.append(result) # noqa: B023 - async with anyio.create_task_group() as tg: - for sub_value in value: - tg.start_soon(process_fn, sub_value.read) - value = serialize_sequence_value(field=field, value=results) + async with anyio.create_task_group() as tg: + for sub_value in value: + tg.start_soon(process_fn, sub_value.read) + value = serialize_sequence_value(field=field, value=results) + else: + value = [ + FastAPIUploadFile.from_starlette(sub_value) + for sub_value in value + if isinstance(sub_value, UploadFile) + ] if value is not None: values[field.alias] = value for key, value in received_body.items(): diff --git a/fastapi/routing.py b/fastapi/routing.py index f46c2e89d..a8e12eb60 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -34,7 +34,7 @@ from fastapi._compat import ( _normalize_errors, lenient_issubclass, ) -from fastapi.datastructures import Default, DefaultPlaceholder, UploadFile +from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( _should_embed_body_fields, @@ -65,7 +65,6 @@ from starlette import routing from starlette._exception_handler import wrap_app_handling_exceptions from starlette._utils import is_async_callable from starlette.concurrency import run_in_threadpool -from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -287,19 +286,6 @@ async def run_endpoint_function( # facilitate profiling endpoints, since inner functions are harder to profile. assert dependant.call is not None, "dependant.call must be a function" - # Convert all Starlette UploadFiles to FastAPI UploadFiles - for key, value in values.items(): - if isinstance(value, StarletteUploadFile) and not isinstance(value, UploadFile): - values[key] = UploadFile.from_starlette(value) - elif isinstance(value, list): - values[key] = [ - UploadFile.from_starlette(item) - if isinstance(item, StarletteUploadFile) - and not isinstance(item, UploadFile) - else item - for item in value - ] - if is_coroutine: return await dependant.call(**values) else: diff --git a/tests/test_request_uploadfile_type.py b/tests/test_request_uploadfile_type.py index 47dec7817..6d30f818b 100644 --- a/tests/test_request_uploadfile_type.py +++ b/tests/test_request_uploadfile_type.py @@ -1,7 +1,8 @@ import io from typing import Any, Dict, List -from fastapi import FastAPI, File, UploadFile +import pytest +from fastapi import Depends, FastAPI, File, UploadFile from fastapi.testclient import TestClient from starlette.datastructures import UploadFile as StarletteUploadFile @@ -33,10 +34,48 @@ async def uploadfiles( ] -def test_uploadfile_type() -> None: +async def get_uploadfile_info(uploadfile: UploadFile = File(...)) -> Dict[str, Any]: + return { + "filename": uploadfile.filename, + "is_fastapi_uploadfile": isinstance(uploadfile, UploadFile), + "is_starlette_uploadfile": isinstance(uploadfile, StarletteUploadFile), + "class": f"{uploadfile.__class__.__module__}.{uploadfile.__class__.__name__}", + } + + +@app.post("/uploadfile-dep") +async def uploadfile_dep( + uploadfile_info: Dict[str, Any] = Depends(get_uploadfile_info), +) -> Dict[str, Any]: + return uploadfile_info + + +async def get_uploadfiles_info( + uploadfiles: List[UploadFile] = File(...), +) -> List[Dict[str, Any]]: + return [ + { + "filename": uploadfile.filename, + "is_fastapi_uploadfile": isinstance(uploadfile, UploadFile), + "is_starlette_uploadfile": isinstance(uploadfile, StarletteUploadFile), + "class": f"{uploadfile.__class__.__module__}.{uploadfile.__class__.__name__}", + } + for uploadfile in uploadfiles + ] + + +@app.post("/uploadfiles-dep") +async def uploadfiles_dep( + uploadfiles_info: List[Dict[str, Any]] = Depends(get_uploadfiles_info), +) -> List[Dict[str, Any]]: + return uploadfiles_info + + +@pytest.mark.parametrize("endpoint", ["/uploadfile", "/uploadfile-dep"]) +def test_uploadfile_type(endpoint: str) -> None: client = TestClient(app) files = {"uploadfile": ("example.txt", io.BytesIO(b"test content"), "text/plain")} - response = client.post("/uploadfile/", files=files) + response = client.post(f"{endpoint}", files=files) data = response.json() assert data["filename"] == "example.txt" @@ -45,19 +84,26 @@ def test_uploadfile_type() -> None: assert data["class"].startswith("fastapi.") -def test_uploadfiles_type() -> None: +@pytest.mark.parametrize("endpoint", ["/uploadfiles", "/uploadfiles-dep"]) +def test_uploadfiles_type(endpoint: str) -> None: client = TestClient(app) files = [ - ("uploadfiles", ("example.txt", io.BytesIO(b"test content"), "text/plain")) + ("uploadfiles", ("example.txt", io.BytesIO(b"test content"), "text/plain")), + ("uploadfiles", ("example2.txt", io.BytesIO(b"test content"), "text/plain")), ] - response = client.post("/uploadfiles/", files=files) + response = client.post(f"{endpoint}", files=files) files_data = response.json() - assert len(files_data) == 1 + assert len(files_data) == 2 - data = files_data[0] + file1 = files_data[0] + assert file1["filename"] == "example.txt" + assert file1["is_fastapi_uploadfile"] is True + assert file1["is_starlette_uploadfile"] is True + assert file1["class"].startswith("fastapi.") - assert data["filename"] == "example.txt" - assert data["is_fastapi_uploadfile"] is True - assert data["is_starlette_uploadfile"] is True - assert data["class"].startswith("fastapi.") + file2 = files_data[1] + assert file2["filename"] == "example2.txt" + assert file2["is_fastapi_uploadfile"] is True + assert file2["is_starlette_uploadfile"] is True + assert file2["class"].startswith("fastapi.")