Skip to content

Commit 3e5dd12

Browse files
authored
fix(util): honour flush_at_end and progress_bar in parallelize
Merge pull request #154 from OpenSemanticLab/test/parallelize-coverage
2 parents 416acd1 + 2794c9b commit 3e5dd12

2 files changed

Lines changed: 385 additions & 47 deletions

File tree

‎src/osw/utils/util.py‎

Lines changed: 105 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import functools
33
import sys
4+
import threading
45
from asyncio import Queue
56
from concurrent.futures import ThreadPoolExecutor
67
from 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+
272328
def 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

Comments
 (0)