mirror of https://github.com/streamlink/streamlink
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:
parent
af4c69188a
commit
1a7295b110
|
@ -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",
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
# TODO: trio>0.22 release: remove __future__ import (generic memorychannels)
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import trio
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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] == [
|
||||
|
|
|
@ -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] == [
|
||||
|
|
Loading…
Reference in New Issue