1010import aiofiles
1111from aiostream import stream
1212from contextlib import contextmanager
13- from tenacity import RetryCallState , retry , stop_after_attempt , wait_fixed , retry_if_exception
13+ from tenacity import (
14+ RetryCallState ,
15+ retry ,
16+ stop_after_attempt ,
17+ wait_fixed ,
18+ retry_if_exception ,
19+ )
1420import logging
1521from tqdm .asyncio import tqdm
1622
1723logger = logging .getLogger (__name__ )
1824
25+
1926def should_retry (e : BaseException ) -> bool :
2027 """
2128 Returns True if the exception is a transient exception from the FileSender server,
2229 that can be retried.
2330 """
2431 if isinstance (e , ReadError ):
2532 # Seems to be just a bug in the backend
26- # https://github.com/encode/httpx/discussions/2941
33+ # https://github.com/encode/httpx/discussions/2941
2734 return True
2835 elif isinstance (e , HTTPStatusError ) and e .response .status_code == 500 :
2936 message = e .response .json ()["message" ]
@@ -44,14 +51,18 @@ def url_without_scheme(url: str) -> str:
4451 """
4552 return unquote (urlunparse (urlparse (url )._replace (scheme = "" )).lstrip ("/" ))
4653
54+
4755def exception_to_message (e : BaseException ) -> str :
4856 if isinstance (e , HTTPStatusError ):
4957 return f"Request failed with content { e .response .text } for request { e .request .method } { e .request .url } ."
5058 elif isinstance (e , RequestError ):
51- return f"Request failed for request { e .request .method } { e .request .url } . { repr (e )} "
59+ return (
60+ f"Request failed for request { e .request .method } { e .request .url } . { repr (e )} "
61+ )
5262 else :
5363 return repr (e )
5464
65+
5566@contextmanager
5667def raise_status ():
5768 """
@@ -63,6 +74,7 @@ def raise_status():
6374 except BaseException as e :
6475 raise Exception (exception_to_message (e )) from e
6576
77+
6678async def yield_chunks (path : Path , chunk_size : int ) -> AsyncIterator [Tuple [bytes , int ]]:
6779 """
6880 Yields (chunk, offset) tuples from a file, chunked by chunk_size.
@@ -78,7 +90,10 @@ async def yield_chunks(path: Path, chunk_size: int) -> AsyncIterator[Tuple[bytes
7890 yield chunk , offset
7991 offset += len (chunk )
8092
81- def iter_files (paths : Iterable [Path ], root : Optional [Path ] = None ) -> Iterable [Tuple [str , Path ]]:
93+
94+ def iter_files (
95+ paths : Iterable [Path ], root : Optional [Path ] = None
96+ ) -> Iterable [Tuple [str , Path ]]:
8297 """
8398 Recursively yields (name, path) tuples for all files included in the input path, or that are children of these paths
8499 """
@@ -87,10 +102,10 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
87102 # Recurse into directories
88103 if root is None :
89104 # If this is a top level directory, then its parent becomes the root
90- yield from iter_files (path .iterdir (), root = path .parent )
105+ yield from iter_files (path .iterdir (), root = path .parent )
91106 else :
92107 # Preserve the same root when recursing
93- yield from iter_files (path .iterdir (), root = root )
108+ yield from iter_files (path .iterdir (), root = root )
94109 else :
95110 if root is None :
96111 # If this is a top level file, just use the filename directly
@@ -99,6 +114,7 @@ def iter_files(paths: Iterable[Path], root: Optional[Path] = None) -> Iterable[T
99114 # If this is a nested file, use the relative path from the root directory as the name
100115 yield str (path .relative_to (root )), path
101116
117+
102118def _file_key (file_info : response .File ) -> str :
103119 """
104120 Returns the per-file upload key, checking both ``uid`` (older FileSender servers)
@@ -109,7 +125,10 @@ def _file_key(file_info: response.File) -> str:
109125 return file_info ["uid" ]
110126 if "puid" in file_info :
111127 return file_info ["puid" ]
112- raise Exception (f"File response for { file_info ['name' ]!r} has neither 'uid' nor 'puid' field" )
128+ raise Exception (
129+ f"File response for { file_info ['name' ]!r} has neither 'uid' nor 'puid' field"
130+ )
131+
113132
114133class EndpointHandler :
115134 base : str
@@ -118,7 +137,9 @@ def __init__(self, base: str) -> None:
118137 # Backwards compatibility to support the old way of specifying the base URL
119138 base = base .rstrip ("/" )
120139 if base .endswith ("/rest.php" ):
121- logger .warning ("The base URL should not include /rest.php. This will no longer be supported in a future version." )
140+ logger .warning (
141+ "The base URL should not include /rest.php. This will no longer be supported in a future version."
142+ )
122143 base = base .removesuffix ("/rest.php" )
123144 self .base = base
124145
@@ -146,6 +167,7 @@ def guest(self) -> str:
146167 def server_info (self ) -> str :
147168 return f"{ self .api ()} /info"
148169
170+
149171class FileSenderClient :
150172 """
151173 A client that can be used to programmatically interact with FileSender.
@@ -197,7 +219,6 @@ def __init__(
197219 self .concurrent_chunks = concurrent_chunks
198220 self .concurrent_files = concurrent_files
199221
200-
201222 async def prepare (self ) -> None :
202223 """
203224 Checks that the chunk size is appropriate and/or sets the chunk size based on the server info.
@@ -232,7 +253,7 @@ def on_retry(state: RetryCallState) -> None:
232253 retry = retry_if_exception (should_retry ),
233254 wait = wait_fixed (0.1 ),
234255 stop = stop_after_attempt (5 ),
235- before_sleep = on_retry
256+ before_sleep = on_retry ,
236257 )
237258 async def _sign_send_inner (self , request : Request ) -> Any :
238259 # Needs to be a separate function to handle retry policy correctly
@@ -303,7 +324,7 @@ async def update_file(
303324 await self ._sign_send (
304325 self .http_client .build_request (
305326 "PUT" ,
306- self .urls .file (file_info ['id' ]),
327+ self .urls .file (file_info ["id" ]),
307328 params = {"key" : _file_key (file_info )},
308329 json = body ,
309330 )
@@ -323,18 +344,24 @@ async def upload_file(self, file_info: response.File, path: Path) -> None:
323344 if self .chunk_size is None :
324345 raise Exception (".prepare() has not been called!" )
325346
326- async def _task_generator (chunk_size : int ) -> AsyncIterator [Tuple [response .File , int , bytes ]]:
347+ async def _task_generator (
348+ chunk_size : int ,
349+ ) -> AsyncIterator [Tuple [response .File , int , bytes ]]:
327350 async for chunk , offset in yield_chunks (path , chunk_size ):
328351 yield file_info , offset , chunk
329352
330353 async with (
331354 stream .starmap (
332- _task_generator (self .chunk_size ),
333- self ._upload_chunk , # type: ignore
334- task_limit = self .concurrent_chunks
335- )
336- ).stream () as streamer :
337- async for _ in tqdm (streamer , total = math .ceil (int (file_info ["size" ]) / self .chunk_size ), desc = file_info ["name" ]):
355+ _task_generator (self .chunk_size ),
356+ self ._upload_chunk , # type: ignore
357+ task_limit = self .concurrent_chunks ,
358+ )
359+ ).stream () as streamer :
360+ async for _ in tqdm (
361+ streamer ,
362+ total = math .ceil (int (file_info ["size" ]) / self .chunk_size ),
363+ desc = file_info ["name" ],
364+ ):
338365 pass
339366
340367 async def _upload_chunk (
@@ -384,14 +411,10 @@ async def _files_from_token(self, token: str) -> Iterable[DownloadFile]:
384411 )
385412 return files_from_page (download_page .content )
386413
387- async def download_files (
388- self ,
389- token : str ,
390- out_dir : Path
391- ) -> None :
414+ async def download_files (self , token : str , out_dir : Path ) -> None :
392415 """
393416 Downloads all files for a transfer.
394-
417+
395418 Params:
396419 token: Obtained from the transfer email. The same as [`GuestAuth`][filesender.GuestAuth]'s `guest_token`.
397420 out_dir: The path to write the downloaded files.
@@ -406,15 +429,17 @@ async def _download_args() -> AsyncIterator[Tuple[str, Any, Path, int, str]]:
406429
407430 # Each file is downloaded in parallel
408431 # Pyright messes this up
409- await stream .starmap (_download_args (), self .download_file , task_limit = self .concurrent_files ) # type: ignore
432+ await stream .starmap (
433+ _download_args (), self .download_file , task_limit = self .concurrent_files
434+ ) # type: ignore
410435
411436 async def download_file (
412437 self ,
413438 token : str ,
414439 file_id : int ,
415440 out_dir : Path ,
416441 file_size : Union [int , float , None ] = None ,
417- file_name : Optional [str ] = None
442+ file_name : Optional [str ] = None ,
418443 ) -> None :
419444 """
420445 Downloads a single file.
@@ -442,9 +467,13 @@ async def download_file(
442467 file_path .parent .mkdir (parents = True , exist_ok = True )
443468 chunk_size = 8192
444469 chunk_size_mb = chunk_size / 1024 / 1024
445- with tqdm (desc = file_name , unit = "MB" , total = None if file_size is None else int (file_size / 1024 / 1024 )) as progress :
470+ with tqdm (
471+ desc = file_name ,
472+ unit = "MB" ,
473+ total = None if file_size is None else int (file_size / 1024 / 1024 ),
474+ ) as progress :
446475 async with aiofiles .open (out_dir / file_name , "wb" ) as fp :
447- # We can't add the total here, because we don't know it:
476+ # We can't add the total here, because we don't know it:
448477 # https://github.com/filesender/filesender/issues/1555
449478 async for chunk in res .aiter_raw (chunk_size = chunk_size ):
450479 await fp .write (chunk )
@@ -472,7 +501,10 @@ async def upload_workflow(
472501 : See [`Transfer`][filesender.response_types.Transfer]
473502 """
474503 files_by_name = {key : value for key , value in iter_files (files )}
475- file_info : List [request .File ] = [{"name" : name , "size" : file .stat ().st_size } for name , file in files_by_name .items ()]
504+ file_info : List [request .File ] = [
505+ {"name" : name , "size" : file .stat ().st_size }
506+ for name , file in files_by_name .items ()
507+ ]
476508 transfer = await self .create_transfer (
477509 {
478510 "files" : file_info ,
@@ -488,14 +520,19 @@ async def upload_workflow(
488520
489521 async def _upload_args () -> AsyncIterator [Tuple [response .File , Path ]]:
490522 for file in transfer ["files" ]:
491- # Skip folders, which aren't real
523+ # Skip folders, which aren't real
492524 if file ["name" ] in files_by_name :
493525 # Pyright seems to not understand that some fields are optional
494526 yield file , files_by_name [file ["name" ]]
495527
496528 # Upload each file in parallel
497529 # Pyright doesn't map the type signatures correctly here
498- await stream .starmap (_upload_args (), self .upload_complete , ordered = False , task_limit = self .concurrent_files ) # type: ignore
530+ await stream .starmap (
531+ _upload_args (),
532+ self .upload_complete ,
533+ ordered = False ,
534+ task_limit = self .concurrent_files ,
535+ ) # type: ignore
499536
500537 # Mark the transfer as complete
501538 transfer = await self .update_transfer (
0 commit comments