mirror of
https://github.com/fastapi/fastapi.git
synced 2026-11-01 13:55:55 -05:00
🐛 Preserve uploaded file positions when converting to bytes (#16566)
This commit is contained in:
1 parent
7417100c8a
commit
b47e166dd5
2 files changed
+158
-2
No files matched your search
@@ -1058,6 +1058,15 @@ def _extract_form_items(
|
||||
yield key, param_values, None
|
||||
|
||||
|
||||
async def _read_file_bytes(file: UploadFile) -> bytes:
|
||||
# Automatic conversion must not consume a file shared with other parameters.
|
||||
position = file.file.tell()
|
||||
try:
|
||||
return await file.read()
|
||||
finally:
|
||||
await file.seek(position)
|
||||
|
||||
|
||||
async def _extract_form_body(
|
||||
body_fields: list[ModelField],
|
||||
received_body: FormData,
|
||||
@@ -1067,7 +1076,7 @@ async def _extract_form_body(
|
||||
if is_bytes_or_nonable_bytes_annotation(
|
||||
field.field_info.annotation
|
||||
) and isinstance(value, UploadFile):
|
||||
value = await value.read()
|
||||
value = await _read_file_bytes(value)
|
||||
elif is_bytes_sequence_annotation(
|
||||
field.field_info.annotation
|
||||
) and value_is_sequence(value):
|
||||
@@ -1075,7 +1084,7 @@ async def _extract_form_body(
|
||||
results: list[bytes | str] = []
|
||||
for sub_value in value:
|
||||
if isinstance(sub_value, UploadFile):
|
||||
results.append(await sub_value.read())
|
||||
results.append(await _read_file_bytes(sub_value))
|
||||
else:
|
||||
results.append(sub_value)
|
||||
value = serialize_sequence_value(field=field, value=results)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from io import BytesIO
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI, File, UploadFile
|
||||
from fastapi.dependencies.utils import _read_file_bytes
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
pytest.param(b"hello", id="memory"),
|
||||
pytest.param(b"x" * (1024 * 1024 + 1), id="disk"),
|
||||
],
|
||||
)
|
||||
def test_file_bytes_shared_by_dependency_and_endpoint(content: bytes):
|
||||
app = FastAPI()
|
||||
|
||||
def inspect_file(file: Annotated[bytes, File()]):
|
||||
return file.decode()
|
||||
|
||||
@app.post("/")
|
||||
def upload(
|
||||
inspected: Annotated[str, Depends(inspect_file)],
|
||||
file: Annotated[bytes, File()],
|
||||
):
|
||||
return {"dependency": inspected, "endpoint": file.decode()}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/", files={"file": ("hello.txt", content)})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"dependency": content.decode(),
|
||||
"endpoint": content.decode(),
|
||||
}
|
||||
|
||||
|
||||
def test_file_bytes_list_shared_by_dependency_and_endpoint():
|
||||
app = FastAPI()
|
||||
|
||||
def inspect_files(files: Annotated[list[bytes], File()]):
|
||||
return [file.decode() for file in files]
|
||||
|
||||
@app.post("/")
|
||||
def upload(
|
||||
inspected: Annotated[list[str], Depends(inspect_files)],
|
||||
files: Annotated[list[bytes], File()],
|
||||
):
|
||||
return {"dependency": inspected, "endpoint": [file.decode() for file in files]}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/",
|
||||
files=[
|
||||
("files", ("one.txt", b"one")),
|
||||
("files", ("two.txt", b"two")),
|
||||
],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"dependency": ["one", "two"],
|
||||
"endpoint": ["one", "two"],
|
||||
}
|
||||
|
||||
|
||||
def test_bytes_dependency_preserves_upload_file_for_endpoint():
|
||||
app = FastAPI()
|
||||
|
||||
def inspect_file(file: Annotated[bytes, File()]):
|
||||
return file.decode()
|
||||
|
||||
@app.post("/")
|
||||
async def upload(
|
||||
inspected: Annotated[str, Depends(inspect_file)],
|
||||
file: Annotated[UploadFile, File()],
|
||||
):
|
||||
content = await file.read()
|
||||
second_read = await file.read()
|
||||
return {
|
||||
"dependency": inspected,
|
||||
"endpoint": content.decode(),
|
||||
"second_read": second_read.decode(),
|
||||
}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/", files={"file": ("hello.txt", b"hello")})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"dependency": "hello",
|
||||
"endpoint": "hello",
|
||||
"second_read": "",
|
||||
}
|
||||
|
||||
|
||||
def test_automatic_file_read_preserves_nonzero_position():
|
||||
app = FastAPI()
|
||||
|
||||
async def read_prefix(file: Annotated[UploadFile, File()]):
|
||||
return (await file.read(2)).decode()
|
||||
|
||||
def inspect_remainder(
|
||||
prefix: Annotated[str, Depends(read_prefix)],
|
||||
file: Annotated[bytes, File()],
|
||||
):
|
||||
return {"prefix": prefix, "remainder": file.decode()}
|
||||
|
||||
@app.post("/")
|
||||
async def upload(
|
||||
inspected: Annotated[dict[str, str], Depends(inspect_remainder)],
|
||||
file: Annotated[UploadFile, File()],
|
||||
):
|
||||
position = file.file.tell()
|
||||
remainder = await file.read()
|
||||
return {
|
||||
"dependency": inspected,
|
||||
"position": position,
|
||||
"remainder": remainder.decode(),
|
||||
}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/", files={"file": ("hello.txt", b"hello")})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"dependency": {"prefix": "he", "remainder": "llo"},
|
||||
"position": 2,
|
||||
"remainder": "llo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_automatic_file_read_restores_position(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
with BytesIO(b"hello") as stream:
|
||||
file = UploadFile(file=stream)
|
||||
stream.seek(2)
|
||||
|
||||
async def failing_read(size: int = -1) -> bytes:
|
||||
stream.read(1)
|
||||
raise OSError("read failed")
|
||||
|
||||
monkeypatch.setattr(file, "read", failing_read)
|
||||
with pytest.raises(OSError, match="read failed"):
|
||||
await _read_file_bytes(file)
|
||||
|
||||
assert stream.tell() == 2
|
||||
Reference in new issue
Block a user