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"
2628DEFAULT_TIMEOUT_SECONDS = 10.0
29+ FILE_UPLOAD_FIELD_NAME = "file"
30+ DEFAULT_FILE_INFO_CONCURRENCY = 8
2731
2832HttpMethod = Literal ["GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "OPTIONS" , "HEAD" ]
2933QueryParamValue = str | int | float | bool | None
3337Body = 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+
3683ClientType = 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
359453class _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
414564class 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 * ,
0 commit comments