Skip to content

Commit d782cb8

Browse files
feat(client): add support for binary request streaming
1 parent 67d744e commit d782cb8

File tree

5 files changed

+363
-19
lines changed

5 files changed

+363
-19
lines changed

src/kernel/_base_client.py

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import inspect
1010
import logging
1111
import platform
12+
import warnings
1213
import email.utils
1314
from types import TracebackType
1415
from random import random
@@ -51,9 +52,11 @@
5152
ResponseT,
5253
AnyMapping,
5354
PostParser,
55+
BinaryTypes,
5456
RequestFiles,
5557
HttpxSendArgs,
5658
RequestOptions,
59+
AsyncBinaryTypes,
5760
HttpxRequestFiles,
5861
ModelBuilderProtocol,
5962
not_given,
@@ -477,8 +480,19 @@ def _build_request(
477480
retries_taken: int = 0,
478481
) -> httpx.Request:
479482
if log.isEnabledFor(logging.DEBUG):
480-
log.debug("Request options: %s", model_dump(options, exclude_unset=True))
481-
483+
log.debug(
484+
"Request options: %s",
485+
model_dump(
486+
options,
487+
exclude_unset=True,
488+
# Pydantic v1 can't dump every type we support in content, so we exclude it for now.
489+
exclude={
490+
"content",
491+
}
492+
if PYDANTIC_V1
493+
else {},
494+
),
495+
)
482496
kwargs: dict[str, Any] = {}
483497

484498
json_data = options.json_data
@@ -532,7 +546,13 @@ def _build_request(
532546
is_body_allowed = options.method.lower() != "get"
533547

534548
if is_body_allowed:
535-
if isinstance(json_data, bytes):
549+
if options.content is not None and json_data is not None:
550+
raise TypeError("Passing both `content` and `json_data` is not supported")
551+
if options.content is not None and files is not None:
552+
raise TypeError("Passing both `content` and `files` is not supported")
553+
if options.content is not None:
554+
kwargs["content"] = options.content
555+
elif isinstance(json_data, bytes):
536556
kwargs["content"] = json_data
537557
else:
538558
kwargs["json"] = json_data if is_given(json_data) else None
@@ -1194,6 +1214,7 @@ def post(
11941214
*,
11951215
cast_to: Type[ResponseT],
11961216
body: Body | None = None,
1217+
content: BinaryTypes | None = None,
11971218
options: RequestOptions = {},
11981219
files: RequestFiles | None = None,
11991220
stream: Literal[False] = False,
@@ -1206,6 +1227,7 @@ def post(
12061227
*,
12071228
cast_to: Type[ResponseT],
12081229
body: Body | None = None,
1230+
content: BinaryTypes | None = None,
12091231
options: RequestOptions = {},
12101232
files: RequestFiles | None = None,
12111233
stream: Literal[True],
@@ -1219,6 +1241,7 @@ def post(
12191241
*,
12201242
cast_to: Type[ResponseT],
12211243
body: Body | None = None,
1244+
content: BinaryTypes | None = None,
12221245
options: RequestOptions = {},
12231246
files: RequestFiles | None = None,
12241247
stream: bool,
@@ -1231,13 +1254,25 @@ def post(
12311254
*,
12321255
cast_to: Type[ResponseT],
12331256
body: Body | None = None,
1257+
content: BinaryTypes | None = None,
12341258
options: RequestOptions = {},
12351259
files: RequestFiles | None = None,
12361260
stream: bool = False,
12371261
stream_cls: type[_StreamT] | None = None,
12381262
) -> ResponseT | _StreamT:
1263+
if body is not None and content is not None:
1264+
raise TypeError("Passing both `body` and `content` is not supported")
1265+
if files is not None and content is not None:
1266+
raise TypeError("Passing both `files` and `content` is not supported")
1267+
if isinstance(body, bytes):
1268+
warnings.warn(
1269+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1270+
"Please pass raw bytes via the `content` parameter instead.",
1271+
DeprecationWarning,
1272+
stacklevel=2,
1273+
)
12391274
opts = FinalRequestOptions.construct(
1240-
method="post", url=path, json_data=body, files=to_httpx_files(files), **options
1275+
method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12411276
)
12421277
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
12431278

@@ -1247,11 +1282,23 @@ def patch(
12471282
*,
12481283
cast_to: Type[ResponseT],
12491284
body: Body | None = None,
1285+
content: BinaryTypes | None = None,
12501286
files: RequestFiles | None = None,
12511287
options: RequestOptions = {},
12521288
) -> ResponseT:
1289+
if body is not None and content is not None:
1290+
raise TypeError("Passing both `body` and `content` is not supported")
1291+
if files is not None and content is not None:
1292+
raise TypeError("Passing both `files` and `content` is not supported")
1293+
if isinstance(body, bytes):
1294+
warnings.warn(
1295+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1296+
"Please pass raw bytes via the `content` parameter instead.",
1297+
DeprecationWarning,
1298+
stacklevel=2,
1299+
)
12531300
opts = FinalRequestOptions.construct(
1254-
method="patch", url=path, json_data=body, files=to_httpx_files(files), **options
1301+
method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12551302
)
12561303
return self.request(cast_to, opts)
12571304

@@ -1261,11 +1308,23 @@ def put(
12611308
*,
12621309
cast_to: Type[ResponseT],
12631310
body: Body | None = None,
1311+
content: BinaryTypes | None = None,
12641312
files: RequestFiles | None = None,
12651313
options: RequestOptions = {},
12661314
) -> ResponseT:
1315+
if body is not None and content is not None:
1316+
raise TypeError("Passing both `body` and `content` is not supported")
1317+
if files is not None and content is not None:
1318+
raise TypeError("Passing both `files` and `content` is not supported")
1319+
if isinstance(body, bytes):
1320+
warnings.warn(
1321+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1322+
"Please pass raw bytes via the `content` parameter instead.",
1323+
DeprecationWarning,
1324+
stacklevel=2,
1325+
)
12671326
opts = FinalRequestOptions.construct(
1268-
method="put", url=path, json_data=body, files=to_httpx_files(files), **options
1327+
method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12691328
)
12701329
return self.request(cast_to, opts)
12711330

@@ -1275,9 +1334,19 @@ def delete(
12751334
*,
12761335
cast_to: Type[ResponseT],
12771336
body: Body | None = None,
1337+
content: BinaryTypes | None = None,
12781338
options: RequestOptions = {},
12791339
) -> ResponseT:
1280-
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
1340+
if body is not None and content is not None:
1341+
raise TypeError("Passing both `body` and `content` is not supported")
1342+
if isinstance(body, bytes):
1343+
warnings.warn(
1344+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1345+
"Please pass raw bytes via the `content` parameter instead.",
1346+
DeprecationWarning,
1347+
stacklevel=2,
1348+
)
1349+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
12811350
return self.request(cast_to, opts)
12821351

12831352
def get_api_list(
@@ -1717,6 +1786,7 @@ async def post(
17171786
*,
17181787
cast_to: Type[ResponseT],
17191788
body: Body | None = None,
1789+
content: AsyncBinaryTypes | None = None,
17201790
files: RequestFiles | None = None,
17211791
options: RequestOptions = {},
17221792
stream: Literal[False] = False,
@@ -1729,6 +1799,7 @@ async def post(
17291799
*,
17301800
cast_to: Type[ResponseT],
17311801
body: Body | None = None,
1802+
content: AsyncBinaryTypes | None = None,
17321803
files: RequestFiles | None = None,
17331804
options: RequestOptions = {},
17341805
stream: Literal[True],
@@ -1742,6 +1813,7 @@ async def post(
17421813
*,
17431814
cast_to: Type[ResponseT],
17441815
body: Body | None = None,
1816+
content: AsyncBinaryTypes | None = None,
17451817
files: RequestFiles | None = None,
17461818
options: RequestOptions = {},
17471819
stream: bool,
@@ -1754,13 +1826,25 @@ async def post(
17541826
*,
17551827
cast_to: Type[ResponseT],
17561828
body: Body | None = None,
1829+
content: AsyncBinaryTypes | None = None,
17571830
files: RequestFiles | None = None,
17581831
options: RequestOptions = {},
17591832
stream: bool = False,
17601833
stream_cls: type[_AsyncStreamT] | None = None,
17611834
) -> ResponseT | _AsyncStreamT:
1835+
if body is not None and content is not None:
1836+
raise TypeError("Passing both `body` and `content` is not supported")
1837+
if files is not None and content is not None:
1838+
raise TypeError("Passing both `files` and `content` is not supported")
1839+
if isinstance(body, bytes):
1840+
warnings.warn(
1841+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1842+
"Please pass raw bytes via the `content` parameter instead.",
1843+
DeprecationWarning,
1844+
stacklevel=2,
1845+
)
17621846
opts = FinalRequestOptions.construct(
1763-
method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1847+
method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
17641848
)
17651849
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
17661850

@@ -1770,11 +1854,28 @@ async def patch(
17701854
*,
17711855
cast_to: Type[ResponseT],
17721856
body: Body | None = None,
1857+
content: AsyncBinaryTypes | None = None,
17731858
files: RequestFiles | None = None,
17741859
options: RequestOptions = {},
17751860
) -> ResponseT:
1861+
if body is not None and content is not None:
1862+
raise TypeError("Passing both `body` and `content` is not supported")
1863+
if files is not None and content is not None:
1864+
raise TypeError("Passing both `files` and `content` is not supported")
1865+
if isinstance(body, bytes):
1866+
warnings.warn(
1867+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1868+
"Please pass raw bytes via the `content` parameter instead.",
1869+
DeprecationWarning,
1870+
stacklevel=2,
1871+
)
17761872
opts = FinalRequestOptions.construct(
1777-
method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1873+
method="patch",
1874+
url=path,
1875+
json_data=body,
1876+
content=content,
1877+
files=await async_to_httpx_files(files),
1878+
**options,
17781879
)
17791880
return await self.request(cast_to, opts)
17801881

@@ -1784,11 +1885,23 @@ async def put(
17841885
*,
17851886
cast_to: Type[ResponseT],
17861887
body: Body | None = None,
1888+
content: AsyncBinaryTypes | None = None,
17871889
files: RequestFiles | None = None,
17881890
options: RequestOptions = {},
17891891
) -> ResponseT:
1892+
if body is not None and content is not None:
1893+
raise TypeError("Passing both `body` and `content` is not supported")
1894+
if files is not None and content is not None:
1895+
raise TypeError("Passing both `files` and `content` is not supported")
1896+
if isinstance(body, bytes):
1897+
warnings.warn(
1898+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1899+
"Please pass raw bytes via the `content` parameter instead.",
1900+
DeprecationWarning,
1901+
stacklevel=2,
1902+
)
17901903
opts = FinalRequestOptions.construct(
1791-
method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1904+
method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
17921905
)
17931906
return await self.request(cast_to, opts)
17941907

@@ -1798,9 +1911,19 @@ async def delete(
17981911
*,
17991912
cast_to: Type[ResponseT],
18001913
body: Body | None = None,
1914+
content: AsyncBinaryTypes | None = None,
18011915
options: RequestOptions = {},
18021916
) -> ResponseT:
1803-
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
1917+
if body is not None and content is not None:
1918+
raise TypeError("Passing both `body` and `content` is not supported")
1919+
if isinstance(body, bytes):
1920+
warnings.warn(
1921+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1922+
"Please pass raw bytes via the `content` parameter instead.",
1923+
DeprecationWarning,
1924+
stacklevel=2,
1925+
)
1926+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
18041927
return await self.request(cast_to, opts)
18051928

18061929
def get_api_list(

src/kernel/_models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@
33
import os
44
import inspect
55
import weakref
6-
from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast
6+
from typing import (
7+
IO,
8+
TYPE_CHECKING,
9+
Any,
10+
Type,
11+
Union,
12+
Generic,
13+
TypeVar,
14+
Callable,
15+
Iterable,
16+
Optional,
17+
AsyncIterable,
18+
cast,
19+
)
720
from datetime import date, datetime
821
from typing_extensions import (
922
List,
@@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
787800
timeout: float | Timeout | None
788801
files: HttpxRequestFiles | None
789802
idempotency_key: str
803+
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None]
790804
json_data: Body
791805
extra_json: AnyMapping
792806
follow_redirects: bool
@@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel):
805819
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
806820
follow_redirects: Union[bool, None] = None
807821

822+
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
808823
# It should be noted that we cannot use `json` here as that would override
809824
# a BaseModel method in an incompatible fashion.
810825
json_data: Union[Body, None] = None

src/kernel/_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
Mapping,
1414
TypeVar,
1515
Callable,
16+
Iterable,
1617
Iterator,
1718
Optional,
1819
Sequence,
20+
AsyncIterable,
1921
)
2022
from typing_extensions import (
2123
Set,
@@ -56,6 +58,13 @@
5658
else:
5759
Base64FileInput = Union[IO[bytes], PathLike]
5860
FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8.
61+
62+
63+
# Used for sending raw binary data / streaming data in request bodies
64+
# e.g. for file uploads without multipart encoding
65+
BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]]
66+
AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]]
67+
5968
FileTypes = Union[
6069
# file (or bytes)
6170
FileContent,

0 commit comments

Comments
 (0)