Skip to content

Commit a2cd781

Browse files
Refactor file upload handling in clients and tests
- Replaced `Iterable[IO[bytes]]` file input normalization with comprehensive `UploadFiles` support for raw content and multipart tuples. - Added `create_from_paths` methods for the client, supporting file uploads by paths with optional content type and headers. - Updated synchronous and asynchronous file creation flows to align with the new normalization approach. - Extended test coverage for new client methods and file upload functionality. Assisted-by: Codex
1 parent 9aca168 commit a2cd781

2 files changed

Lines changed: 452 additions & 44 deletions

File tree

src/pdfrest/client.py

Lines changed: 216 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import asyncio
66
import os
77
import uuid
8-
from collections.abc import Iterable, Mapping, Sequence
8+
from collections.abc import Mapping, Sequence
9+
from contextlib import ExitStack
10+
from os import PathLike
911
from pathlib import Path
10-
from typing import IO, Any, Generic, Literal, TypeVar, cast
12+
from typing import IO, Any, Generic, Literal, TypeAlias, TypeVar, cast
1113

1214
import httpx
1315
from httpx import URL
@@ -37,28 +39,18 @@
3739
Query = Mapping[str, QueryParamValue]
3840
Body = Mapping[str, Any]
3941

42+
FileContent = IO[bytes] | bytes | str
43+
FileTuple2 = tuple[str | None, FileContent]
44+
FileTuple3 = tuple[str | None, FileContent, str | None]
45+
FileTuple4 = tuple[str | None, FileContent, str | None, Mapping[str, str]]
46+
FileTypes = FileContent | FileTuple2 | FileTuple3 | FileTuple4
47+
UploadFiles = Mapping[str, FileTypes] | Sequence[tuple[str, FileTypes]]
4048

41-
def _normalize_file_inputs(files: Iterable[IO[bytes]]) -> list[IO[bytes]]:
42-
normalized = list(files)
43-
if not normalized:
44-
msg = "At least one file must be provided."
45-
raise ValueError(msg)
46-
for file_obj in normalized:
47-
if not hasattr(file_obj, "read"):
48-
msg = "files must be file-like objects opened in binary mode."
49-
raise TypeError(msg)
50-
return normalized
51-
52-
53-
def _build_multipart_payload(
54-
file_objects: Sequence[IO[bytes]],
55-
) -> list[tuple[str, tuple[str, IO[bytes], str | None]]]:
56-
multipart: list[tuple[str, tuple[str, IO[bytes], str | None]]] = []
57-
for file_obj in file_objects:
58-
name_attr = getattr(file_obj, "name", None)
59-
filename = Path(str(name_attr)).name if name_attr else FILE_UPLOAD_FIELD_NAME
60-
multipart.append((FILE_UPLOAD_FIELD_NAME, (filename, file_obj, None)))
61-
return multipart
49+
FilePath = str | PathLike[str]
50+
FilePathTuple2 = tuple[FilePath, str | None]
51+
FilePathTuple3 = tuple[FilePath, str | None, Mapping[str, str]]
52+
FilePathTypes = FilePath | FilePathTuple2 | FilePathTuple3
53+
NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4
6254

6355

6456
def _extract_uploaded_file_ids(payload: Any) -> list[str]:
@@ -81,6 +73,108 @@ def _extract_uploaded_file_ids(payload: Any) -> list[str]:
8173
return file_ids
8274

8375

76+
def _normalize_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
77+
return {str(key): str(value) for key, value in headers.items()}
78+
79+
80+
def _ensure_file_content(value: FileContent) -> FileContent:
81+
if isinstance(value, (bytes, str)):
82+
return value
83+
if hasattr(value, "read"):
84+
return value
85+
msg = "File content must be a readable binary stream, bytes, or str."
86+
raise TypeError(msg)
87+
88+
89+
def _normalize_file_type(file_value: FileTypes) -> NormalizedFileTypes:
90+
if isinstance(file_value, tuple):
91+
length = len(file_value)
92+
if length not in {2, 3, 4}:
93+
msg = "File tuple inputs must contain 2, 3, or 4 items."
94+
raise TypeError(msg)
95+
if length == 2:
96+
filename, content = cast(FileTuple2, file_value)
97+
normalized_filename = str(filename) if filename is not None else None
98+
normalized_content = _ensure_file_content(content)
99+
return (normalized_filename, normalized_content)
100+
if length == 3:
101+
filename, content, content_type = cast(FileTuple3, file_value)
102+
normalized_filename = str(filename) if filename is not None else None
103+
normalized_content = _ensure_file_content(content)
104+
normalized_content_type = (
105+
str(content_type) if content_type is not None else None
106+
)
107+
return (normalized_filename, normalized_content, normalized_content_type)
108+
109+
filename, content, content_type, headers = cast(FileTuple4, file_value)
110+
normalized_filename = str(filename) if filename is not None else None
111+
normalized_content = _ensure_file_content(content)
112+
normalized_content_type = (
113+
str(content_type) if content_type is not None else None
114+
)
115+
if not isinstance(headers, Mapping):
116+
msg = "Headers must be provided as a mapping of str keys to str values."
117+
raise TypeError(msg)
118+
normalized_headers = _normalize_headers(headers)
119+
return (
120+
normalized_filename,
121+
normalized_content,
122+
normalized_content_type,
123+
normalized_headers,
124+
)
125+
return _ensure_file_content(file_value)
126+
127+
128+
def _normalize_upload_files(
129+
files: UploadFiles,
130+
) -> list[tuple[str, NormalizedFileTypes]]:
131+
is_mapping = isinstance(files, Mapping)
132+
if is_mapping:
133+
mapping_files = cast(Mapping[str, FileTypes], files)
134+
items: list[tuple[str, FileTypes]] = list(mapping_files.items())
135+
else:
136+
sequence_files = cast(Sequence[tuple[str, FileTypes]], files)
137+
items = list(sequence_files)
138+
if not items:
139+
msg = "At least one file must be provided."
140+
raise ValueError(msg)
141+
normalized_items: list[tuple[str, NormalizedFileTypes]] = []
142+
for entry in items:
143+
if is_mapping:
144+
field_name, file_value = entry
145+
else:
146+
if not isinstance(entry, tuple) or len(entry) != 2:
147+
msg = "Files sequence entries must be (field_name, file_value) tuples."
148+
raise TypeError(msg)
149+
field_name, file_value = entry
150+
normalized_items.append((str(field_name), _normalize_file_type(file_value)))
151+
return normalized_items
152+
153+
154+
def _parse_path_spec(spec: FilePathTypes) -> tuple[Path, str | None, Mapping[str, str]]:
155+
if isinstance(spec, tuple):
156+
length = len(spec)
157+
if length == 2:
158+
raw_path, content_type = cast(FilePathTuple2, spec)
159+
headers: Mapping[str, str] = {}
160+
elif length == 3:
161+
raw_path, content_type, headers = cast(FilePathTuple3, spec)
162+
if not isinstance(headers, Mapping):
163+
msg = "Headers must be provided as a mapping of str keys to str values."
164+
raise TypeError(msg)
165+
else:
166+
msg = "File path tuples must contain a path plus optional content type and headers."
167+
raise TypeError(msg)
168+
normalized_headers = _normalize_headers(headers)
169+
normalized_content_type = (
170+
str(content_type) if content_type is not None else None
171+
)
172+
path = Path(raw_path)
173+
return path, normalized_content_type, normalized_headers
174+
path = Path(spec)
175+
return path, None, {}
176+
177+
84178
ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient)
85179

86180

@@ -526,14 +620,60 @@ class _FilesClient:
526620
def __init__(self, client: _SyncApiClient) -> None:
527621
self._client = client
528622

529-
def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]:
530-
file_objects = _normalize_file_inputs(files)
531-
multipart = _build_multipart_payload(file_objects)
532-
request = self._client.prepare_request("POST", "/upload", files=multipart)
623+
def create(self, files: UploadFiles) -> list[PdfRestFile]:
624+
"""Upload one or more files by content, in the same style accepted by
625+
the `files` parameter of `httpx.Client.post`.
626+
627+
Provide either a mapping of field names to file specifications, or a
628+
sequence of `(field_name, file_spec)` tuples. File specifications may be
629+
raw file-like objects, bytes, str, or the tuple forms documented by
630+
httpx.
631+
"""
632+
normalized_files = _normalize_upload_files(files)
633+
request = self._client.prepare_request(
634+
"POST", "/upload", files=normalized_files
635+
)
533636
payload = self._client.send_request(request)
534637
file_ids = _extract_uploaded_file_ids(payload)
535638
return [self._client.fetch_file_info(file_id) for file_id in file_ids]
536639

640+
def create_from_paths(
641+
self, file_paths: Sequence[FilePathTypes]
642+
) -> list[PdfRestFile]:
643+
"""Upload one or more files by their path.
644+
645+
Each entry may be a bare path-like object or a tuple of
646+
`(path, content_type)` / `(path, content_type, headers)` where headers
647+
mirrors the httpx multipart header mapping. All opened file handles are
648+
closed once the request completes.
649+
"""
650+
if not file_paths:
651+
msg = "At least one file path must be provided."
652+
raise ValueError(msg)
653+
654+
with ExitStack() as stack:
655+
upload_entries: list[tuple[str, FileTypes]] = []
656+
for spec in file_paths:
657+
path, content_type, headers = _parse_path_spec(spec)
658+
file_obj = stack.enter_context(path.open("rb"))
659+
filename = path.name
660+
if headers:
661+
upload_entries.append(
662+
(
663+
FILE_UPLOAD_FIELD_NAME,
664+
(filename, file_obj, content_type, headers),
665+
)
666+
)
667+
elif content_type is not None:
668+
upload_entries.append(
669+
(FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type))
670+
)
671+
else:
672+
upload_entries.append(
673+
(FILE_UPLOAD_FIELD_NAME, (filename, file_obj))
674+
)
675+
return self.create(upload_entries)
676+
537677

538678
class _AsyncFilesClient:
539679
"""Expose file-related operations for the asynchronous client."""
@@ -547,10 +687,18 @@ def __init__(
547687
self._client = client
548688
self._concurrency_limit = concurrency_limit
549689

550-
async def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]:
551-
file_objects = _normalize_file_inputs(files)
552-
multipart = _build_multipart_payload(file_objects)
553-
request = self._client.prepare_request("POST", "/upload", files=multipart)
690+
async def create(self, files: UploadFiles) -> list[PdfRestFile]:
691+
"""Upload one or more files by content, in the same style accepted by
692+
the `files` parameter of `httpx.AsyncClient.post`.
693+
694+
Provide either a mapping of field names to file specifications, or a
695+
sequence of `(field_name, file_spec)` tuples. File specifications may be
696+
raw file-like objects, bytes, str, or the tuple forms documented by
697+
httpx."""
698+
normalized_files = _normalize_upload_files(files)
699+
request = self._client.prepare_request(
700+
"POST", "/upload", files=normalized_files
701+
)
554702
payload = await self._client.send_request(request)
555703
file_ids = _extract_uploaded_file_ids(payload)
556704
semaphore = asyncio.Semaphore(self._concurrency_limit)
@@ -561,6 +709,43 @@ async def fetch(file_id: str) -> PdfRestFile:
561709

562710
return await asyncio.gather(*(fetch(file_id) for file_id in file_ids))
563711

712+
async def create_from_paths(
713+
self, file_paths: Sequence[FilePathTypes]
714+
) -> list[PdfRestFile]:
715+
"""Upload one or more files by their path.
716+
717+
Each entry may be a bare path-like object or a tuple of
718+
`(path, content_type)` / `(path, content_type, headers)` where headers
719+
mirrors the httpx multipart header mapping. All opened file handles are
720+
closed once the request completes.
721+
"""
722+
if not file_paths:
723+
msg = "At least one file path must be provided."
724+
raise ValueError(msg)
725+
726+
with ExitStack() as stack:
727+
upload_entries: list[tuple[str, FileTypes]] = []
728+
for spec in file_paths:
729+
path, content_type, headers = _parse_path_spec(spec)
730+
file_obj = stack.enter_context(path.open("rb"))
731+
filename = path.name
732+
if headers:
733+
upload_entries.append(
734+
(
735+
FILE_UPLOAD_FIELD_NAME,
736+
(filename, file_obj, content_type, headers),
737+
)
738+
)
739+
elif content_type is not None:
740+
upload_entries.append(
741+
(FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type))
742+
)
743+
else:
744+
upload_entries.append(
745+
(FILE_UPLOAD_FIELD_NAME, (filename, file_obj))
746+
)
747+
return await self.create(upload_entries)
748+
564749

565750
class PdfRestClient(_SyncApiClient):
566751
"""Synchronous client for interacting with the pdfrest API."""

0 commit comments

Comments
 (0)