Skip to content

Commit 7f9db48

Browse files
feat(client): improve file upload types (#148)
1 parent 26c8928 commit 7f9db48

10 files changed

+295
-19
lines changed

mypy.ini

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
[mypy]
22
pretty = True
33
show_error_codes = True
4-
exclude = _dev
4+
5+
# Exclude _files.py because mypy isn't smart enough to apply
6+
# the correct type narrowing and as this is an internal module
7+
# it's fine to just use Pyright.
8+
exclude = ^(src/finch/_files\.py|_dev/.*\.py)$
59

610
strict_equality = True
711
implicit_reexport = True

pyproject.toml

+10
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dev-dependencies = [
3838
"isort==5.10.1",
3939
"time-machine==2.9.0",
4040
"nox==2023.4.22",
41+
"dirty-equals>=0.6.0",
4142

4243
]
4344

@@ -53,6 +54,15 @@ format = { chain = [
5354
"format:ruff" = "ruff --fix ."
5455
"format:isort" = "isort ."
5556

57+
typecheck = { chain = [
58+
"typecheck:pyright",
59+
"typecheck:verify-types",
60+
"typecheck:mypy"
61+
]}
62+
"typecheck:pyright" = "pyright"
63+
"typecheck:verify-types" = "pyright --verifytypes finch --ignoreexternal"
64+
"typecheck:mypy" = "mypy --enable-incomplete-feature=Unpack ."
65+
5666
[build-system]
5767
requires = ["hatchling"]
5868
build-backend = "hatchling.build"

requirements-dev.lock

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ black==23.3.0
1515
certifi==2023.7.22
1616
click==8.1.7
1717
colorlog==6.7.0
18+
dirty-equals==0.6.0
1819
distlib==0.3.7
1920
distro==1.8.0
2021
exceptiongroup==1.1.3
@@ -40,6 +41,7 @@ pyright==1.1.332
4041
pytest==7.1.1
4142
pytest-asyncio==0.21.1
4243
python-dateutil==2.8.2
44+
pytz==2023.3.post1
4345
respx==0.19.2
4446
rfc3986==1.5.0
4547
ruff==0.0.282

src/finch/_base_client.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from . import _exceptions
4242
from ._qs import Querystring
43+
from ._files import to_httpx_files, async_to_httpx_files
4344
from ._types import (
4445
NOT_GIVEN,
4546
Body,
@@ -1088,7 +1089,9 @@ def post(
10881089
stream: bool = False,
10891090
stream_cls: type[_StreamT] | None = None,
10901091
) -> ResponseT | _StreamT:
1091-
opts = FinalRequestOptions.construct(method="post", url=path, json_data=body, files=files, **options)
1092+
opts = FinalRequestOptions.construct(
1093+
method="post", url=path, json_data=body, files=to_httpx_files(files), **options
1094+
)
10921095
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
10931096

10941097
def patch(
@@ -1111,7 +1114,9 @@ def put(
11111114
files: RequestFiles | None = None,
11121115
options: RequestOptions = {},
11131116
) -> ResponseT:
1114-
opts = FinalRequestOptions.construct(method="put", url=path, json_data=body, files=files, **options)
1117+
opts = FinalRequestOptions.construct(
1118+
method="put", url=path, json_data=body, files=to_httpx_files(files), **options
1119+
)
11151120
return self.request(cast_to, opts)
11161121

11171122
def delete(
@@ -1491,7 +1496,9 @@ async def post(
14911496
stream: bool = False,
14921497
stream_cls: type[_AsyncStreamT] | None = None,
14931498
) -> ResponseT | _AsyncStreamT:
1494-
opts = FinalRequestOptions.construct(method="post", url=path, json_data=body, files=files, **options)
1499+
opts = FinalRequestOptions.construct(
1500+
method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1501+
)
14951502
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
14961503

14971504
async def patch(
@@ -1514,7 +1521,9 @@ async def put(
15141521
files: RequestFiles | None = None,
15151522
options: RequestOptions = {},
15161523
) -> ResponseT:
1517-
opts = FinalRequestOptions.construct(method="put", url=path, json_data=body, files=files, **options)
1524+
opts = FinalRequestOptions.construct(
1525+
method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1526+
)
15181527
return await self.request(cast_to, opts)
15191528

15201529
async def delete(

src/finch/_files.py

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import os
5+
import pathlib
6+
from typing import overload
7+
from typing_extensions import TypeGuard
8+
9+
import anyio
10+
11+
from ._types import (
12+
FileTypes,
13+
FileContent,
14+
RequestFiles,
15+
HttpxFileTypes,
16+
HttpxFileContent,
17+
HttpxRequestFiles,
18+
)
19+
from ._utils import is_tuple_t, is_mapping_t, is_sequence_t
20+
21+
22+
def is_file_content(obj: object) -> TypeGuard[FileContent]:
23+
return (
24+
isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike)
25+
)
26+
27+
28+
def assert_is_file_content(obj: object, *, key: str | None = None) -> None:
29+
if not is_file_content(obj):
30+
prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`"
31+
raise RuntimeError(
32+
f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead."
33+
) from None
34+
35+
36+
@overload
37+
def to_httpx_files(files: None) -> None:
38+
...
39+
40+
41+
@overload
42+
def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles:
43+
...
44+
45+
46+
def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None:
47+
if files is None:
48+
return None
49+
50+
if is_mapping_t(files):
51+
files = {key: _transform_file(file) for key, file in files.items()}
52+
elif is_sequence_t(files):
53+
files = [(key, _transform_file(file)) for key, file in files]
54+
else:
55+
raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence")
56+
57+
return files
58+
59+
60+
def _transform_file(file: FileTypes) -> HttpxFileTypes:
61+
if is_file_content(file):
62+
if isinstance(file, os.PathLike):
63+
path = pathlib.Path(file)
64+
return (path.name, path.read_bytes())
65+
66+
return file
67+
68+
if is_tuple_t(file):
69+
return (file[0], _read_file_content(file[1]), *file[2:])
70+
71+
raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple")
72+
73+
74+
def _read_file_content(file: FileContent) -> HttpxFileContent:
75+
if isinstance(file, os.PathLike):
76+
return pathlib.Path(file).read_bytes()
77+
return file
78+
79+
80+
@overload
81+
async def async_to_httpx_files(files: None) -> None:
82+
...
83+
84+
85+
@overload
86+
async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles:
87+
...
88+
89+
90+
async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None:
91+
if files is None:
92+
return None
93+
94+
if is_mapping_t(files):
95+
files = {key: await _async_transform_file(file) for key, file in files.items()}
96+
elif is_sequence_t(files):
97+
files = [(key, await _async_transform_file(file)) for key, file in files]
98+
else:
99+
raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence")
100+
101+
return files
102+
103+
104+
async def _async_transform_file(file: FileTypes) -> HttpxFileTypes:
105+
if is_file_content(file):
106+
if isinstance(file, os.PathLike):
107+
path = anyio.Path(file)
108+
return (path.name, await path.read_bytes())
109+
110+
return file
111+
112+
if is_tuple_t(file):
113+
return (file[0], await _async_read_file_content(file[1]), *file[2:])
114+
115+
raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple")
116+
117+
118+
async def _async_read_file_content(file: FileContent) -> HttpxFileContent:
119+
if isinstance(file, os.PathLike):
120+
return await anyio.Path(file).read_bytes()
121+
122+
return file

src/finch/_models.py

+29-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
import inspect
44
from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, cast
55
from datetime import date, datetime
6-
from typing_extensions import Literal, ClassVar, Protocol, final, runtime_checkable
6+
from typing_extensions import (
7+
Unpack,
8+
Literal,
9+
ClassVar,
10+
Protocol,
11+
Required,
12+
TypedDict,
13+
final,
14+
runtime_checkable,
15+
)
716

817
import pydantic
918
import pydantic.generics
@@ -18,7 +27,7 @@
1827
Timeout,
1928
NotGiven,
2029
AnyMapping,
21-
RequestFiles,
30+
HttpxRequestFiles,
2231
)
2332
from ._utils import is_list, is_mapping, parse_date, parse_datetime, strip_not_given
2433
from ._compat import PYDANTIC_V2, ConfigDict
@@ -363,6 +372,19 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
363372
return RootModel[type_] # type: ignore
364373

365374

375+
class FinalRequestOptionsInput(TypedDict, total=False):
376+
method: Required[str]
377+
url: Required[str]
378+
params: Query
379+
headers: Headers
380+
max_retries: int
381+
timeout: float | Timeout | None
382+
files: HttpxRequestFiles | None
383+
idempotency_key: str
384+
json_data: Body
385+
extra_json: AnyMapping
386+
387+
366388
@final
367389
class FinalRequestOptions(pydantic.BaseModel):
368390
method: str
@@ -371,7 +393,7 @@ class FinalRequestOptions(pydantic.BaseModel):
371393
headers: Union[Headers, NotGiven] = NotGiven()
372394
max_retries: Union[int, NotGiven] = NotGiven()
373395
timeout: Union[float, Timeout, None, NotGiven] = NotGiven()
374-
files: Union[RequestFiles, None] = None
396+
files: Union[HttpxRequestFiles, None] = None
375397
idempotency_key: Union[str, None] = None
376398

377399
# It should be noted that we cannot use `json` here as that would override
@@ -395,11 +417,13 @@ def get_max_retries(self, max_retries: int) -> int:
395417
# this is necessary as we don't want to do any actual runtime type checking
396418
# (which means we can't use validators) but we do want to ensure that `NotGiven`
397419
# values are not present
420+
#
421+
# type ignore required because we're adding explicit types to `**values`
398422
@classmethod
399-
def construct(
423+
def construct( # type: ignore
400424
cls,
401425
_fields_set: set[str] | None = None,
402-
**values: Any,
426+
**values: Unpack[FinalRequestOptionsInput],
403427
) -> FinalRequestOptions:
404428
kwargs: dict[str, Any] = {
405429
# we unconditionally call `strip_not_given` on any value

src/finch/_types.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from os import PathLike
34
from typing import (
45
IO,
56
TYPE_CHECKING,
@@ -32,9 +33,10 @@
3233
_T = TypeVar("_T")
3334

3435
# Approximates httpx internal ProxiesTypes and RequestFiles types
36+
# while adding support for `PathLike` instances
3537
ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]]
3638
ProxiesTypes = Union[str, Proxy, ProxiesDict]
37-
FileContent = Union[IO[bytes], bytes]
39+
FileContent = Union[IO[bytes], bytes, PathLike[str]]
3840
FileTypes = Union[
3941
# file (or bytes)
4042
FileContent,
@@ -47,6 +49,19 @@
4749
]
4850
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
4951

52+
# duplicate of the above but without our custom file support
53+
HttpxFileContent = Union[IO[bytes], bytes]
54+
HttpxFileTypes = Union[
55+
# file (or bytes)
56+
HttpxFileContent,
57+
# (filename, file (or bytes))
58+
Tuple[Optional[str], HttpxFileContent],
59+
# (filename, file (or bytes), content_type)
60+
Tuple[Optional[str], HttpxFileContent, Optional[str]],
61+
# (filename, file (or bytes), content_type, headers)
62+
Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]],
63+
]
64+
HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]]
5065

5166
# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT
5267
# where ResponseT includes `None`. In order to support directly

src/finch/_utils/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
from ._utils import is_dict as is_dict
44
from ._utils import is_list as is_list
55
from ._utils import is_given as is_given
6+
from ._utils import is_tuple as is_tuple
67
from ._utils import is_mapping as is_mapping
8+
from ._utils import is_tuple_t as is_tuple_t
79
from ._utils import parse_date as parse_date
10+
from ._utils import is_sequence as is_sequence
811
from ._utils import coerce_float as coerce_float
912
from ._utils import is_list_type as is_list_type
13+
from ._utils import is_mapping_t as is_mapping_t
1014
from ._utils import removeprefix as removeprefix
1115
from ._utils import removesuffix as removesuffix
1216
from ._utils import extract_files as extract_files
17+
from ._utils import is_sequence_t as is_sequence_t
1318
from ._utils import is_union_type as is_union_type
1419
from ._utils import required_args as required_args
1520
from ._utils import coerce_boolean as coerce_boolean

0 commit comments

Comments
 (0)