Skip to content

Commit 9aca168

Browse files
wip first cut file uploads
Assisted-by: Codex
1 parent bdfbe19 commit 9aca168

8 files changed

Lines changed: 385 additions & 36 deletions

File tree

src/pdfrest/client.py

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import os
67
import uuid
7-
from collections.abc import Mapping
8-
from typing import Any, Generic, Literal, TypeVar
8+
from collections.abc import Iterable, Mapping, Sequence
9+
from pathlib import Path
10+
from typing import IO, Any, Generic, Literal, TypeVar, cast
911

1012
import httpx
1113
from httpx import URL
@@ -17,14 +19,16 @@
1719
PdfRestConfigurationError,
1820
translate_httpx_error,
1921
)
20-
from .models import PdfRestErrorResponse, UpResponse
22+
from .models import PdfRestErrorResponse, PdfRestFile, UpResponse
2123

2224
__all__ = ("AsyncPdfRestClient", "PdfRestClient")
2325

2426
DEFAULT_BASE_URL = "https://api.pdfrest.com"
2527
API_KEY_ENV_VAR = "PDFREST_API_KEY"
2628
API_KEY_HEADER_NAME = "Api-Key"
2729
DEFAULT_TIMEOUT_SECONDS = 10.0
30+
FILE_UPLOAD_FIELD_NAME = "file"
31+
DEFAULT_FILE_INFO_CONCURRENCY = 8
2832

2933
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
3034
QueryParamValue = str | int | float | bool | None
@@ -34,6 +38,49 @@
3438
Body = Mapping[str, Any]
3539

3640

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
62+
63+
64+
def _extract_uploaded_file_ids(payload: Any) -> list[str]:
65+
try:
66+
files_payload = payload["files"]
67+
except (TypeError, KeyError) as exc: # pragma: no cover - defensive
68+
raise PdfRestApiError(
69+
500, message="Upload response missing 'files' collection."
70+
) from exc
71+
if not isinstance(files_payload, Sequence): # pragma: no cover - defensive
72+
raise PdfRestApiError(500, message="Upload response 'files' is not a sequence.")
73+
entries = cast(Sequence[Mapping[str, Any]], files_payload)
74+
file_ids: list[str] = []
75+
for entry in entries:
76+
if "id" not in entry:
77+
raise PdfRestApiError(
78+
500, message="Upload response contains invalid file references."
79+
)
80+
file_ids.append(str(entry["id"]))
81+
return file_ids
82+
83+
3784
ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient)
3885

3986

@@ -103,6 +150,8 @@ class _RequestModel(BaseModel):
103150
headers: dict[str, str] = Field(default_factory=dict)
104151
timeout: TimeoutTypes
105152
json_body: dict[str, Any] | None = None
153+
files: Any | None = None
154+
data: Any | None = None
106155

107156
model_config = ConfigDict(arbitrary_types_allowed=True)
108157

@@ -205,6 +254,8 @@ def _prepare_request(
205254
extra_headers: AnyMapping | None = None,
206255
extra_body: Body | None = None,
207256
timeout: TimeoutTypes | None = None,
257+
files: Any | None = None,
258+
data: Any | None = None,
208259
) -> _RequestModel:
209260
headers = self._compose_headers(extra_headers)
210261
params = self._compose_query_params(query, extra_query)
@@ -219,13 +270,42 @@ def _prepare_request(
219270
headers=headers,
220271
timeout=timeout_value,
221272
json_body=json_payload,
273+
files=files,
274+
data=data,
222275
)
223276
except PdfRestConfigurationError:
224277
raise
225278
except ValidationError as exc: # pragma: no cover - defensive
226279
raise PdfRestConfigurationError(str(exc)) from exc
227280
return request
228281

282+
def prepare_request(
283+
self,
284+
method: HttpMethod,
285+
endpoint: str,
286+
*,
287+
query: Query | None = None,
288+
json_body: Body | None = None,
289+
extra_query: Query | None = None,
290+
extra_headers: AnyMapping | None = None,
291+
extra_body: Body | None = None,
292+
timeout: TimeoutTypes | None = None,
293+
files: Any | None = None,
294+
data: Any | None = None,
295+
) -> _RequestModel:
296+
return self._prepare_request(
297+
method,
298+
endpoint,
299+
query=query,
300+
json_body=json_body,
301+
extra_query=extra_query,
302+
extra_headers=extra_headers,
303+
extra_body=extra_body,
304+
timeout=timeout,
305+
files=files,
306+
data=data,
307+
)
308+
229309
def _compose_headers(self, extra_headers: AnyMapping | None) -> dict[str, str]:
230310
combined_headers: dict[str, str] = dict(self._config.headers)
231311
if extra_headers is None:
@@ -351,11 +431,25 @@ def _send_request(self, request: _RequestModel) -> Any:
351431
headers=request.headers or None,
352432
timeout=request.timeout,
353433
json=request.json_body,
434+
files=request.files,
435+
data=request.data,
354436
)
355437
except httpx.HTTPError as exc:
356438
raise translate_httpx_error(exc) from exc
357439
return self._handle_response(response)
358440

441+
def send_request(self, request: _RequestModel) -> Any:
442+
return self._send_request(request)
443+
444+
def fetch_file_info(self, file_id: str) -> PdfRestFile:
445+
request = self.prepare_request(
446+
"GET",
447+
f"/resource/{file_id}",
448+
query={"format": "info"},
449+
)
450+
payload = self._send_request(request)
451+
return PdfRestFile.model_validate(payload)
452+
359453

360454
class _AsyncApiClient(_BaseApiClient[httpx.AsyncClient]):
361455
"""Internal asynchronous client implementation."""
@@ -406,11 +500,67 @@ async def _send_request(self, request: _RequestModel) -> Any:
406500
headers=request.headers or None,
407501
timeout=request.timeout,
408502
json=request.json_body,
503+
files=request.files,
504+
data=request.data,
409505
)
410506
except httpx.HTTPError as exc:
411507
raise translate_httpx_error(exc) from exc
412508
return self._handle_response(response)
413509

510+
async def send_request(self, request: _RequestModel) -> Any:
511+
return await self._send_request(request)
512+
513+
async def fetch_file_info(self, file_id: str) -> PdfRestFile:
514+
request = self.prepare_request(
515+
"GET",
516+
f"/resource/{file_id}",
517+
query={"format": "info"},
518+
)
519+
payload = await self._send_request(request)
520+
return PdfRestFile.model_validate(payload)
521+
522+
523+
class _FilesClient:
524+
"""Expose file-related operations for the synchronous client."""
525+
526+
def __init__(self, client: _SyncApiClient) -> None:
527+
self._client = client
528+
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)
533+
payload = self._client.send_request(request)
534+
file_ids = _extract_uploaded_file_ids(payload)
535+
return [self._client.fetch_file_info(file_id) for file_id in file_ids]
536+
537+
538+
class _AsyncFilesClient:
539+
"""Expose file-related operations for the asynchronous client."""
540+
541+
def __init__(
542+
self,
543+
client: _AsyncApiClient,
544+
*,
545+
concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY,
546+
) -> None:
547+
self._client = client
548+
self._concurrency_limit = concurrency_limit
549+
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)
554+
payload = await self._client.send_request(request)
555+
file_ids = _extract_uploaded_file_ids(payload)
556+
semaphore = asyncio.Semaphore(self._concurrency_limit)
557+
558+
async def fetch(file_id: str) -> PdfRestFile:
559+
async with semaphore:
560+
return await self._client.fetch_file_info(file_id)
561+
562+
return await asyncio.gather(*(fetch(file_id) for file_id in file_ids))
563+
414564

415565
class PdfRestClient(_SyncApiClient):
416566
"""Synchronous client for interacting with the pdfrest API."""
@@ -435,6 +585,7 @@ def __init__(
435585
http_client=http_client,
436586
transport=transport,
437587
)
588+
self._files_client = _FilesClient(self)
438589

439590
def __enter__(self) -> PdfRestClient:
440591
super().__enter__()
@@ -443,6 +594,10 @@ def __enter__(self) -> PdfRestClient:
443594
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
444595
super().__exit__(exc_type, exc, traceback)
445596

597+
@property
598+
def files(self) -> _FilesClient:
599+
return self._files_client
600+
446601
def up(
447602
self,
448603
*,
@@ -488,6 +643,7 @@ def __init__(
488643
http_client=http_client,
489644
transport=transport,
490645
)
646+
self._files_client = _AsyncFilesClient(self)
491647

492648
async def __aenter__(self) -> AsyncPdfRestClient:
493649
await super().__aenter__()
@@ -496,6 +652,10 @@ async def __aenter__(self) -> AsyncPdfRestClient:
496652
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
497653
await super().__aexit__(exc_type, exc, traceback)
498654

655+
@property
656+
def files(self) -> _AsyncFilesClient:
657+
return self._files_client
658+
499659
async def up(
500660
self,
501661
*,

src/pdfrest/models.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
from datetime import date
66

7-
from pydantic import BaseModel, ConfigDict, Field
7+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, HttpUrl
88

9-
__all__ = ("PdfRestErrorResponse", "UpResponse")
9+
__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse")
1010

1111

1212
class UpResponse(BaseModel):
@@ -25,3 +25,37 @@ class PdfRestErrorResponse(BaseModel):
2525

2626
error: str | None = Field(alias="message")
2727
model_config = ConfigDict(extra="allow", frozen=True)
28+
29+
30+
class PdfRestFile(BaseModel):
31+
"""Represents a file on the pdfRest server."""
32+
33+
id: str = Field(
34+
min_length=1,
35+
description="Identifier of the file on the pdfRest server",
36+
)
37+
name: str = Field(
38+
min_length=1,
39+
description="Name of the file",
40+
)
41+
url: HttpUrl = Field(
42+
description="URL from which the file can be downloaded",
43+
)
44+
type: str = Field(
45+
min_length=1,
46+
description="MIME type of the file",
47+
)
48+
size: int = Field(
49+
description="Size of the file",
50+
)
51+
modified: AwareDatetime = Field(
52+
description="The last modified time of the file, which must include time zone "
53+
"info.",
54+
)
55+
scheduled_deletion_time_utc: AwareDatetime | None = Field(
56+
alias="scheduledDeletionTimeUtc",
57+
default=None,
58+
description="The UTC time at which the file will be deleted from the server.",
59+
)
60+
61+
model_config = ConfigDict(frozen=True)

tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
import httpx
6+
import pytest
7+
8+
LIVE_BASE_URL_CANDIDATES: tuple[str, ...] = (
9+
"http://localhost:3000",
10+
"https://apidev.pdfrest.com",
11+
"https://api.pdfrest.com",
12+
)
13+
14+
15+
@pytest.fixture(scope="session")
16+
def pdfrest_api_key() -> str:
17+
key = os.getenv("PDFREST_API_KEY")
18+
if not key:
19+
pytest.fail("PDFREST_API_KEY is not configured.")
20+
return key
21+
22+
23+
@pytest.fixture(scope="session")
24+
def pdfrest_live_base_url(pdfrest_api_key: str) -> str:
25+
headers = {"Authorization": f"Bearer {pdfrest_api_key}"}
26+
timeout = httpx.Timeout(2.0)
27+
for base_url in LIVE_BASE_URL_CANDIDATES:
28+
try:
29+
with httpx.Client(base_url=base_url, timeout=timeout) as client:
30+
response = client.get("/up", headers=headers)
31+
except httpx.HTTPError:
32+
continue
33+
if response.is_success:
34+
return base_url
35+
pytest.fail("No reachable pdfRest API instance for live tests.")

tests/resources/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pathlib import Path
2+
3+
here = Path(__file__).parent
4+
5+
6+
def get_test_resource_path(*pathsegments: str) -> Path:
7+
return here.joinpath(*pathsegments)

tests/resources/report.docx

8.47 KB
Binary file not shown.

tests/resources/report.pdf

25 KB
Binary file not shown.

0 commit comments

Comments
 (0)