hojichar.utils.async_handlers
1from __future__ import annotations 2 3import asyncio 4import itertools 5import logging 6import queue 7import threading 8from concurrent.futures import ThreadPoolExecutor 9from pathlib import Path 10from typing import ( 11 Any, 12 AsyncGenerator, 13 AsyncIterable, 14 Awaitable, 15 Callable, 16 Generic, 17 Iterable, 18 Iterator, 19 TextIO, 20 TypeVar, 21 cast, 22) 23 24T = TypeVar("T") 25logger = logging.getLogger(__name__) 26 27 28class _AsyncIteratorError: 29 def __init__(self, error: BaseException): 30 self.error = error 31 32 33_ASYNC_ITERATOR_END = object() 34 35 36class AsyncToSyncIterator(Iterator[T], Generic[T]): 37 """Consume an async iterable from synchronous code. 38 39 A single background thread owns the event loop for the lifetime of the iterator. Use 40 this class as a context manager when iteration may stop before the source is exhausted. 41 """ 42 43 def __init__( 44 self, 45 source_stream: AsyncIterable[T], 46 *, 47 buffer_size: int = 128, 48 finalizer: Callable[[], Awaitable[None]] | None = None, 49 ) -> None: 50 if buffer_size < 1: 51 raise ValueError("buffer_size must be at least 1") 52 53 self._source_stream = source_stream 54 self._queue: queue.Queue[Any] = queue.Queue(maxsize=buffer_size) 55 self._finalizer = finalizer 56 self._stop_event = threading.Event() 57 self._started_event = threading.Event() 58 self._thread: threading.Thread | None = None 59 self._loop: asyncio.AbstractEventLoop | None = None 60 self._consumer_task: asyncio.Task[None] | None = None 61 self._closed = False 62 63 def __iter__(self) -> "AsyncToSyncIterator[T]": 64 return self 65 66 def __next__(self) -> T: 67 if self._closed: 68 raise StopIteration 69 self._start() 70 71 item = self._queue.get() 72 if item is _ASYNC_ITERATOR_END: 73 self.close() 74 raise StopIteration 75 if isinstance(item, _AsyncIteratorError): 76 self.close() 77 raise item.error 78 return cast(T, item) 79 80 def __enter__(self) -> "AsyncToSyncIterator[T]": 81 return self 82 83 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: 84 self.close() 85 86 def close(self) -> None: 87 if self._closed: 88 return 89 self._closed = True 90 self._stop_event.set() 91 thread_was_started = self._thread is not None 92 93 if not thread_was_started: 94 # Closing an unconsumed iterator must still close its source and run its finalizer. 95 self._thread = threading.Thread( 96 target=self._thread_main, 97 name="hojichar-async-to-sync", 98 daemon=True, 99 ) 100 self._thread.start() 101 self._started_event.wait() 102 103 while True: 104 try: 105 self._queue.get_nowait() 106 except queue.Empty: 107 break 108 109 if ( 110 thread_was_started 111 and self._thread is not None 112 and self._thread.is_alive() 113 and self._loop is not None 114 and not self._loop.is_closed() 115 and self._consumer_task is not None 116 ): 117 try: 118 self._loop.call_soon_threadsafe(self._consumer_task.cancel) 119 except RuntimeError: 120 # The background loop can close between is_closed() and scheduling. 121 pass 122 123 if self._thread is not None: 124 self._thread.join(timeout=5.0) 125 if self._thread.is_alive(): 126 logger.warning("Async-to-sync iterator did not stop within 5 seconds") 127 128 def _start(self) -> None: 129 if self._thread is not None: 130 return 131 try: 132 asyncio.get_running_loop() 133 except RuntimeError: 134 pass 135 else: 136 raise RuntimeError( 137 "Cannot synchronously consume an async iterable from a running event loop" 138 ) 139 140 self._thread = threading.Thread( 141 target=self._thread_main, 142 name="hojichar-async-to-sync", 143 daemon=True, 144 ) 145 self._thread.start() 146 self._started_event.wait() 147 148 def _thread_main(self) -> None: 149 try: 150 asyncio.run(self._consume()) 151 except BaseException as error: 152 if not self._stop_event.is_set(): 153 self._put(_AsyncIteratorError(error)) 154 elif not isinstance(error, asyncio.CancelledError): 155 logger.error("Failed to close async-to-sync iterator", exc_info=True) 156 finally: 157 self._started_event.set() 158 if not self._stop_event.is_set(): 159 self._put(_ASYNC_ITERATOR_END) 160 161 async def _consume(self) -> None: 162 self._loop = asyncio.get_running_loop() 163 self._consumer_task = asyncio.current_task() 164 self._started_event.set() 165 iterator = None 166 167 try: 168 iterator = self._source_stream.__aiter__() 169 while not self._stop_event.is_set(): 170 try: 171 item = await iterator.__anext__() 172 except StopAsyncIteration: 173 break 174 if not self._put(item): 175 break 176 finally: 177 try: 178 aclose = getattr(iterator, "aclose", None) 179 if aclose is not None: 180 await aclose() 181 finally: 182 if self._finalizer is not None: 183 await self._finalizer() 184 185 def _put(self, item: Any) -> bool: 186 while not self._stop_event.is_set(): 187 try: 188 self._queue.put(item, timeout=0.05) 189 return True 190 except queue.Full: 191 continue 192 return False 193 194 195def handle_stream_as_async( 196 source_stream: Iterable[T] | AsyncIterable[T], 197 chunk_size: int = 1000, 198 executor: ThreadPoolExecutor | None = None, 199) -> AsyncGenerator[T, None]: 200 """ 201 Convert a synchronous iterable to an asynchronous generator 202 with a specified chunk size. 203 204 Args: 205 source_stream (Iterable[T]): The synchronous iterable to convert. 206 chunk_size (int): The number of items to yield at a time. 207 """ 208 if isinstance(source_stream, AsyncIterable): 209 return source_stream # type: ignore[return-value] 210 stream = iter(source_stream) 211 212 async def sync_to_async() -> AsyncGenerator[T, None]: 213 loop = asyncio.get_running_loop() 214 while True: 215 chunk = await loop.run_in_executor( 216 executor, lambda: list(itertools.islice(stream, chunk_size)) 217 ) 218 if not chunk: 219 break 220 for item in chunk: 221 yield item 222 223 return sync_to_async() 224 225 226def handle_async_stream_as_sync( 227 source_stream: AsyncIterable[T], 228 *, 229 buffer_size: int = 128, 230 finalizer: Callable[[], Awaitable[None]] | None = None, 231) -> AsyncToSyncIterator[T]: 232 """Convert an async iterable to a synchronous, closeable iterator. 233 234 The iterator owns a background event-loop thread. Fully consuming it closes the source 235 automatically. Use it as a context manager when the consumer may stop early. 236 """ 237 return AsyncToSyncIterator( 238 source_stream, 239 buffer_size=buffer_size, 240 finalizer=finalizer, 241 ) 242 243 244async def write_stream_to_file( 245 stream: AsyncGenerator[str, None], 246 output_path: Path | str, 247 *, 248 chunk_size: int = 1000, 249 delimiter: str = "\n", 250) -> None: 251 """ 252 Write an asynchronous stream of strings to a file. 253 To lessen overhead with file I/O, it writes in chunks. 254 """ 255 loop = asyncio.get_running_loop() 256 with open(output_path, "w", encoding="utf-8") as f: 257 chunk = [] 258 async for line in stream: 259 chunk.append(line) 260 if len(chunk) >= chunk_size: 261 await loop.run_in_executor(None, f.writelines, [s + delimiter for s in chunk]) 262 chunk = [] 263 if chunk: 264 await loop.run_in_executor(None, f.writelines, [s + delimiter for s in chunk]) 265 chunk = [] 266 await loop.run_in_executor(None, f.flush) 267 268 269async def fileout_from_async_iter( 270 fp: TextIO, iter: AsyncIterable[str], buffer_size: int = 128 271) -> None: 272 buffer = [] 273 async for line in iter: 274 buffer.append(line + "\n") 275 if len(buffer) >= buffer_size: 276 await asyncio.to_thread(fp.write, "".join(buffer)) 277 buffer.clear() 278 await asyncio.to_thread(fp.writelines, buffer) 279 buffer.clear()
37class AsyncToSyncIterator(Iterator[T], Generic[T]): 38 """Consume an async iterable from synchronous code. 39 40 A single background thread owns the event loop for the lifetime of the iterator. Use 41 this class as a context manager when iteration may stop before the source is exhausted. 42 """ 43 44 def __init__( 45 self, 46 source_stream: AsyncIterable[T], 47 *, 48 buffer_size: int = 128, 49 finalizer: Callable[[], Awaitable[None]] | None = None, 50 ) -> None: 51 if buffer_size < 1: 52 raise ValueError("buffer_size must be at least 1") 53 54 self._source_stream = source_stream 55 self._queue: queue.Queue[Any] = queue.Queue(maxsize=buffer_size) 56 self._finalizer = finalizer 57 self._stop_event = threading.Event() 58 self._started_event = threading.Event() 59 self._thread: threading.Thread | None = None 60 self._loop: asyncio.AbstractEventLoop | None = None 61 self._consumer_task: asyncio.Task[None] | None = None 62 self._closed = False 63 64 def __iter__(self) -> "AsyncToSyncIterator[T]": 65 return self 66 67 def __next__(self) -> T: 68 if self._closed: 69 raise StopIteration 70 self._start() 71 72 item = self._queue.get() 73 if item is _ASYNC_ITERATOR_END: 74 self.close() 75 raise StopIteration 76 if isinstance(item, _AsyncIteratorError): 77 self.close() 78 raise item.error 79 return cast(T, item) 80 81 def __enter__(self) -> "AsyncToSyncIterator[T]": 82 return self 83 84 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: 85 self.close() 86 87 def close(self) -> None: 88 if self._closed: 89 return 90 self._closed = True 91 self._stop_event.set() 92 thread_was_started = self._thread is not None 93 94 if not thread_was_started: 95 # Closing an unconsumed iterator must still close its source and run its finalizer. 96 self._thread = threading.Thread( 97 target=self._thread_main, 98 name="hojichar-async-to-sync", 99 daemon=True, 100 ) 101 self._thread.start() 102 self._started_event.wait() 103 104 while True: 105 try: 106 self._queue.get_nowait() 107 except queue.Empty: 108 break 109 110 if ( 111 thread_was_started 112 and self._thread is not None 113 and self._thread.is_alive() 114 and self._loop is not None 115 and not self._loop.is_closed() 116 and self._consumer_task is not None 117 ): 118 try: 119 self._loop.call_soon_threadsafe(self._consumer_task.cancel) 120 except RuntimeError: 121 # The background loop can close between is_closed() and scheduling. 122 pass 123 124 if self._thread is not None: 125 self._thread.join(timeout=5.0) 126 if self._thread.is_alive(): 127 logger.warning("Async-to-sync iterator did not stop within 5 seconds") 128 129 def _start(self) -> None: 130 if self._thread is not None: 131 return 132 try: 133 asyncio.get_running_loop() 134 except RuntimeError: 135 pass 136 else: 137 raise RuntimeError( 138 "Cannot synchronously consume an async iterable from a running event loop" 139 ) 140 141 self._thread = threading.Thread( 142 target=self._thread_main, 143 name="hojichar-async-to-sync", 144 daemon=True, 145 ) 146 self._thread.start() 147 self._started_event.wait() 148 149 def _thread_main(self) -> None: 150 try: 151 asyncio.run(self._consume()) 152 except BaseException as error: 153 if not self._stop_event.is_set(): 154 self._put(_AsyncIteratorError(error)) 155 elif not isinstance(error, asyncio.CancelledError): 156 logger.error("Failed to close async-to-sync iterator", exc_info=True) 157 finally: 158 self._started_event.set() 159 if not self._stop_event.is_set(): 160 self._put(_ASYNC_ITERATOR_END) 161 162 async def _consume(self) -> None: 163 self._loop = asyncio.get_running_loop() 164 self._consumer_task = asyncio.current_task() 165 self._started_event.set() 166 iterator = None 167 168 try: 169 iterator = self._source_stream.__aiter__() 170 while not self._stop_event.is_set(): 171 try: 172 item = await iterator.__anext__() 173 except StopAsyncIteration: 174 break 175 if not self._put(item): 176 break 177 finally: 178 try: 179 aclose = getattr(iterator, "aclose", None) 180 if aclose is not None: 181 await aclose() 182 finally: 183 if self._finalizer is not None: 184 await self._finalizer() 185 186 def _put(self, item: Any) -> bool: 187 while not self._stop_event.is_set(): 188 try: 189 self._queue.put(item, timeout=0.05) 190 return True 191 except queue.Full: 192 continue 193 return False
Consume an async iterable from synchronous code.
A single background thread owns the event loop for the lifetime of the iterator. Use this class as a context manager when iteration may stop before the source is exhausted.
44 def __init__( 45 self, 46 source_stream: AsyncIterable[T], 47 *, 48 buffer_size: int = 128, 49 finalizer: Callable[[], Awaitable[None]] | None = None, 50 ) -> None: 51 if buffer_size < 1: 52 raise ValueError("buffer_size must be at least 1") 53 54 self._source_stream = source_stream 55 self._queue: queue.Queue[Any] = queue.Queue(maxsize=buffer_size) 56 self._finalizer = finalizer 57 self._stop_event = threading.Event() 58 self._started_event = threading.Event() 59 self._thread: threading.Thread | None = None 60 self._loop: asyncio.AbstractEventLoop | None = None 61 self._consumer_task: asyncio.Task[None] | None = None 62 self._closed = False
87 def close(self) -> None: 88 if self._closed: 89 return 90 self._closed = True 91 self._stop_event.set() 92 thread_was_started = self._thread is not None 93 94 if not thread_was_started: 95 # Closing an unconsumed iterator must still close its source and run its finalizer. 96 self._thread = threading.Thread( 97 target=self._thread_main, 98 name="hojichar-async-to-sync", 99 daemon=True, 100 ) 101 self._thread.start() 102 self._started_event.wait() 103 104 while True: 105 try: 106 self._queue.get_nowait() 107 except queue.Empty: 108 break 109 110 if ( 111 thread_was_started 112 and self._thread is not None 113 and self._thread.is_alive() 114 and self._loop is not None 115 and not self._loop.is_closed() 116 and self._consumer_task is not None 117 ): 118 try: 119 self._loop.call_soon_threadsafe(self._consumer_task.cancel) 120 except RuntimeError: 121 # The background loop can close between is_closed() and scheduling. 122 pass 123 124 if self._thread is not None: 125 self._thread.join(timeout=5.0) 126 if self._thread.is_alive(): 127 logger.warning("Async-to-sync iterator did not stop within 5 seconds")
196def handle_stream_as_async( 197 source_stream: Iterable[T] | AsyncIterable[T], 198 chunk_size: int = 1000, 199 executor: ThreadPoolExecutor | None = None, 200) -> AsyncGenerator[T, None]: 201 """ 202 Convert a synchronous iterable to an asynchronous generator 203 with a specified chunk size. 204 205 Args: 206 source_stream (Iterable[T]): The synchronous iterable to convert. 207 chunk_size (int): The number of items to yield at a time. 208 """ 209 if isinstance(source_stream, AsyncIterable): 210 return source_stream # type: ignore[return-value] 211 stream = iter(source_stream) 212 213 async def sync_to_async() -> AsyncGenerator[T, None]: 214 loop = asyncio.get_running_loop() 215 while True: 216 chunk = await loop.run_in_executor( 217 executor, lambda: list(itertools.islice(stream, chunk_size)) 218 ) 219 if not chunk: 220 break 221 for item in chunk: 222 yield item 223 224 return sync_to_async()
Convert a synchronous iterable to an asynchronous generator with a specified chunk size.
Args: source_stream (Iterable[T]): The synchronous iterable to convert. chunk_size (int): The number of items to yield at a time.
227def handle_async_stream_as_sync( 228 source_stream: AsyncIterable[T], 229 *, 230 buffer_size: int = 128, 231 finalizer: Callable[[], Awaitable[None]] | None = None, 232) -> AsyncToSyncIterator[T]: 233 """Convert an async iterable to a synchronous, closeable iterator. 234 235 The iterator owns a background event-loop thread. Fully consuming it closes the source 236 automatically. Use it as a context manager when the consumer may stop early. 237 """ 238 return AsyncToSyncIterator( 239 source_stream, 240 buffer_size=buffer_size, 241 finalizer=finalizer, 242 )
Convert an async iterable to a synchronous, closeable iterator.
The iterator owns a background event-loop thread. Fully consuming it closes the source automatically. Use it as a context manager when the consumer may stop early.
245async def write_stream_to_file( 246 stream: AsyncGenerator[str, None], 247 output_path: Path | str, 248 *, 249 chunk_size: int = 1000, 250 delimiter: str = "\n", 251) -> None: 252 """ 253 Write an asynchronous stream of strings to a file. 254 To lessen overhead with file I/O, it writes in chunks. 255 """ 256 loop = asyncio.get_running_loop() 257 with open(output_path, "w", encoding="utf-8") as f: 258 chunk = [] 259 async for line in stream: 260 chunk.append(line) 261 if len(chunk) >= chunk_size: 262 await loop.run_in_executor(None, f.writelines, [s + delimiter for s in chunk]) 263 chunk = [] 264 if chunk: 265 await loop.run_in_executor(None, f.writelines, [s + delimiter for s in chunk]) 266 chunk = [] 267 await loop.run_in_executor(None, f.flush)
Write an asynchronous stream of strings to a file. To lessen overhead with file I/O, it writes in chunks.
270async def fileout_from_async_iter( 271 fp: TextIO, iter: AsyncIterable[str], buffer_size: int = 128 272) -> None: 273 buffer = [] 274 async for line in iter: 275 buffer.append(line + "\n") 276 if len(buffer) >= buffer_size: 277 await asyncio.to_thread(fp.write, "".join(buffer)) 278 buffer.clear() 279 await asyncio.to_thread(fp.writelines, buffer) 280 buffer.clear()