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)
This commit is contained in:
bastimeyer 2024-03-17 22:04:33 +01:00 committed by Sebastian Meyer
parent af4c69188a
commit 1a7295b110
7 changed files with 92 additions and 65 deletions

View File

@ -63,7 +63,7 @@ dependencies = [
"pycryptodome >=3.4.3,<4",
"PySocks >=1.5.6,!=1.5.7",
"requests >=2.26.0,<3",
"trio >=0.22.0,<0.25",
"trio >=0.25.0,<1",
"trio-websocket >=0.9.0,<1",
"typing-extensions >=4.0.0",
"urllib3 >=1.26.0,<3",

View File

@ -1,4 +1,3 @@
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
from __future__ import annotations
import dataclasses

View File

@ -8,6 +8,7 @@ from subprocess import DEVNULL
from typing import AsyncContextManager, AsyncGenerator, Generator, List, Optional, Union
import trio
from exceptiongroup import BaseExceptionGroup, catch
from streamlink.utils.path import resolve_executable
from streamlink.webbrowser.exceptions import WebbrowserError
@ -59,7 +60,7 @@ class Webbrowser:
launcher = _WebbrowserLauncher(executable, arguments, timeout)
# noinspection PyTypeChecker
# noinspection PyArgumentList
return launcher.launch()
@staticmethod
@ -79,37 +80,39 @@ class _WebbrowserLauncher:
@asynccontextmanager
async def launch(self) -> AsyncGenerator[trio.Nursery, None]:
async with trio.open_nursery() as nursery:
log.info(f"Launching web browser: {self.executable}")
# the process is run in a separate task
run_process = partial(
trio.run_process,
[self.executable, *self.arguments],
check=False,
stdout=DEVNULL,
stderr=DEVNULL,
)
# trio ensures that the process gets terminated when the task group gets cancelled
process: trio.Process = await nursery.start(run_process)
# the process watcher task cancels the entire task group when the user terminates/kills the process
nursery.start_soon(self._task_process_watcher, process, nursery)
try:
# the application logic is run here
with trio.move_on_after(self.timeout) as cancel_scope:
yield nursery
except BaseException:
# handle KeyboardInterrupt and SystemExit
raise
else:
# check if the application logic has timed out
if cancel_scope.cancelled_caught:
log.warning("Web browser task group has timed out")
finally:
# check if the task group hasn't been cancelled yet in the process watcher task
if not self._process_ended_early:
log.debug("Waiting for web browser process to terminate")
# once the application logic is done, cancel the entire task group and terminate/kill the process
nursery.cancel_scope.cancel()
def handle_baseexception(exc_grp: BaseExceptionGroup) -> None:
raise exc_grp.exceptions[0] from exc_grp.exceptions[0].__context__
with catch({ # type: ignore[dict-item] # bug in exceptiongroup==1.2.0
(KeyboardInterrupt, SystemExit): handle_baseexception, # type: ignore[dict-item] # bug in exceptiongroup==1.2.0
}):
async with trio.open_nursery() as nursery:
log.info(f"Launching web browser: {self.executable}")
# the process is run in a separate task
run_process = partial(
trio.run_process,
[self.executable, *self.arguments],
check=False,
stdout=DEVNULL,
stderr=DEVNULL,
)
# trio ensures that the process gets terminated when the task group gets cancelled
process: trio.Process = await nursery.start(run_process)
# the process watcher task cancels the entire task group when the user terminates/kills the process
nursery.start_soon(self._task_process_watcher, process, nursery)
try:
# the application logic is run here
with trio.move_on_after(self.timeout) as cancel_scope:
yield nursery
# check if the application logic has timed out
if cancel_scope.cancelled_caught:
log.warning("Web browser task group has timed out")
finally:
# check if the task group hasn't been cancelled yet in the process watcher task
if not self._process_ended_early:
log.debug("Waiting for web browser process to terminate")
# once the application logic is done, cancel the entire task group and terminate/kill the process
nursery.cancel_scope.cancel()
async def _task_process_watcher(self, process: trio.Process, nursery: trio.Nursery) -> None:
"""Task for cancelling the launch task group if the user closes the browser or if it exits early on its own"""

View File

@ -1,6 +1,3 @@
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
from __future__ import annotations
from typing import List
import trio

View File

@ -4,6 +4,7 @@ from unittest.mock import ANY, AsyncMock, Mock, call
import pytest
import trio
from exceptiongroup import ExceptionGroup
from trio.testing import wait_all_tasks_blocked
from streamlink.session import Streamlink
@ -178,7 +179,7 @@ class TestEvaluate:
@pytest.mark.trio()
async def test_exception(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
with pytest.raises(CDPError, match="^SyntaxError: Invalid regular expression: missing /$"): # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_client_session.evaluate, "/")
@ -202,9 +203,11 @@ class TestEvaluate:
}}
""")
assert excinfo.group_contains(CDPError, match="^SyntaxError: Invalid regular expression: missing /$")
@pytest.mark.trio()
async def test_error(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
with pytest.raises(CDPError, match="^Error: foo\\n at <anonymous>:1:1$"): # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_client_session.evaluate, "new Error('foo')")
@ -221,6 +224,8 @@ class TestEvaluate:
}}
""")
assert excinfo.group_contains(CDPError, match="^Error: foo\\n at <anonymous>:1:1$")
class TestRequestPausedHandler:
@pytest.mark.parametrize(("url_pattern", "regex_pattern"), [
@ -384,7 +389,7 @@ class TestNavigate:
async with cdp_client_session.navigate("https://foo"):
pass # pragma: no cover
with pytest.raises(CDPError, match="^Target has been detached$"): # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(navigate)
@ -397,13 +402,15 @@ class TestNavigate:
"""{"method":"Target.detachedFromTarget","params":{"sessionId":"56789"}}""",
)
assert excinfo.group_contains(CDPError, match="^Target has been detached$")
@pytest.mark.trio()
async def test_error(self, cdp_client_session: CDPClientSession, websocket_connection: FakeWebsocketConnection):
async def navigate():
async with cdp_client_session.navigate("https://foo"):
pass # pragma: no cover
with pytest.raises(CDPError, match="^Navigation error: failure$"): # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(navigate)
@ -435,6 +442,8 @@ class TestNavigate:
"""{"id":2,"result":{},"sessionId":"56789"}""",
)
assert excinfo.group_contains(CDPError, match="^Navigation error: failure$")
@pytest.mark.trio()
async def test_loaded(
self,

View File

@ -1,3 +1,4 @@
import contextlib
from contextlib import nullcontext
from dataclasses import dataclass
from functools import partial
@ -6,6 +7,7 @@ from unittest.mock import AsyncMock
import pytest
import trio
from exceptiongroup import ExceptionGroup
from trio.testing import MockClock, wait_all_tasks_blocked
from trio_websocket import CloseReason, ConnectionClosed, ConnectionTimeout # type: ignore[import]
@ -76,9 +78,10 @@ class TestCreateConnection:
async def test_failure(self, monkeypatch: pytest.MonkeyPatch):
fake_connect_websocket_url = AsyncMock(side_effect=ConnectionTimeout)
monkeypatch.setattr("streamlink.webbrowser.cdp.connection.connect_websocket_url", fake_connect_websocket_url)
with pytest.raises(ConnectionTimeout):
with pytest.raises(ExceptionGroup) as excinfo:
async with CDPConnection.create("ws://localhost:1234/fake"):
pass # pragma: no cover
assert excinfo.group_contains(ConnectionTimeout)
@pytest.mark.trio()
@pytest.mark.parametrize(("timeout", "expected"), [
@ -86,39 +89,53 @@ class TestCreateConnection:
pytest.param(0, 2, id="No timeout uses default value"),
pytest.param(3, 3, id="Custom timeout value"),
])
async def test_timeout(self, monkeypatch: pytest.MonkeyPatch, websocket_connection, timeout, expected):
async with CDPConnection.create("ws://localhost:1234/fake", timeout=timeout) as cdp_connection:
async def test_timeout(self, websocket_connection: FakeWebsocketConnection, timeout: Optional[int], expected: int):
async with CDPConnection.create("ws://localhost:1234/fake", timeout=timeout) as cdp_conn:
pass
assert cdp_connection.cmd_timeout == expected
assert cdp_conn.cmd_timeout == expected
class TestReaderError:
@pytest.mark.trio()
async def test_invalid_json(self, caplog: pytest.LogCaptureFixture, websocket_connection: FakeWebsocketConnection):
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with CDPConnection.create("ws://localhost:1234/fake"):
assert not websocket_connection.closed
await websocket_connection.sender.send("INVALID JSON")
await wait_all_tasks_blocked()
assert str(cm.value) == "Received invalid CDP JSON data: Expecting value: line 1 column 1 (char 0)"
assert excinfo.group_contains(
CDPError,
match=r"^Received invalid CDP JSON data: Expecting value: line 1 column 1 \(char 0\)$",
)
assert caplog.records == []
@pytest.mark.trio()
async def test_unknown_session_id(self, caplog: pytest.LogCaptureFixture, websocket_connection: FakeWebsocketConnection):
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with CDPConnection.create("ws://localhost:1234/fake"):
assert not websocket_connection.closed
await websocket_connection.sender.send("""{"sessionId":"unknown"}""")
await wait_all_tasks_blocked()
assert str(cm.value) == "Unknown CDP session ID: SessionID('unknown')"
assert excinfo.group_contains(CDPError, match=r"^Unknown CDP session ID: SessionID\('unknown'\)$")
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
("streamlink.webbrowser.cdp.connection", "all", """Received message: {"sessionId":"unknown"}"""),
]
@contextlib.contextmanager
def raises_group(*group_contains):
try:
with pytest.raises(ExceptionGroup) as excinfo:
yield
finally:
for args, kwargs, expected in group_contains:
assert excinfo.group_contains(*args, **kwargs) is expected
class TestSend:
# noinspection PyUnusedLocal
@pytest.mark.trio()
@pytest.mark.parametrize(("timeout", "jump", "raises"), [
pytest.param(
@ -130,7 +147,9 @@ class TestSend:
pytest.param(
None,
2,
pytest.raises(CDPError, match="^Sending CDP message and receiving its response timed out$"),
raises_group(
((CDPError,), {"match": "^Sending CDP message and receiving its response timed out$"}, True),
),
id="Default timeout, response not in time",
),
pytest.param(
@ -142,7 +161,9 @@ class TestSend:
pytest.param(
3,
3,
pytest.raises(CDPError, match="^Sending CDP message and receiving its response timed out$"),
raises_group(
((CDPError,), {"match": "^Sending CDP message and receiving its response timed out$"}, True),
),
id="Custom timeout, response not in time",
),
])
@ -210,12 +231,12 @@ class TestSend:
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == []
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_connection.send, bad_command())
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"result":{}}""")
assert str(cm.value) == "Generator of CDP command ID 0 did not exit when expected!"
assert excinfo.group_contains(CDPError, match="^Generator of CDP command ID 0 did not exit when expected!$")
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.badCommand","params":{}}"""]
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@ -241,12 +262,12 @@ class TestSend:
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == []
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"result":{}}""")
assert str(cm.value) == "Generator of CDP command ID 0 raised KeyError: 'value'"
assert excinfo.group_contains(CDPError, match="^Generator of CDP command ID 0 raised KeyError: 'value'$")
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@ -364,12 +385,12 @@ class TestHandleCmdResponse:
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == []
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
nursery.start_soon(websocket_connection.sender.send, """{"id":0,"error":"Some error message"}""")
assert str(cm.value) == "Error in CDP command response 0: Some error message"
assert excinfo.group_contains(CDPError, match="^Error in CDP command response 0: Some error message$")
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [
@ -395,12 +416,12 @@ class TestHandleCmdResponse:
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == []
with pytest.raises(CDPError) as cm: # noqa: PT012
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
async with trio.open_nursery() as nursery:
nursery.start_soon(cdp_connection.send, fake_command(FakeCommand("foo")))
nursery.start_soon(websocket_connection.sender.send, """{"id":0}""")
assert str(cm.value) == "No result in CDP command response 0"
assert excinfo.group_contains(CDPError, match="^No result in CDP command response 0$")
assert cdp_connection.cmd_buffers == {}
assert websocket_connection.sent == ["""{"id":0,"method":"Fake.fakeCommand","params":{"value":"foo"}}"""]
assert [(record.name, record.levelname, record.message) for record in caplog.records] == [

View File

@ -97,15 +97,13 @@ class TestLaunch:
]
@pytest.mark.trio()
async def test_terminate_on_nursery_baseexception(self, caplog: pytest.LogCaptureFixture, webbrowser_launch):
class FakeBaseException(BaseException):
pass
@pytest.mark.parametrize("exception", [KeyboardInterrupt, SystemExit])
async def test_terminate_on_nursery_baseexception(self, caplog: pytest.LogCaptureFixture, webbrowser_launch, exception):
process: trio.Process
with pytest.raises(FakeBaseException): # noqa: PT012
with pytest.raises(exception): # noqa: PT012
async with webbrowser_launch() as (_nursery, process):
assert process.poll() is None, "process is still running"
raise FakeBaseException()
raise exception()
assert process.poll() == (1 if is_win32 else -SIGTERM), "Process has been terminated"
assert [(record.name, record.levelname, record.msg) for record in caplog.records] == [