Skip to content

Commit 2d42b3a

Browse files
committed
Merge remote-tracking branch 'origin/main' into page-method-navigation-result
2 parents a7b5ab3 + efe1bf8 commit 2d42b3a

13 files changed

Lines changed: 851 additions & 897 deletions

File tree

.github/workflows/tests.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ jobs:
4040
restore-keys: |
4141
${{ runner.os }}-playwright-
4242
43+
- name: Install Playwright system dependencies
44+
if: runner.os == 'Linux'
45+
uses: nick-fields/retry@v4
46+
with:
47+
timeout_minutes: 5
48+
max_attempts: 3
49+
command: pip install playwright && playwright install-deps
50+
4351
- name: Install tox
4452
run: pip install tox
4553

@@ -109,6 +117,13 @@ jobs:
109117
restore-keys: |
110118
${{ runner.os }}-playwright-
111119
120+
- name: Install Playwright system dependencies
121+
uses: nick-fields/retry@v4
122+
with:
123+
timeout_minutes: 5
124+
max_attempts: 3
125+
command: pip install playwright && playwright install-deps
126+
112127
- name: Install tox
113128
run: pip install tox
114129

pylintrc

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ disable=
1010
too-few-public-methods,
1111
too-many-arguments,
1212
too-many-instance-attributes,
13-
too-many-lines,
1413
# tests
1514
duplicate-code,
1615
import-outside-toplevel,

scrapy_playwright/_loop.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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

Comments
 (0)