11import asyncio
22import functools
33import sys
4+ import threading
45from asyncio import Queue
56from concurrent .futures import ThreadPoolExecutor
67from contextlib import redirect_stdout , suppress
@@ -269,6 +270,61 @@ def wrapper(*args_, **kwargs_):
269270 return decorator (func_ = func )
270271
271272
273+ class ThreadRoutedStdout :
274+ """A stdout stand-in that routes each worker thread's writes to its own buffer
275+
276+ Threads that have not registered a buffer, the calling thread and tqdm among
277+ them, write straight through to the wrapped stream. Installed once for the
278+ duration of a parallelize() call, so unlike contextlib.redirect_stdout it
279+ stays correct while several tasks print at the same time.
280+ """
281+
282+ def __init__ (self , stream : IO ):
283+ self .stream = stream
284+ self ._local = threading .local ()
285+
286+ def register (self ) -> List [str ]:
287+ """Gives the calling thread its own buffer and returns it"""
288+ buffer = []
289+ self ._local .buffer = buffer
290+ return buffer
291+
292+ def unregister (self ):
293+ """Sends the calling thread's writes back to the wrapped stream"""
294+ self ._local .buffer = None
295+
296+ def write (self , message ):
297+ buffer = getattr (self ._local , "buffer" , None )
298+ if buffer is None :
299+ return self .stream .write (message )
300+ buffer .append (message )
301+ return len (message )
302+
303+ def flush (self ):
304+ if getattr (self ._local , "buffer" , None ) is None :
305+ self .stream .flush ()
306+
307+ def __getattr__ (self , name ):
308+ # encoding, isatty, fileno and the like stay those of the wrapped stream
309+ return getattr (self .stream , name )
310+
311+
312+ def capture_output (
313+ call : Callable , stdout : ThreadRoutedStdout , sink : List [str ], index : int
314+ ) -> Callable :
315+ """Wraps call so that what it prints ends up in sink[index] instead of stdout"""
316+
317+ def wrapper ():
318+ buffer = stdout .register ()
319+ try :
320+ return call ()
321+ finally :
322+ stdout .unregister ()
323+ sink [index ] = "" .join (buffer )
324+
325+ return wrapper
326+
327+
272328def parallelize (
273329 func : Callable ,
274330 iterable : Iterable ,
@@ -286,8 +342,10 @@ def parallelize(
286342 iterable:
287343 The iterable, who's items are to be passed as singles to the function.
288344 flush_at_end:
289- If True, the message buffer will be printed (flushed) to stdout after parallel
290- execution.
345+ If True, what the tasks print is written to stdout in the order of
346+ ``iterable`` once the batch has finished. If False, it is discarded.
347+ Either way it is captured while the tasks run, so that concurrent
348+ printing cannot garble the progress bar.
291349 progress_bar:
292350 If True, a progress bar will be displayed.
293351 return_exceptions:
@@ -299,54 +357,46 @@ def parallelize(
299357 Keyword arguments to be passed to the function.z
300358 """
301359
302- MODE = "asyncio"
303- if MODE == "dask" :
304- # this mode can be removed if asyncio works as expected
305- with MessageBuffer (flush_at_end ) as msg_buf :
306- tasks = [
307- dask .delayed (redirect_print_explicitly (func , msg_buf ))(item , ** kwargs )
308- for item in iterable
309- ]
310- print (
311- f"Performing parallel execution of { func .__name__ } ({ len (tasks )} tasks)."
360+ items = list (iterable )
361+ # One slot per item, so the collected output can be replayed in input order
362+ # even though the tasks finish in whatever order they finish.
363+ outputs = ["" ] * len (items )
364+ stdout = ThreadRoutedStdout (sys .stdout )
365+
366+ # run func concurrently with each item in iterable using asyncio
367+ async def _run_tasks ():
368+ loop = asyncio .get_event_loop ()
369+ # ToDo: handle IO-bound different from CPU-bound tasks
370+ # see https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor
371+ # None => ThreadPoolExecutor for IO-bound tasks
372+ tasks = [
373+ loop .run_in_executor (
374+ None ,
375+ capture_output (
376+ functools .partial (func , item , ** kwargs ), stdout , outputs , index
377+ ),
312378 )
313- if progress_bar :
314- with ProgressBar ():
315- results = dask .compute (* tasks )
316- else :
317- results = dask .compute (* tasks )
379+ for index , item in enumerate (items )
380+ ]
381+ print (f"Performing parallel execution of { func .__name__ } ({ len (tasks )} tasks)." )
382+ if return_exceptions :
383+ # tqdm.gather() re-raises the first exception (it has no
384+ # return_exceptions kwarg), which would abandon the remaining
385+ # results. Capture each task's exception instead so the batch
386+ # completes and results stay aligned with the input order.
387+ async def _capture (task ):
388+ try :
389+ return await task
390+ except Exception as exc :
391+ return exc
318392
319- if MODE == "asyncio" :
320- # run func concurrently with each item in iterable using asyncio
321- async def _run_tasks ():
322- loop = asyncio .get_event_loop ()
323- # ToDo: handle IO-bound different from CPU-bound tasks
324- # see https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor
325- # None => ThreadPoolExecutor for IO-bound tasks
326- tasks = [
327- loop .run_in_executor (None , functools .partial (func , item , ** kwargs ))
328- for item in iterable
329- ]
330- print (
331- f"Performing parallel execution of { func .__name__ } ({ len (tasks )} tasks)."
332- )
333- if return_exceptions :
334- # tqdm.gather() re-raises the first exception (it has no
335- # return_exceptions kwarg), which would abandon the remaining
336- # results. Capture each task's exception instead so the batch
337- # completes and results stay aligned with the input order.
338- async def _capture (task ):
339- try :
340- return await task
341- except Exception as exc :
342- return exc
343-
344- tasks = [_capture (task ) for task in tasks ]
345- res = await tqdm .gather (
346- * tasks
347- ) # like asyncio.gather, but with a progress bar
348- return res
393+ tasks = [_capture (task ) for task in tasks ]
394+ # tqdm.gather is asyncio.gather plus a progress bar
395+ gather = tqdm .gather if progress_bar else asyncio .gather
396+ return await gather (* tasks )
349397
398+ sys .stdout = stdout
399+ try :
350400 # Check if we are already in an event loop, e.g. in a Jupyter notebook
351401 try :
352402 loop = asyncio .get_running_loop ()
@@ -362,6 +412,14 @@ async def _capture(task):
362412 else :
363413 # Starting new event loop
364414 results = asyncio .run (_run_tasks ())
415+ finally :
416+ sys .stdout = stdout .stream
417+ if flush_at_end :
418+ # in the finally block, so a failed batch still reports what its
419+ # tasks had to say
420+ for output in outputs :
421+ if output :
422+ print (output , end = "" )
365423
366424 return results
367425
0 commit comments