1+ import asyncio
12import os
23import posixpath
34import contextvars
1718class AsyncFileWrapper :
1819 """
1920 Wrapper to make sync file objects work with async context managers.
20- This allows sync filesystem file objects to be used with 'async with'.
21+
22+ All I/O methods dispatch to a thread pool via asyncio.to_thread so that
23+ blocking S3/GCS network calls never stall the event loop.
2124 """
2225
2326 def __init__ (self , file_obj ):
@@ -27,9 +30,10 @@ def __init__(self, file_obj):
2730 self ._is_context_manager = hasattr (file_obj , "__enter__" ) and hasattr (file_obj , "__exit__" )
2831
2932 async def __aenter__ (self ):
30- # Enter the sync context manager if it is one
33+ # Enter the sync context manager in a thread — for S3 this is where
34+ # the actual HTTP connection / GetObject request is initiated.
3135 if self ._is_context_manager :
32- self .file_obj = self ._file_obj .__enter__ ( )
36+ self .file_obj = await asyncio . to_thread ( self ._file_obj .__enter__ )
3337 else :
3438 self .file_obj = self ._file_obj
3539 return self
@@ -41,55 +45,52 @@ async def __aexit__(
4145 exc_tb : Optional [TracebackType ],
4246 ) -> None :
4347 if self ._is_context_manager :
44- # Exit the sync context manager
45- self ._file_obj .__exit__ (exc_type , exc_val , exc_tb )
48+ await asyncio .to_thread (self ._file_obj .__exit__ , exc_type , exc_val , exc_tb )
4649 elif self .file_obj and hasattr (self .file_obj , "close" ):
47- # Just close if no context manager protocol
48- self .file_obj .close ()
50+ await asyncio .to_thread (self .file_obj .close )
4951 self .file_obj = None
5052
51- # Override common I/O methods to make them async-compatible
5253 async def read (self , size = - 1 ):
53- """Read from the file (async wrapper for sync read) ."""
54+ """Read bytes from the file."""
5455 if self .file_obj is None :
5556 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
56- return self .file_obj .read ( size )
57+ return await asyncio . to_thread ( self .file_obj .read , size )
5758
5859 async def write (self , data ):
59- """Write to the file (async wrapper for sync write) ."""
60+ """Write data to the file."""
6061 if self .file_obj is None :
6162 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
62- return self .file_obj .write ( data )
63+ return await asyncio . to_thread ( self .file_obj .write , data )
6364
6465 async def readline (self , size = - 1 ):
65- """Read a line from the file (async wrapper for sync readline) ."""
66+ """Read a single line from the file."""
6667 if self .file_obj is None :
6768 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
68- return self .file_obj .readline ( size )
69+ return await asyncio . to_thread ( self .file_obj .readline , size )
6970
7071 async def readlines (self , hint = - 1 ):
71- """Read all lines from the file (async wrapper for sync readlines) ."""
72+ """Read all lines from the file."""
7273 if self .file_obj is None :
7374 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
74- return self .file_obj .readlines ( hint )
75+ return await asyncio . to_thread ( self .file_obj .readlines , hint )
7576
7677 async def seek (self , offset , whence = 0 ):
77- """Seek to a position in the file (async wrapper for sync seek) ."""
78+ """Seek to the given position in the file."""
7879 if self .file_obj is None :
7980 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
80- return self .file_obj .seek ( offset , whence )
81+ return await asyncio . to_thread ( self .file_obj .seek , offset , whence )
8182
8283 async def tell (self ):
83- """Get current file position (async wrapper for sync tell) ."""
84+ """Return the current file position."""
8485 if self .file_obj is None :
8586 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
86- return self .file_obj .tell ( )
87+ return await asyncio . to_thread ( self .file_obj .tell )
8788
8889 async def flush (self ):
89- """Flush the file buffer (async wrapper for sync flush) ."""
90+ """Flush any buffered writes to the file ."""
9091 if self .file_obj is None :
9192 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
92- return self .file_obj .flush ( )
93+ return await asyncio . to_thread ( self .file_obj .flush )
9394
9495 def __getattr__ (self , name ):
9596 # Delegate all other attributes to the underlying file object
@@ -103,14 +104,13 @@ def __iter__(self):
103104 return iter (self .file_obj )
104105
105106 def __aiter__ (self ):
106- # For async iteration, we need to wrap the sync iterator
107107 return self
108108
109109 async def __anext__ (self ):
110110 if self .file_obj is None :
111111 raise ValueError ("File object not initialized. Use 'async with' to open the file." )
112112 try :
113- return next ( self .file_obj )
113+ return await asyncio . to_thread ( next , self .file_obj )
114114 except StopIteration :
115115 raise StopAsyncIteration
116116
@@ -253,44 +253,41 @@ async def root_join(*parts: str) -> str:
253253
254254async def exists (path : str ) -> bool :
255255 fs = await filesystem ()
256- return fs .exists ( path )
256+ return await asyncio . to_thread ( fs .exists , path )
257257
258258
259259async def isdir (path : str , fs = None ) -> bool :
260260 try :
261261 filesys = fs if fs is not None else await filesystem ()
262- return filesys .isdir ( path )
262+ return await asyncio . to_thread ( filesys .isdir , path )
263263 except Exception :
264264 return False
265265
266266
267267async def isfile (path : str ) -> bool :
268268 try :
269269 fs = await filesystem ()
270- return fs .isfile ( path )
270+ return await asyncio . to_thread ( fs .isfile , path )
271271 except Exception :
272272 return False
273273
274274
275275async def makedirs (path : str , exist_ok : bool = True ) -> None :
276276 fs = await filesystem ()
277277 try :
278- fs .makedirs ( path , exist_ok = exist_ok )
278+ await asyncio . to_thread ( fs .makedirs , path , exist_ok = exist_ok )
279279 except TypeError :
280280 # Some filesystems don't support exist_ok parameter
281281 if not exist_ok or not await exists (path ):
282- fs .makedirs ( path )
282+ await asyncio . to_thread ( fs .makedirs , path )
283283
284284
285285async def ls (path : str , detail : bool = False , fs = None ):
286286 # Use provided filesystem or get default
287287 filesys = fs if fs is not None else await filesystem ()
288- # Let fsspec parse the URI
289- paths = filesys .ls (path , detail = detail )
290- # Dont include the current path in the list
288+ paths = await asyncio .to_thread (filesys .ls , path , detail = detail )
291289 # Ensure paths are full URIs for remote filesystems
292290 if path .startswith (("s3://" , "gs://" , "abfs://" , "gcs://" )):
293- # For remote filesystems, ensure returned paths are full URIs
294291 full_paths = []
295292 for p in paths :
296293 if not p .startswith (("s3://" , "gs://" , "abfs://" , "gcs://" )):
@@ -307,51 +304,55 @@ async def ls(path: str, detail: bool = False, fs=None):
307304
308305async def find (path : str ) -> list [str ]:
309306 fs = await filesystem ()
310- return fs .find ( path )
307+ return await asyncio . to_thread ( fs .find , path )
311308
312309
313310async def walk (path : str , maxdepth = None , topdown = True , on_error = "omit" ):
314311 """
315- Walk directory tree, yielding (root, dirs, files) tuples.
312+ Walk directory tree, returning a list of (root, dirs, files) tuples.
316313
317314 Args:
318315 path: Root directory to start the walk
319316 maxdepth: Maximum recursion depth (None for no limit)
320317 topdown: If True, traverse top-down; if False, bottom-up
321318 on_error: Error behavior ('omit', 'raise', or callable)
322319
323- Yields :
324- (root, dirs, files) tuples similar to os.walk()
320+ Returns :
321+ List of (root, dirs, files) tuples similar to os.walk()
325322 """
326323 fs = await filesystem ()
327- return fs .walk (path , maxdepth = maxdepth , topdown = topdown , on_error = on_error )
324+ # Materialise the generator in a thread so the blocking filesystem
325+ # traversal never stalls the event loop.
326+ return await asyncio .to_thread (lambda : list (fs .walk (path , maxdepth = maxdepth , topdown = topdown , on_error = on_error )))
328327
329328
330329async def rm (path : str ) -> None :
331330 if await exists (path ):
332331 fs = await filesystem ()
333- fs .rm ( path )
332+ await asyncio . to_thread ( fs .rm , path )
334333
335334
336335async def rm_tree (path : str ) -> None :
337336 if await exists (path ):
338337 fs = await filesystem ()
339338 try :
340- fs .rm ( path , recursive = True )
339+ await asyncio . to_thread ( fs .rm , path , recursive = True )
341340 except TypeError :
342341 # Some filesystems don't support recursive parameter
343342 # Use find() to get all files and remove them individually
344343 files = await find (path )
345344 for file_path in reversed (files ): # Remove files before directories
346- fs .rm ( file_path )
345+ await asyncio . to_thread ( fs .rm , file_path )
347346
348347
349348async def open (path : str , mode : str = "r" , fs = None , uncached : bool = False , ** kwargs ):
350349 """
351350 Open a file for reading or writing.
352351
353352 For local files, uses aiofiles for truly async file I/O.
354- For remote files (S3, GCS, etc.), uses fsspec sync file objects.
353+ For remote files (S3, GCS, etc.), dispatches the blocking open() call to a
354+ thread pool and wraps the result in AsyncFileWrapper whose I/O methods are
355+ also thread-dispatched, so no S3 network call ever blocks the event loop.
355356
356357 Args:
357358 path: Path to the file
@@ -361,7 +362,7 @@ async def open(path: str, mode: str = "r", fs=None, uncached: bool = False, **kw
361362 **kwargs: Additional arguments passed to filesystem.open()
362363
363364 Returns:
364- File-like object ( context manager for remote, async context manager for local)
365+ Async context manager wrapping the file object.
365366 """
366367 if uncached :
367368 # Create an uncached filesystem instance
@@ -374,12 +375,13 @@ async def open(path: str, mode: str = "r", fs=None, uncached: bool = False, **kw
374375 is_local = isinstance (filesys , fsspec .implementations .local .LocalFileSystem )
375376
376377 if is_local :
377- # Use aiofiles for local files to get truly async file I/O
378+ # Use aiofiles for local files — already truly async, no change needed
378379 return aiofiles .open (path , mode = mode , ** kwargs )
379380 else :
380- # Use sync filesystem open method, but wrap it in async context manager
381- # so it can be used with 'async with'
382- sync_file = filesys .open (path , mode = mode , ** kwargs )
381+ # Open the remote file in a thread (the open() call itself initiates the
382+ # S3/GCS connection), then wrap it so subsequent reads/writes are also
383+ # dispatched to threads via AsyncFileWrapper.
384+ sync_file = await asyncio .to_thread (filesys .open , path , mode , ** kwargs )
383385 return AsyncFileWrapper (sync_file )
384386
385387
@@ -494,16 +496,18 @@ def _get_fs_for_path(path: str):
494496
495497async def copy_file (src : str , dest : str ) -> None :
496498 """Copy a single file from src to dest across arbitrary filesystems."""
497- # Use streaming copy to be robust across different filesystems
498- # Get sync filesystems with proper storage_options handling
499+ # Run the entire streaming copy in a thread — src_fs.open() and dest_fs.open()
500+ # both make blocking network calls for remote filesystems.
499501 src_fs , _ = _get_fs_for_path (src )
500502 dest_fs , _ = _get_fs_for_path (dest )
501503
502- # Use sync filesystem methods (wrapped in async function for API compatibility)
503- with src_fs .open (src , "rb" ) as r :
504- with dest_fs .open (dest , "wb" ) as w :
505- for chunk in iter_chunks (r ):
506- w .write (chunk )
504+ def _do_copy ():
505+ with src_fs .open (src , "rb" ) as r :
506+ with dest_fs .open (dest , "wb" ) as w :
507+ for chunk in iter_chunks (r ):
508+ w .write (chunk )
509+
510+ await asyncio .to_thread (_do_copy )
507511
508512
509513def iter_chunks (file_obj , chunk_size : int = 8 * 1024 * 1024 ):
@@ -527,11 +531,12 @@ async def copy_dir(src_dir: str, dest_dir: str) -> None:
527531 src_protocol = src_dir .split ("://" , 1 )[0 ]
528532
529533 try :
530- src_files = src_fs .find ( src_dir )
534+ src_files = await asyncio . to_thread ( src_fs .find , src_dir )
531535 except Exception :
532536 # If find is not available, fall back to listing via walk
533537 src_files = []
534- for _ , _ , files in src_fs .walk (src_dir ):
538+ walk_result = await asyncio .to_thread (lambda : list (src_fs .walk (src_dir )))
539+ for _ , _ , files in walk_result :
535540 for f in files :
536541 src_files .append (f )
537542
0 commit comments