22
33from __future__ import annotations
44
5+ import asyncio
56import os
67import 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
1012import httpx
1113from httpx import URL
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
2426DEFAULT_BASE_URL = "https://api.pdfrest.com"
2527API_KEY_ENV_VAR = "PDFREST_API_KEY"
2628API_KEY_HEADER_NAME = "Api-Key"
2729DEFAULT_TIMEOUT_SECONDS = 10.0
30+ FILE_UPLOAD_FIELD_NAME = "file"
31+ DEFAULT_FILE_INFO_CONCURRENCY = 8
2832
2933HttpMethod = Literal ["GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "OPTIONS" , "HEAD" ]
3034QueryParamValue = str | int | float | bool | None
3438Body = 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+
3784ClientType = 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
360454class _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
415565class 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 * ,
0 commit comments