|
| 1 | +import asyncio |
| 2 | +import platform |
| 3 | +from dataclasses import dataclass |
| 4 | +from threading import Thread |
| 5 | +from typing import Awaitable, Dict |
| 6 | + |
| 7 | +from twisted.internet.defer import Deferred |
| 8 | +from twisted.python import failure |
| 9 | + |
| 10 | +from scrapy_playwright._utils import logger |
| 11 | + |
| 12 | + |
| 13 | +@dataclass |
| 14 | +class _QueueItem: |
| 15 | + coro: Awaitable |
| 16 | + promise: Deferred | asyncio.Future |
| 17 | + loop: asyncio.AbstractEventLoop | None = None |
| 18 | + |
| 19 | + |
| 20 | +class _ThreadedLoopAdapter: |
| 21 | + """Utility class to start an asyncio event loop in a new thread and redirect coroutines. |
| 22 | + This allows to run Playwright in a different loop than the Scrapy crawler, allowing to |
| 23 | + use ProactorEventLoop which is supported by Playwright on Windows. |
| 24 | + """ |
| 25 | + |
| 26 | + _loop: asyncio.AbstractEventLoop |
| 27 | + _thread: Thread |
| 28 | + _coro_queue: asyncio.Queue = asyncio.Queue() |
| 29 | + _stop_events: Dict[int, asyncio.Event] = {} |
| 30 | + |
| 31 | + @classmethod |
| 32 | + async def _handle_coro_deferred(cls, queue_item: _QueueItem) -> None: |
| 33 | + from twisted.internet import reactor |
| 34 | + |
| 35 | + dfd: Deferred = queue_item.promise |
| 36 | + |
| 37 | + try: |
| 38 | + result = await queue_item.coro |
| 39 | + except Exception as exc: |
| 40 | + reactor.callFromThread(dfd.errback, failure.Failure(exc)) |
| 41 | + else: |
| 42 | + reactor.callFromThread(dfd.callback, result) |
| 43 | + |
| 44 | + @classmethod |
| 45 | + async def _handle_coro_future(cls, queue_item: _QueueItem) -> None: |
| 46 | + future: asyncio.Future = queue_item.promise |
| 47 | + loop: asyncio.AbstractEventLoop = queue_item.loop # type: ignore[assignment] |
| 48 | + try: |
| 49 | + result = await queue_item.coro |
| 50 | + except Exception as exc: |
| 51 | + loop.call_soon_threadsafe(future.set_exception, exc) |
| 52 | + else: |
| 53 | + loop.call_soon_threadsafe(future.set_result, result) |
| 54 | + |
| 55 | + @classmethod |
| 56 | + async def _process_queue(cls) -> None: |
| 57 | + while any(not ev.is_set() for ev in cls._stop_events.values()): |
| 58 | + queue_item = await cls._coro_queue.get() |
| 59 | + if isinstance(queue_item.promise, asyncio.Future): |
| 60 | + asyncio.create_task(cls._handle_coro_future(queue_item)) |
| 61 | + elif isinstance(queue_item.promise, Deferred): |
| 62 | + asyncio.create_task(cls._handle_coro_deferred(queue_item)) |
| 63 | + cls._coro_queue.task_done() |
| 64 | + |
| 65 | + @classmethod |
| 66 | + def _deferred_from_coro(cls, coro: Awaitable) -> Deferred: |
| 67 | + dfd: Deferred = Deferred() |
| 68 | + queue_item = _QueueItem(coro=coro, promise=dfd) |
| 69 | + asyncio.run_coroutine_threadsafe(cls._coro_queue.put(queue_item), cls._loop) |
| 70 | + return dfd |
| 71 | + |
| 72 | + @classmethod |
| 73 | + def _future_from_coro(cls, coro: Awaitable) -> asyncio.Future: |
| 74 | + target_loop = asyncio.get_running_loop() # Scrapy thread loop |
| 75 | + future: asyncio.Future = asyncio.Future() |
| 76 | + queue_item = _QueueItem(coro=coro, promise=future, loop=target_loop) |
| 77 | + asyncio.run_coroutine_threadsafe(cls._coro_queue.put(queue_item), cls._loop) |
| 78 | + return future |
| 79 | + |
| 80 | + @classmethod |
| 81 | + def start(cls, download_handler_id: int) -> None: |
| 82 | + """Start the event loop in a new thread if not already started. |
| 83 | + Should be called from the Scrapy thread. |
| 84 | + """ |
| 85 | + cls._stop_events[download_handler_id] = asyncio.Event() |
| 86 | + if not getattr(cls, "_loop", None): |
| 87 | + policy = asyncio.DefaultEventLoopPolicy() |
| 88 | + if platform.system() == "Windows": |
| 89 | + policy = asyncio.WindowsProactorEventLoopPolicy() # type: ignore[attr-defined] |
| 90 | + cls._loop = policy.new_event_loop() |
| 91 | + |
| 92 | + if not getattr(cls, "_thread", None): |
| 93 | + cls._thread = Thread(target=cls._loop.run_forever, daemon=True) |
| 94 | + cls._thread.start() |
| 95 | + logger.info("Started loop on separate thread: %s", cls._loop) |
| 96 | + asyncio.run_coroutine_threadsafe(cls._process_queue(), cls._loop) |
| 97 | + |
| 98 | + @classmethod |
| 99 | + def stop(cls, download_handler_id: int) -> None: |
| 100 | + """Wait until all handlers are closed to stop the event loop and join the thread. |
| 101 | + Should be called from the Scrapy thread. |
| 102 | + """ |
| 103 | + cls._stop_events[download_handler_id].set() |
| 104 | + if all(ev.is_set() for ev in cls._stop_events.values()): |
| 105 | + asyncio.run_coroutine_threadsafe(cls._coro_queue.join(), cls._loop) |
| 106 | + cls._loop.call_soon_threadsafe(cls._loop.stop) |
| 107 | + cls._thread.join() |
0 commit comments