Coverage for src/chat_limiter/utils.py: 84%
38 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-09-18 21:15 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-09-18 21:15 +0100
1"""
2Utilities for handling asyncio execution contexts.
4Fail-fast, minimal helpers to bridge async code into sync callers.
5"""
7import asyncio
8import concurrent.futures
9from functools import lru_cache
10from typing import Any, TypeVar
12T = TypeVar("T")
15@lru_cache(maxsize=1)
16def _get_background_executor() -> concurrent.futures.ThreadPoolExecutor:
17 """Return a singleton background executor for bridging from running loops."""
18 return concurrent.futures.ThreadPoolExecutor(max_workers=1)
21def run_coro_blocking(coro: Any) -> T:
22 """Run the given coroutine to completion, regardless of event loop state.
24 - If no loop is running, call asyncio.run(coro).
25 - If a loop is running (e.g., Jupyter), execute asyncio.run(coro) in a new thread.
26 """
27 try:
28 asyncio.get_running_loop()
29 in_async_context = True
30 except RuntimeError:
31 in_async_context = False
33 if not in_async_context:
34 try:
35 return asyncio.run(coro) # type: ignore[no-any-return]
36 finally:
37 # Ensure the coroutine is closed even if asyncio.run was mocked
38 # and did not consume it, to avoid "coroutine was never awaited" warnings.
39 try:
40 coro.close() # type: ignore[attr-defined]
41 except AttributeError:
42 pass
44 def _runner() -> T:
45 return asyncio.run(coro) # type: ignore[no-any-return]
47 try:
48 executor = _get_background_executor()
49 except Exception:
50 # Ensure the coroutine is closed to avoid 'was never awaited' warnings
51 try:
52 coro.close() # type: ignore[attr-defined]
53 except AttributeError:
54 pass
55 raise
56 try:
57 future: concurrent.futures.Future[T] = executor.submit(_runner)
58 return future.result()
59 finally:
60 # Close the coroutine in case the background runner did not
61 # actually execute it (e.g., asyncio.run mocked in tests).
62 try:
63 coro.close() # type: ignore[attr-defined]
64 except AttributeError:
65 pass