Skip to content

Commit 231f555

Browse files
committed
support chunked upload in async file-like interfaces
1 parent def4778 commit 231f555

4 files changed

Lines changed: 92 additions & 1 deletion

File tree

httpx/_content.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ._multipart import MultipartStream
1818
from ._types import (
1919
AsyncByteStream,
20+
AsyncFile,
2021
RequestContent,
2122
RequestData,
2223
RequestFiles,
@@ -83,6 +84,11 @@ async def __aiter__(self) -> AsyncIterator[bytes]:
8384
while chunk:
8485
yield chunk
8586
chunk = await self._stream.aread(self.CHUNK_SIZE)
87+
elif isinstance(self._stream, AsyncFile):
88+
chunk = await self._stream.read(self.CHUNK_SIZE)
89+
while chunk:
90+
yield chunk
91+
chunk = await self._stream.read(self.CHUNK_SIZE)
8692
else:
8793
# Otherwise iterate.
8894
async for part in self._stream:
@@ -127,7 +133,12 @@ def encode_content(
127133
return headers, IteratorByteStream(content) # type: ignore
128134

129135
elif isinstance(content, AsyncIterable):
130-
headers = {"Transfer-Encoding": "chunked"}
136+
if isinstance(content, AsyncFile):
137+
content_length_or_none = peek_filelike_length(content)
138+
if content_length_or_none is None:
139+
headers = {"Transfer-Encoding": "chunked"}
140+
else:
141+
headers = {"Content-Length": str(content_length_or_none)}
131142
return headers, AsyncIteratorByteStream(content)
132143

133144
raise TypeError(f"Unexpected type for 'content', {type(content)!r}")

httpx/_types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
IO,
88
TYPE_CHECKING,
99
Any,
10+
AnyStr,
1011
AsyncIterable,
1112
AsyncIterator,
1213
Callable,
@@ -16,9 +17,11 @@
1617
List,
1718
Mapping,
1819
Optional,
20+
Protocol,
1921
Sequence,
2022
Tuple,
2123
Union,
24+
runtime_checkable,
2225
)
2326

2427
if TYPE_CHECKING: # pragma: no cover
@@ -112,3 +115,10 @@ async def __aiter__(self) -> AsyncIterator[bytes]:
112115

113116
async def aclose(self) -> None:
114117
pass
118+
119+
120+
@runtime_checkable
121+
class AsyncFile(Protocol):
122+
async def read(self, size: int = -1) -> AnyStr: ...
123+
124+
def fileno(self) -> int: ...

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ trio==0.31.0
2727
trio-typing==0.10.0
2828
trustme==1.2.1
2929
uvicorn==0.35.0
30+
aiofiles==25.1.0
31+
types-aiofiles==25.1.0.20251011

tests/test_content.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import io
22
import typing
33

4+
import aiofiles
5+
import anyio
46
import pytest
7+
import trio
58

69
import httpx
10+
from httpx._content import AsyncIteratorByteStream
11+
from httpx._types import AsyncFile
712

813
method = "POST"
914
url = "https://www.example.com"
@@ -516,3 +521,66 @@ def test_allow_nan_false():
516521
ValueError, match="Out of range float values are not JSON compliant"
517522
):
518523
httpx.Response(200, json=data_with_inf)
524+
525+
526+
@pytest.mark.parametrize("client_method", ["put", "post"])
527+
@pytest.mark.anyio
528+
async def test_chunked_async_file_content(
529+
tmp_path, anyio_backend, monkeypatch, client_method
530+
):
531+
total_chunks = 3
532+
533+
def echo_request_content(request: httpx.Request) -> httpx.Response:
534+
return httpx.Response(200, content=request.content)
535+
536+
content_bytes = b"".join([b"a" * AsyncIteratorByteStream.CHUNK_SIZE] * total_chunks)
537+
to_upload = tmp_path / "upload.txt"
538+
to_upload.write_bytes(content_bytes)
539+
540+
async def checks(client: httpx.AsyncClient, async_file: AsyncFile) -> None:
541+
read_called = 0
542+
fileno_called = 0
543+
original_read = async_file.read
544+
original_fileno = async_file.fileno
545+
546+
async def mock_read(*args, **kwargs):
547+
nonlocal read_called
548+
read_called += 1
549+
return await original_read(*args, **kwargs)
550+
551+
def mock_fileno(*args):
552+
nonlocal fileno_called
553+
fileno_called += 1
554+
return original_fileno(*args)
555+
556+
monkeypatch.setattr(async_file, "read", mock_read)
557+
monkeypatch.setattr(async_file, "fileno", mock_fileno)
558+
response = await getattr(client, client_method)(
559+
url="http://127.0.0.1:8000/", content=async_file
560+
)
561+
assert response.status_code == 200
562+
assert response.content == content_bytes
563+
assert response.request.headers["Content-Length"] == str(len(content_bytes))
564+
assert read_called == total_chunks + 1
565+
assert fileno_called == 1
566+
567+
async with (
568+
await anyio.open_file(to_upload, mode="rb")
569+
if anyio_backend != "trio"
570+
else await trio.open_file(to_upload, mode="rb") as async_file,
571+
httpx.AsyncClient(
572+
transport=httpx.MockTransport(echo_request_content)
573+
) as client,
574+
):
575+
assert isinstance(async_file, AsyncFile)
576+
await checks(client, async_file)
577+
578+
if anyio_backend != "trio": # aiofiles doesn't work with trio
579+
async with (
580+
aiofiles.open(to_upload, mode="rb") as aio_file,
581+
httpx.AsyncClient(
582+
transport=httpx.MockTransport(echo_request_content)
583+
) as client,
584+
):
585+
assert isinstance(aio_file, AsyncFile)
586+
await checks(client, aio_file)

0 commit comments

Comments
 (0)