Skip to content

Commit d16f9e0

Browse files
wip first cut file uploads
1 parent 66652f5 commit d16f9e0

8 files changed

Lines changed: 384 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,13 +19,15 @@
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
DEFAULT_TIMEOUT_SECONDS = 10.0
29+
FILE_UPLOAD_FIELD_NAME = "file"
30+
DEFAULT_FILE_INFO_CONCURRENCY = 8
2731

2832
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
2933
QueryParamValue = str | int | float | bool | None
@@ -33,6 +37,49 @@
3337
Body = Mapping[str, Any]
3438

3539

40+
def _normalize_file_inputs(files: Iterable[IO[bytes]]) -> list[IO[bytes]]:
41+
normalized = list(files)
42+
if not normalized:
43+
msg = "At least one file must be provided."
44+
raise ValueError(msg)
45+
for file_obj in normalized:
46+
if not hasattr(file_obj, "read"):
47+
msg = "files must be file-like objects opened in binary mode."
48+
raise TypeError(msg)
49+
return normalized
50+
51+
52+
def _build_multipart_payload(
53+
file_objects: Sequence[IO[bytes]],
54+
) -> list[tuple[str, tuple[str, IO[bytes], str | None]]]:
55+
multipart: list[tuple[str, tuple[str, IO[bytes], str | None]]] = []
56+
for file_obj in file_objects:
57+
name_attr = getattr(file_obj, "name", None)
58+
filename = Path(str(name_attr)).name if name_attr else FILE_UPLOAD_FIELD_NAME
59+
multipart.append((FILE_UPLOAD_FIELD_NAME, (filename, file_obj, None)))
60+
return multipart
61+
62+
63+
def _extract_uploaded_file_ids(payload: Any) -> list[str]:
64+
try:
65+
files_payload = payload["files"]
66+
except (TypeError, KeyError) as exc: # pragma: no cover - defensive
67+
raise PdfRestApiError(
68+
500, message="Upload response missing 'files' collection."
69+
) from exc
70+
if not isinstance(files_payload, Sequence): # pragma: no cover - defensive
71+
raise PdfRestApiError(500, message="Upload response 'files' is not a sequence.")
72+
entries = cast(Sequence[Mapping[str, Any]], files_payload)
73+
file_ids: list[str] = []
74+
for entry in entries:
75+
if "id" not in entry:
76+
raise PdfRestApiError(
77+
500, message="Upload response contains invalid file references."
78+
)
79+
file_ids.append(str(entry["id"]))
80+
return file_ids
81+
82+
3683
ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient)
3784

3885

@@ -102,6 +149,8 @@ class _RequestModel(BaseModel):
102149
headers: dict[str, str] = Field(default_factory=dict)
103150
timeout: TimeoutTypes
104151
json_body: dict[str, Any] | None = None
152+
files: Any | None = None
153+
data: Any | None = None
105154

106155
model_config = ConfigDict(arbitrary_types_allowed=True)
107156

@@ -204,6 +253,8 @@ def _prepare_request(
204253
extra_headers: AnyMapping | None = None,
205254
extra_body: Body | None = None,
206255
timeout: TimeoutTypes | None = None,
256+
files: Any | None = None,
257+
data: Any | None = None,
207258
) -> _RequestModel:
208259
headers = self._compose_headers(extra_headers)
209260
params = self._compose_query_params(query, extra_query)
@@ -218,13 +269,42 @@ def _prepare_request(
218269
headers=headers,
219270
timeout=timeout_value,
220271
json_body=json_payload,
272+
files=files,
273+
data=data,
221274
)
222275
except PdfRestConfigurationError:
223276
raise
224277
except ValidationError as exc: # pragma: no cover - defensive
225278
raise PdfRestConfigurationError(str(exc)) from exc
226279
return request
227280

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

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

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

509+
async def send_request(self, request: _RequestModel) -> Any:
510+
return await self._send_request(request)
511+
512+
async def fetch_file_info(self, file_id: str) -> PdfRestFile:
513+
request = self.prepare_request(
514+
"GET",
515+
f"/resource/{file_id}",
516+
query={"format": "info"},
517+
)
518+
payload = await self._send_request(request)
519+
return PdfRestFile.model_validate(payload)
520+
521+
522+
class _FilesClient:
523+
"""Expose file-related operations for the synchronous client."""
524+
525+
def __init__(self, client: _SyncApiClient) -> None:
526+
self._client = client
527+
528+
def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]:
529+
file_objects = _normalize_file_inputs(files)
530+
multipart = _build_multipart_payload(file_objects)
531+
request = self._client.prepare_request("POST", "/upload", files=multipart)
532+
payload = self._client.send_request(request)
533+
file_ids = _extract_uploaded_file_ids(payload)
534+
return [self._client.fetch_file_info(file_id) for file_id in file_ids]
535+
536+
537+
class _AsyncFilesClient:
538+
"""Expose file-related operations for the asynchronous client."""
539+
540+
def __init__(
541+
self,
542+
client: _AsyncApiClient,
543+
*,
544+
concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY,
545+
) -> None:
546+
self._client = client
547+
self._concurrency_limit = concurrency_limit
548+
549+
async def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]:
550+
file_objects = _normalize_file_inputs(files)
551+
multipart = _build_multipart_payload(file_objects)
552+
request = self._client.prepare_request("POST", "/upload", files=multipart)
553+
payload = await self._client.send_request(request)
554+
file_ids = _extract_uploaded_file_ids(payload)
555+
semaphore = asyncio.Semaphore(self._concurrency_limit)
556+
557+
async def fetch(file_id: str) -> PdfRestFile:
558+
async with semaphore:
559+
return await self._client.fetch_file_info(file_id)
560+
561+
return await asyncio.gather(*(fetch(file_id) for file_id in file_ids))
562+
413563

414564
class PdfRestClient(_SyncApiClient):
415565
"""Synchronous client for interacting with the pdfrest API."""
@@ -434,6 +584,7 @@ def __init__(
434584
http_client=http_client,
435585
transport=transport,
436586
)
587+
self._files_client = _FilesClient(self)
437588

438589
def __enter__(self) -> PdfRestClient:
439590
super().__enter__()
@@ -442,6 +593,10 @@ def __enter__(self) -> PdfRestClient:
442593
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
443594
super().__exit__(exc_type, exc, traceback)
444595

596+
@property
597+
def files(self) -> _FilesClient:
598+
return self._files_client
599+
445600
def up(
446601
self,
447602
*,
@@ -487,6 +642,7 @@ def __init__(
487642
http_client=http_client,
488643
transport=transport,
489644
)
645+
self._files_client = _AsyncFilesClient(self)
490646

491647
async def __aenter__(self) -> AsyncPdfRestClient:
492648
await super().__aenter__()
@@ -495,6 +651,10 @@ async def __aenter__(self) -> AsyncPdfRestClient:
495651
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
496652
await super().__aexit__(exc_type, exc, traceback)
497653

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

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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import importlib.resources
2+
from importlib.abc import Traversable
3+
4+
5+
def get_test_resource_path(*pathsegments: str) -> Traversable:
6+
return importlib.resources.files("tests.resources").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)