Skip to content

Commit 1a7295b

Browse files
committed
webbrowser: fix compatibility with trio 0.25
- Set min. version requirement of `trio` to `0.25`, so we don't have to set `strict_exception_groups` to `True` on older versions (probably not even possible via `pytest-trio`) - Fix compatibility with `trio>=0.25`: Since `strict_exception_groups` now defaults to `True`, trio nurseries now always raise an `ExceptionGroup` in all cases, so update tests and handle exception groups instead. Don't unwrap exception groups for now, even if only a single exception is included. Explicitly handle `KeyboardInterrupt`/`SystemExit` and re-raise by using the `exceptiongroup.catch` utility (<py311 compat)
1 parent af4c691 commit 1a7295b

File tree

7 files changed

+92
-65
lines changed

7 files changed

+92
-65
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ dependencies = [
6363
"pycryptodome >=3.4.3,<4",
6464
"PySocks >=1.5.6,!=1.5.7",
6565
"requests >=2.26.0,<3",
66-
"trio >=0.22.0,<0.25",
66+
"trio >=0.25.0,<1",
6767
"trio-websocket >=0.9.0,<1",
6868
"typing-extensions >=4.0.0",
6969
"urllib3 >=1.26.0,<3",

src/streamlink/webbrowser/cdp/connection.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
21
from __future__ import annotations
32

43
import dataclasses

src/streamlink/webbrowser/webbrowser.py

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import AsyncContextManager, AsyncGenerator, Generator, List, Optional, Union
99

1010
import trio
11+
from exceptiongroup import BaseExceptionGroup, catch
1112

1213
from streamlink.utils.path import resolve_executable
1314
from streamlink.webbrowser.exceptions import WebbrowserError
@@ -59,7 +60,7 @@ def _launch(
5960

6061
launcher = _WebbrowserLauncher(executable, arguments, timeout)
6162

62-
# noinspection PyTypeChecker
63+
# noinspection PyArgumentList
6364
return launcher.launch()
6465

6566
@staticmethod
@@ -79,37 +80,39 @@ def __init__(self, executable: Union[str, Path], arguments: List[str], timeout:
7980

8081
@asynccontextmanager
8182
async def launch(self) -> AsyncGenerator[trio.Nursery, None]:
82-
async with trio.open_nursery() as nursery:
83-
log.info(f"Launching web browser: {self.executable}")
84-
# the process is run in a separate task
85-
run_process = partial(
86-
trio.run_process,
87-
[self.executable, *self.arguments],
88-
check=False,
89-
stdout=DEVNULL,
90-
stderr=DEVNULL,
91-
)
92-
# trio ensures that the process gets terminated when the task group gets cancelled
93-
process: trio.Process = await nursery.start(run_process)
94-
# the process watcher task cancels the entire task group when the user terminates/kills the process
95-
nursery.start_soon(self._task_process_watcher, process, nursery)
96-
try:
97-
# the application logic is run here
98-
with trio.move_on_after(self.timeout) as cancel_scope:
99-
yield nursery
100-
except BaseException:
101-
# handle KeyboardInterrupt and SystemExit
102-
raise
103-
else:
104-
# check if the application logic has timed out
105-
if cancel_scope.cancelled_caught:
106-
log.warning("Web browser task group has timed out")
107-
finally:
108-
# check if the task group hasn't been cancelled yet in the process watcher task
109-
if not self._process_ended_early:
110-
log.debug("Waiting for web browser process to terminate")
111-
# once the application logic is done, cancel the entire task group and terminate/kill the process
112-
nursery.cancel_scope.cancel()
83+
def handle_baseexception(exc_grp: BaseExceptionGroup) -> None:
84+
raise exc_grp.exceptions[0] from exc_grp.exceptions[0].__context__
85+
86+
with catch({ # type: ignore[dict-item] # bug in exceptiongroup==1.2.0
87+
(KeyboardInterrupt, SystemExit): handle_baseexception, # type: ignore[dict-item] # bug in exceptiongroup==1.2.0
88+
}):
89+
async with trio.open_nursery() as nursery:
90+
log.info(f"Launching web browser: {self.executable}")
91+
# the process is run in a separate task
92+
run_process = partial(
93+
trio.run_process,
94+
[self.executable, *self.arguments],
95+
check=False,
96+
stdout=DEVNULL,
97+
stderr=DEVNULL,
98+
)
99+
# trio ensures that the process gets terminated when the task group gets cancelled
100+
process: trio.Process = await nursery.start(run_process)
101+
# the process watcher task cancels the entire task group when the user terminates/kills the process
102+
nursery.start_soon(self._task_process_watcher, process, nursery)
103+
try:
104+
# the application logic is run here
105+
with trio.move_on_after(self.timeout) as cancel_scope:
106+
yield nursery
107+
# check if the application logic has timed out
108+
if cancel_scope.cancelled_caught:
109+
log.warning("Web browser task group has timed out")
110+
finally:
111+
# check if the task group hasn't been cancelled yet in the process watcher task
112+
if not self._process_ended_early:
113+
log.debug("Waiting for web browser process to terminate")
114+
# once the application logic is done, cancel the entire task group and terminate/kill the process
115+
nursery.cancel_scope.cancel()
113116

114117
async def _task_process_watcher(self, process: trio.Process, nursery: trio.Nursery) -> None:
115118
"""Task for cancelling the launch task group if the user closes the browser or if it exits early on its own"""

tests/webbrowser/cdp/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
2-
from __future__ import annotations
3-
41
from typing import List
52

63
import trio

tests/webbrowser/cdp/test_client.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66
import trio
7+
from exceptiongroup import ExceptionGroup
78
from trio.testing import wait_all_tasks_blocked
89

910
from streamlink.session import Streamlink
@@ -178,7 +179,7 @@ async def evaluate():
178179

179180
@pytest.mark.trio()
180181
async def test_exception(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
181-
with pytest.raises(CDPError, match="^SyntaxError: Invalid regular expression: missing /$"): # noqa: PT012
182+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
182183
async with trio.open_nursery() as nursery:
183184
nursery.start_soon(cdp_client_session.evaluate, "/")
184185

@@ -202,9 +203,11 @@ async def test_exception(self, cdp_client_session: CDPClientSession, websocket_c
202203
}}
203204
""")
204205

206+
assert excinfo.group_contains(CDPError, match="^SyntaxError: Invalid regular expression: missing /$")
207+
205208
@pytest.mark.trio()
206209
async def test_error(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
207-
with pytest.raises(CDPError, match="^Error: foo\\n at <anonymous>:1:1$"): # noqa: PT012
210+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
208211
async with trio.open_nursery() as nursery:
209212
nursery.start_soon(cdp_client_session.evaluate, "new Error('foo')")
210213

@@ -221,6 +224,8 @@ async def test_error(self, cdp_client_session: CDPClientSession, websocket_conne
221224
}}
222225
""")
223226

227+
assert excinfo.group_contains(CDPError, match="^Error: foo\\n at <anonymous>:1:1$")
228+
224229

225230
class TestRequestPausedHandler:
226231
@pytest.mark.parametrize(("url_pattern", "regex_pattern"), [
@@ -384,7 +389,7 @@ async def navigate():
384389
async with cdp_client_session.navigate("https://foo"):
385390
pass # pragma: no cover
386391

387-
with pytest.raises(CDPError, match="^Target has been detached$"): # noqa: PT012
392+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
388393
async with trio.open_nursery() as nursery:
389394
nursery.start_soon(navigate)
390395

@@ -397,13 +402,15 @@ async def navigate():
397402
"""{"method":"Target.detachedFromTarget","params":{"sessionId":"56789"}}""",
398403
)
399404

405+
assert excinfo.group_contains(CDPError, match="^Target has been detached$")
406+
400407
@pytest.mark.trio()
401408
async def test_error(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
402409
async def navigate():
403410
async with cdp_client_session.navigate("https://foo"):
404411
pass # pragma: no cover
405412

406-
with pytest.raises(CDPError, match="^Navigation error: failure$"): # noqa: PT012
413+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
407414
async with trio.open_nursery() as nursery:
408415
nursery.start_soon(navigate)
409416

@@ -435,6 +442,8 @@ async def navigate():
435442
"""{"id":2,"result":{},"sessionId":"56789"}""",
436443
)
437444

445+
assert excinfo.group_contains(CDPError, match="^Navigation error: failure$")
446+
438447
@pytest.mark.trio()
439448
async def test_loaded(
440449
self,

tests/webbrowser/cdp/test_connection.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
from contextlib import nullcontext
23
from dataclasses import dataclass
34
from functools import partial
@@ -6,6 +7,7 @@
67

78
import pytest
89
import trio
10+
from exceptiongroup import ExceptionGroup
911
from trio.testing import MockClock, wait_all_tasks_blocked
1012
from trio_websocket import CloseReason, ConnectionClosed, ConnectionTimeout # type: ignore[import]
1113

@@ -76,49 +78,64 @@ async def test_success(self, cdp_connection: CDPConnection):
7678
async def test_failure(self, monkeypatch: pytest.MonkeyPatch):
7779
fake_connect_websocket_url = AsyncMock(side_effect=ConnectionTimeout)
7880
monkeypatch.setattr("streamlink.webbrowser.cdp.connection.connect_websocket_url", fake_connect_websocket_url)
79-
with pytest.raises(ConnectionTimeout):
81+
with pytest.raises(ExceptionGroup) as excinfo:
8082
async with CDPConnection.create("ws://localhost:1234/fake"):
8183
pass # pragma: no cover
84+
assert excinfo.group_contains(ConnectionTimeout)
8285

8386
@pytest.mark.trio()
8487
@pytest.mark.parametrize(("timeout", "expected"), [
8588
pytest.param(None, 2, id="Default value of 2 seconds"),
8689
pytest.param(0, 2, id="No timeout uses default value"),
8790
pytest.param(3, 3, id="Custom timeout value"),
8891
])
89-
async def test_timeout(self, monkeypatch: pytest.MonkeyPatch, websocket_connection, timeout, expected):
90-
async with CDPConnection.create("ws://localhost:1234/fake", timeout=timeout) as cdp_connection:
92+
async def test_timeout(self, websocket_connection: FakeWebsocketConnection, timeout: Optional[int], expected: int):
93+
async with CDPConnection.create("ws://localhost:1234/fake", timeout=timeout) as cdp_conn:
9194
pass
92-
assert cdp_connection.cmd_timeout == expected
95+
assert cdp_conn.cmd_timeout == expected
9396

9497

9598
class TestReaderError:
9699
@pytest.mark.trio()
97100
async def test_invalid_json(self, caplog: pytest.LogCaptureFixture, websocket_connection: FakeWebsocketConnection):
98-
with pytest.raises(CDPError) as cm: # noqa: PT012
101+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
99102
async with CDPConnection.create("ws://localhost:1234/fake"):
100103
assert not websocket_connection.closed
101104
await websocket_connection.sender.send("INVALID JSON")
102105
await wait_all_tasks_blocked()
103106

104-
assert str(cm.value) == "Received invalid CDP JSON data: Expecting value: line 1 column 1 (char 0)"
107+
assert excinfo.group_contains(
108+
CDPError,
109+
match=r"^Received invalid CDP JSON data: Expecting value: line 1 column 1 \(char 0\)$",
110+
)
105111
assert caplog.records == []
106112

107113
@pytest.mark.trio()
108114
async def test_unknown_session_id(self, caplog: pytest.LogCaptureFixture, websocket_connection: FakeWebsocketConnection):
109-
with pytest.raises(CDPError) as cm: # noqa: PT012
115+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
110116
async with CDPConnection.create("ws://localhost:1234/fake"):
111117
assert not websocket_connection.closed
112118
await websocket_connection.sender.send("""{"sessionId":"unknown"}""")
113119
await wait_all_tasks_blocked()
114120

115-
assert str(cm.value) == "Unknown CDP session ID: SessionID('unknown')"
121+
assert excinfo.group_contains(CDPError, match=r"^Unknown CDP session ID: SessionID\('unknown'\)$")
116122
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
117123
("streamlink.webbrowser.cdp.connection", "all", """Received message: {"sessionId":"unknown"}"""),
118124
]
119125

120126

127+
@contextlib.contextmanager
128+
def raises_group(*group_contains):
129+
try:
130+
with pytest.raises(ExceptionGroup) as excinfo:
131+
yield
132+
finally:
133+
for args, kwargs, expected in group_contains:
134+
assert excinfo.group_contains(*args, **kwargs) is expected
135+
136+
121137
class TestSend:
138+
# noinspection PyUnusedLocal
122139
@pytest.mark.trio()
123140
@pytest.mark.parametrize(("timeout", "jump", "raises"), [
124141
pytest.param(
@@ -130,7 +147,9 @@ class TestSend:
130147
pytest.param(
131148
None,
132149
2,
133-
pytest.raises(CDPError, match="^Sending CDP message and receiving its response timed out$"),
150+
raises_group(
151+
((CDPError,), {"match": "^Sending CDP message and receiving its response timed out$"}, True),
152+
),
134153
id="Default timeout, response not in time",
135154
),
136155
pytest.param(
@@ -142,7 +161,9 @@ class TestSend:
142161
pytest.param(
143162
3,
144163
3,
145-
pytest.raises(CDPError, match="^Sending CDP message and receiving its response timed out$"),
164+
raises_group(
165+
((CDPError,), {"match": "^Sending CDP message and receiving its response timed out$"}, True),
166+
),
146167
id="Custom timeout, response not in time",
147168
),
148169
])
@@ -210,12 +231,12 @@ async def test_bad_command(
210231
assert cdp_connection.cmd_buffers == {}
211232
assert websocket_connection.sent == []
212233

213-
with pytest.raises(CDPError) as cm: # noqa: PT012
234+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
214235
async with trio.open_nursery() as nursery:
215236
nursery.start_soon(cdp_connection.send, bad_command())
216237
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"result":{}}""")
217238

218-
assert str(cm.value) == "Generator of CDP command ID 0 did not exit when expected!"
239+
assert excinfo.group_contains(CDPError, match="^Generator of CDP command ID 0 did not exit when expected!$")
219240
assert cdp_connection.cmd_buffers == {}
220241
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.badCommand","params":{}}"""]
221242
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@@ -241,12 +262,12 @@ async def test_result_exception(
241262
assert cdp_connection.cmd_buffers == {}
242263
assert websocket_connection.sent == []
243264

244-
with pytest.raises(CDPError) as cm: # noqa: PT012
265+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
245266
async with trio.open_nursery() as nursery:
246267
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
247268
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"result":{}}""")
248269

249-
assert str(cm.value) == "Generator of CDP command ID 0 raised KeyError: 'value'"
270+
assert excinfo.group_contains(CDPError, match="^Generator of CDP command ID 0 raised KeyError: 'value'$")
250271
assert cdp_connection.cmd_buffers == {}
251272
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
252273
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@@ -364,12 +385,12 @@ async def test_response_error(
364385
assert cdp_connection.cmd_buffers == {}
365386
assert websocket_connection.sent == []
366387

367-
with pytest.raises(CDPError) as cm: # noqa: PT012
388+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
368389
async with trio.open_nursery() as nursery:
369390
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
370391
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"error":"Some error message"}""")
371392

372-
assert str(cm.value) == "Error in CDP command response 0: Some error message"
393+
assert excinfo.group_contains(CDPError, match="^Error in CDP command response 0: Some error message$")
373394
assert cdp_connection.cmd_buffers == {}
374395
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
375396
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@@ -395,12 +416,12 @@ async def test_response_no_result(
395416
assert cdp_connection.cmd_buffers == {}
396417
assert websocket_connection.sent == []
397418

398-
with pytest.raises(CDPError) as cm: # noqa: PT012
419+
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
399420
async with trio.open_nursery() as nursery:
400421
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
401422
nursery.start_soon(websocket_connection.sender.send, """{"id":0}""")
402423

403-
assert str(cm.value) == "No result in CDP command response 0"
424+
assert excinfo.group_contains(CDPError, match="^No result in CDP command response 0$")
404425
assert cdp_connection.cmd_buffers == {}
405426
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
406427
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [

tests/webbrowser/test_webbrowser.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,13 @@ async def test_terminate_on_nursery_timeout(self, caplog: pytest.LogCaptureFixtu
9797
]
9898

9999
@pytest.mark.trio()
100-
async def test_terminate_on_nursery_baseexception(self, caplog: pytest.LogCaptureFixture, webbrowser_launch):
101-
class FakeBaseException(BaseException):
102-
pass
103-
100+
@pytest.mark.parametrize("exception", [KeyboardInterrupt, SystemExit])
101+
async def test_terminate_on_nursery_baseexception(self, caplog: pytest.LogCaptureFixture, webbrowser_launch, exception):
104102
process: trio.Process
105-
with pytest.raises(FakeBaseException): # noqa: PT012
103+
with pytest.raises(exception): # noqa: PT012
106104
async with webbrowser_launch() as (_nursery, process):
107105
assert process.poll() is None, "process is still running"
108-
raise FakeBaseException()
106+
raise exception()
109107

110108
assert process.poll() == (1 if is_win32 else -SIGTERM), "Process has been terminated"
111109
assert [(record.name, record.levelname, record.msg) for record in caplog.records] == [

0 commit comments

Comments
 (0)