55import asyncio
66import os
77import 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
911from 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
1214import httpx
1315from httpx import URL
3739Query = Mapping [str , QueryParamValue ]
3840Body = 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
6456def _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+
84178ClientType = 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
538678class _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
565750class PdfRestClient (_SyncApiClient ):
566751 """Synchronous client for interacting with the pdfrest API."""
0 commit comments