From 542fe5b882df8778f2f7328d6b026039738b82b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 2 Sep 2026 15:48:40 +0200 Subject: [PATCH] feat: add BiDiServer to drive lightpanda with Selenium over WebDriver BiDi Add `BiDiServer` / `AsyncBiDiServer`, which spawn `lightpanda serve --protocol webdriver` and expose `http_endpoint` (Selenium's `command_executor`), `bidi_endpoint` (the raw BiDi WebSocket on `/session`) and `status()`. The browser serves the BiDi modules plus the classic session bootstrap Selenium needs; every other classic WebDriver route 404s, which the docs and tests spell out. `--protocol` is additive, so `args=["--protocol", "cdp"]` serves both protocols on one port. The process-owning shape of `CDPServer` moves into a private `_serve.py` base (`_ServeProcess` / `_AsyncServeProcess`); `CDPServer` and `BiDiServer` and their async twins are thin subclasses differing only in protocol flags and endpoints. The CDP public surface is unchanged. Tests cover the WebDriver HTTP bootstrap with the standard library and a real Selenium session (create context, navigate, locateNodes, script evaluate) using the `selenium` package as a client only, in the dev group and CI venv; they skip when it is absent. The CDP `/json/list` assertion is relaxed to "a list", since newer browser builds return target entries. --- .github/workflows/wheels.yml | 7 +- README.md | 36 +++++- lightpanda/__init__.py | 6 +- lightpanda/_serve.py | 149 +++++++++++++++++++++++++ lightpanda/bidi.py | 85 ++++++++++++++ lightpanda/cdp.py | 118 ++------------------ pyproject.toml | 2 +- tests/conftest.py | 8 +- tests/test_bidi.py | 56 ++++++++++ tests/test_bidi_selenium.py | 37 ++++++ tests/test_cdp.py | 37 +----- tests/test_serve.py | 36 ++++++ uv.lock | 211 +++++++++++++++++++++++++++++++++++ 13 files changed, 638 insertions(+), 150 deletions(-) create mode 100644 lightpanda/_serve.py create mode 100644 lightpanda/bidi.py create mode 100644 tests/test_bidi.py create mode 100644 tests/test_bidi_selenium.py create mode 100644 tests/test_serve.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 697a37b..3665cf8 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -183,9 +183,10 @@ jobs: - name: Install the wheel into a clean venv run: | uv venv wheel-env - # playwright is a CDP client here (connect_over_cdp), so no browser - # download (`playwright install`) is needed. - uv pip install --python wheel-env dist/*.whl pytest pytest-asyncio playwright + # playwright (connect_over_cdp) and selenium (webdriver.Remote) are + # only clients of the bundled browser here: no browser or driver + # download is needed. + uv pip install --python wheel-env dist/*.whl pytest pytest-asyncio playwright selenium - name: Run the test suite against the installed wheel run: | diff --git a/README.md b/README.md index b84c3f2..d7bb77e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,37 @@ cannot serve MCP and CDP from the same one). The package itself needs only Python's standard library; Playwright is a dev-only test dependency and `connect_over_cdp` never downloads a browser. +## Drive it with Selenium (WebDriver BiDi) + +The browser also speaks [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/). +`BiDiServer` starts `lightpanda serve --protocol webdriver` and hands you the +URL Selenium's `webdriver.Remote` takes as `command_executor`: + +```python +from lightpanda import BiDiServer +from selenium import webdriver +from selenium.webdriver.common.options import ArgOptions + +options = ArgOptions() +options.web_socket_url = True # ask for a WebDriver BiDi session + +with BiDiServer() as server: + driver = webdriver.Remote(command_executor=server.http_endpoint, options=options) + context = driver.browsing_context.create(type="tab") + driver.browsing_context.navigate(context=context, url="https://example.com", wait="complete") + print(driver.script.execute("() => document.title", context_id=context)["value"]) + driver.quit() +``` + +`AsyncBiDiServer` is the asyncio twin, and `server.bidi_endpoint` +(`ws://127.0.0.1:/session`) is the raw BiDi WebSocket for clients that +speak the protocol directly. The browser serves the BiDi modules plus the +classic session bootstrap Selenium needs, not the classic WebDriver +commands: `driver.get`, `find_element` and friends are not implemented, so +drive the page through `driver.browsing_context` and `driver.script` with an +explicit context, created first as above. Pass `args=["--protocol", "cdp"]` +to serve CDP on the same port as well. + ## How the bindings work Every browser tool is a `Session` method, typed and documented in your IDE. @@ -125,8 +156,9 @@ lightpanda binary. Then: uv run --group dev pytest tests ``` -The `dev` group includes `playwright` for the CDP tests (its pip package -only; no `playwright install`). Those tests skip when it is absent. +The `dev` group includes `playwright` and `selenium` as clients for the CDP +and BiDi tests (their pip packages only; no browser or driver download). +Those tests skip when the client is absent. Regenerate the tool methods (`lightpanda/_methods.py`) and the API docs: diff --git a/lightpanda/__init__.py b/lightpanda/__init__.py index 98d392a..4dacfbc 100644 --- a/lightpanda/__init__.py +++ b/lightpanda/__init__.py @@ -22,10 +22,12 @@ For Playwright or Puppeteer code, ``CDPServer`` runs the browser's own Chrome DevTools Protocol server and hands you the endpoint to connect to -(see its docs for an example). +(see its docs for an example). For Selenium, ``BiDiServer`` serves WebDriver +BiDi the same way and hands you the ``command_executor`` URL. """ from .async_browser import AsyncBrowser, AsyncSession, run_script_async +from .bidi import AsyncBiDiServer, BiDiServer from .browser import Browser, Session, run_script from .cdp import AsyncCDPServer, CDPServer from .errors import LightpandaError, ProtocolError, ScriptError, ToolError @@ -39,6 +41,8 @@ "run_script_async", "CDPServer", "AsyncCDPServer", + "BiDiServer", + "AsyncBiDiServer", "LightpandaError", "ProtocolError", "ScriptError", diff --git a/lightpanda/_serve.py b/lightpanda/_serve.py new file mode 100644 index 0000000..97b1771 --- /dev/null +++ b/lightpanda/_serve.py @@ -0,0 +1,149 @@ +"""Shared shape of the ``lightpanda serve`` wrappers (``CDPServer``, ``BiDiServer``). + +A serve wrapper owns one ``lightpanda serve`` process on a localhost port +and hands out endpoints for third-party clients; the subclasses differ only +in the ``--protocol`` flags they pass and the endpoints they expose. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import urllib.request +from typing import Generic, TypeVar + +from .client import _HOST, _spawn, _terminate, find_binary +from .errors import LightpandaError + +_HTTP_TIMEOUT = 5.0 + + +class _ServeProcess: + _protocol: tuple[str, ...] = () # ``serve`` flags placed before the user's ``args`` + + def __init__( + self, + binary: str | os.PathLike | None = None, + env: dict[str, str] | None = None, + verbose: bool = False, + args: tuple[str, ...] | list[str] = (), + port: int | None = None, + ): + """``port`` pins the listening port (default: a free one). ``args`` + are extra ``lightpanda serve`` flags; pass ``port=`` rather than + ``--port``. ``verbose`` lets the browser log through to stderr.""" + self._proc, self._port = _spawn( + find_binary(binary), "serve", [*self._protocol, *args], env, verbose, port=port + ) + + @property + def port(self) -> int: + return self._port + + @property + def http_endpoint(self) -> str: + """``http://127.0.0.1:``, the server's HTTP root: what Puppeteer + (``browserURL``) and Playwright (``connect_over_cdp`` with an http URL) + discover the CDP WebSocket from, and Selenium's ``command_executor``.""" + return f"http://{_HOST}:{self._port}" + + def _get_json(self, path: str) -> dict: + """GET ``path`` and parse the JSON body. urllib sends an IP-literal + ``Host`` and no ``Origin``, which is what the browser accepts.""" + if self._proc is None: + raise LightpandaError("server closed") + url = f"{self.http_endpoint}{path}" + try: + with urllib.request.urlopen(url, timeout=_HTTP_TIMEOUT) as resp: + return json.loads(resp.read()) + except (OSError, ValueError) as err: # URLError is an OSError + raise LightpandaError(f"GET {url} failed: {err}") from err + + def close(self) -> None: + if self._proc is not None: + _terminate(self._proc) + self._proc = None + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def __del__(self): + try: + self.close() + except Exception: + pass + + +_S = TypeVar("_S", bound=_ServeProcess) + + +class _AsyncServeProcess(Generic[_S]): + """asyncio twin of a :class:`_ServeProcess`: the process is spawned by + :meth:`start`, called automatically on ``async with`` entry. Subclasses + set ``_sync_cls`` and re-declare their protocol-specific members.""" + + _sync_cls: type[_S] + + def __init__( + self, + binary: str | os.PathLike | None = None, + env: dict[str, str] | None = None, + verbose: bool = False, + args: tuple[str, ...] | list[str] = (), + port: int | None = None, + ): + """Arguments are forwarded to the sync class.""" + self._kwargs = dict(binary=binary, env=env, verbose=verbose, args=args, port=port) + self._server: _S | None = None + self._start_lock = asyncio.Lock() + + async def start(self): + """Spawn the server process. Idempotent.""" + if self._server is None: + async with self._start_lock: + if self._server is None: + self._server = await asyncio.to_thread(self._sync_cls, **self._kwargs) + return self + + def _started(self) -> _S: + if self._server is None: + raise LightpandaError("server not started; use `async with` or `await start()`") + return self._server + + @property + def port(self) -> int: + return self._started().port + + @property + def http_endpoint(self) -> str: + """``http://127.0.0.1:``, see the sync class.""" + return self._started().http_endpoint + + async def close(self) -> None: + if self._server is not None: + server, self._server = self._server, None + await asyncio.to_thread(server.close) + + async def __aenter__(self): + return await self.start() + + async def __aexit__(self, *exc): + await self.close() + + +def _documented(cls: type) -> type: + """Copy the public members (and ``__init__``) this module's bases give + ``cls`` onto ``cls`` itself, so pdoc and IDEs document them as its own: + pdoc hides what is inherited from a private module. Same trick as + ``_attach_generated`` in browser.py.""" + for base in cls.__mro__[1:]: + if base.__module__ != __name__: + continue + for name, member in vars(base).items(): + if (name == "__init__" or not name.startswith("_")) and name not in vars(cls): + setattr(cls, name, member) + return cls diff --git a/lightpanda/bidi.py b/lightpanda/bidi.py new file mode 100644 index 0000000..b18de88 --- /dev/null +++ b/lightpanda/bidi.py @@ -0,0 +1,85 @@ +"""WebDriver BiDi: run ``lightpanda serve --protocol webdriver`` for Selenium and co. + +:class:`BiDiServer` spawns the browser's WebDriver BiDi server on a free +localhost port and owns the process; :class:`AsyncBiDiServer` is the asyncio +twin. See :class:`BiDiServer` for what the browser serves. +""" + +from __future__ import annotations + +import asyncio + +from ._serve import _AsyncServeProcess, _ServeProcess, _documented +from .client import _HOST + + +@_documented +class BiDiServer(_ServeProcess): + """A lightpanda process serving WebDriver BiDi on 127.0.0.1. + + ```python + from lightpanda import BiDiServer + from selenium import webdriver + from selenium.webdriver.common.options import ArgOptions + + options = ArgOptions() + options.web_socket_url = True # ask for a WebDriver BiDi session + + with BiDiServer() as server: + driver = webdriver.Remote(command_executor=server.http_endpoint, options=options) + context = driver.browsing_context.create(type="tab") + driver.browsing_context.navigate(context=context, url="https://example.com", wait="complete") + print(driver.script.execute("() => document.title", context_id=context)["value"]) + driver.quit() + ``` + + :attr:`http_endpoint` is Selenium's ``command_executor``. The browser + serves the BiDi modules (``session``, ``browser``, ``browsingContext``, + ``script``, ``input``) over the WebSocket plus the classic session + bootstrap (``GET /status``, ``POST /session`` with the ``webSocketUrl`` + capability, ``DELETE /session/``); other classic WebDriver commands + such as Selenium's ``driver.get`` or ``find_element`` are not served, so + drive the page through ``driver.browsing_context`` and ``driver.script`` + with an explicit context, created first as above. Pass + ``args=["--protocol", "cdp"]`` to serve CDP on the same port as well + (``--protocol`` is additive). The process is stopped by :meth:`close` / + leaving the ``with`` block, and on Linux also when the interpreter dies. + """ + + _protocol = ("--protocol", "webdriver") + + @property + def bidi_endpoint(self) -> str: + """The session-less BiDi WebSocket URL, ``ws://127.0.0.1:/session``, + for clients that speak BiDi directly (``session.new`` over the socket). + A session bootstrapped through ``POST /session`` gets its own socket at + ``/``, returned as the ``webSocketUrl`` + capability. + + Keep the IP literal: the WebSocket upgrade rejects any ``Origin`` + header and only accepts an IP-literal or ``localhost`` host.""" + return f"ws://{_HOST}:{self._port}/session" + + def status(self) -> dict: + """The ``GET /status`` value, ``{"ready": True, "message": ""}``.""" + return self._get_json("/status")["value"] + + +@_documented +class AsyncBiDiServer(_AsyncServeProcess[BiDiServer]): + """:class:`BiDiServer` for asyncio: the process is spawned by + :meth:`start`, called automatically on ``async with`` entry.""" + + _sync_cls = BiDiServer + + @property + def bidi_endpoint(self) -> str: + """See :attr:`BiDiServer.bidi_endpoint`.""" + return self._started().bidi_endpoint + + async def status(self) -> dict: + """See :meth:`BiDiServer.status`.""" + return await asyncio.to_thread(self._started().status) + + +__all__ = ["BiDiServer", "AsyncBiDiServer"] diff --git a/lightpanda/cdp.py b/lightpanda/cdp.py index 8d87624..f797e6e 100644 --- a/lightpanda/cdp.py +++ b/lightpanda/cdp.py @@ -15,28 +15,13 @@ from __future__ import annotations import asyncio -import json -import os -import urllib.request -from .client import _HOST, _spawn, _terminate, find_binary -from .errors import LightpandaError +from ._serve import _AsyncServeProcess, _ServeProcess, _documented +from .client import _HOST -_VERSION_TIMEOUT = 5.0 - -def _get_version(port: int) -> dict: - """GET ``/json/version``. urllib sends an IP-literal ``Host`` and no - ``Origin``, which is what the browser's handshake accepts.""" - url = f"http://{_HOST}:{port}/json/version" - try: - with urllib.request.urlopen(url, timeout=_VERSION_TIMEOUT) as resp: - return json.loads(resp.read()) - except (OSError, ValueError) as err: # URLError is an OSError - raise LightpandaError(f"GET {url} failed: {err}") from err - - -class CDPServer: +@_documented +class CDPServer(_ServeProcess): """A lightpanda process serving the Chrome DevTools Protocol on 127.0.0.1. ```python @@ -55,23 +40,7 @@ class CDPServer: Linux also when the interpreter dies. """ - def __init__( - self, - binary: str | os.PathLike | None = None, - env: dict[str, str] | None = None, - verbose: bool = False, - args: tuple[str, ...] | list[str] = (), - port: int | None = None, - ): - """``port`` pins the listening port (default: a free one). ``args`` - are extra ``lightpanda serve`` flags (``--http-proxy``, ``--cookie``, - ``--cdp-max-connections``, ...); pass ``port=`` rather than - ``--port``. ``verbose`` lets the browser log through to stderr.""" - self._proc, self._port = _spawn(find_binary(binary), "serve", args, env, verbose, port=port) - - @property - def port(self) -> int: - return self._port + _protocol = ("--protocol", "cdp") # explicit, so args=["--protocol", "webdriver"] is additive @property def ws_endpoint(self) -> str: @@ -81,39 +50,14 @@ def ws_endpoint(self) -> str: accepts an IP-literal or ``localhost`` host.""" return f"ws://{_HOST}:{self._port}/" - @property - def http_endpoint(self) -> str: - """``http://127.0.0.1:``, for clients that discover the - WebSocket through ``/json/version`` (Puppeteer's ``browserURL``, - Playwright's ``connect_over_cdp`` with an http URL).""" - return f"http://{_HOST}:{self._port}" - def version(self) -> dict: """The ``/json/version`` document (browser, protocol version, ``webSocketDebuggerUrl``).""" - if self._proc is None: - raise LightpandaError("server closed") - return _get_version(self._port) - - def close(self) -> None: - if self._proc is not None: - _terminate(self._proc) - self._proc = None - - def __enter__(self): - return self + return self._get_json("/json/version") - def __exit__(self, *exc): - self.close() - def __del__(self): - try: - self.close() - except Exception: - pass - - -class AsyncCDPServer: +@_documented +class AsyncCDPServer(_AsyncServeProcess[CDPServer]): """:class:`CDPServer` for asyncio: the process is spawned by :meth:`start`, called automatically on ``async with`` entry. @@ -123,60 +67,16 @@ class AsyncCDPServer: ``` """ - def __init__( - self, - binary: str | os.PathLike | None = None, - env: dict[str, str] | None = None, - verbose: bool = False, - args: tuple[str, ...] | list[str] = (), - port: int | None = None, - ): - """Arguments are forwarded to :class:`CDPServer`.""" - self._kwargs = dict(binary=binary, env=env, verbose=verbose, args=args, port=port) - self._server: CDPServer | None = None - self._start_lock = asyncio.Lock() - - async def start(self) -> AsyncCDPServer: - """Spawn the server process. Idempotent.""" - if self._server is None: - async with self._start_lock: - if self._server is None: - self._server = await asyncio.to_thread(CDPServer, **self._kwargs) - return self - - def _started(self) -> CDPServer: - if self._server is None: - raise LightpandaError("server not started; use `async with` or `await start()`") - return self._server - - @property - def port(self) -> int: - return self._started().port + _sync_cls = CDPServer @property def ws_endpoint(self) -> str: """See :attr:`CDPServer.ws_endpoint`.""" return self._started().ws_endpoint - @property - def http_endpoint(self) -> str: - """See :attr:`CDPServer.http_endpoint`.""" - return self._started().http_endpoint - async def version(self) -> dict: """See :meth:`CDPServer.version`.""" return await asyncio.to_thread(self._started().version) - async def close(self) -> None: - if self._server is not None: - server, self._server = self._server, None - await asyncio.to_thread(server.close) - - async def __aenter__(self): - return await self.start() - - async def __aexit__(self, *exc): - await self.close() - __all__ = ["CDPServer", "AsyncCDPServer"] diff --git a/pyproject.toml b/pyproject.toml index 27092e5..12b0e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ packages = ["lightpanda"] lightpanda = ["lightpanda", "lightpanda.exe", "py.typed"] [dependency-groups] -dev = ["pytest>=8", "pytest-asyncio>=1.0", "playwright>=1.45"] +dev = ["pytest>=8", "pytest-asyncio>=1.0", "playwright>=1.45", "selenium>=4.32"] docs = ["pdoc"] [tool.pytest.ini_options] diff --git a/tests/conftest.py b/tests/conftest.py index f10ce76..0ea1509 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest -from lightpanda import AsyncBrowser, Browser, CDPServer +from lightpanda import AsyncBrowser, BiDiServer, Browser, CDPServer BROWSER_CHECKOUT = Path(__file__).parent.parent.parent / "browser" FIXTURES = Path(__file__).parent / "fixtures" @@ -59,3 +59,9 @@ async def abrowser(browser): def cdp_server(binary): with CDPServer(binary=binary) as server: yield server + + +@pytest.fixture(scope="session") +def bidi_server(binary): + with BiDiServer(binary=binary) as server: + yield server diff --git a/tests/test_bidi.py b/tests/test_bidi.py new file mode 100644 index 0000000..dc537c0 --- /dev/null +++ b/tests/test_bidi.py @@ -0,0 +1,56 @@ +"""BiDiServer's endpoints and the WebDriver session bootstrap they promise, +with the standard library only. A real BiDi client is exercised in +test_bidi_selenium.py; the shared process lifecycle in test_serve.py.""" + +import json +import urllib.error +import urllib.request + +from lightpanda import BiDiServer + +BIDI_CAPS = {"capabilities": {"alwaysMatch": {"webSocketUrl": True}}} + + +def _request(method, url, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as err: + return err.code, None + + +def _with_session(server): + """The `POST /session` value; call the returned closer when done.""" + status, payload = _request("POST", f"{server.http_endpoint}/session", BIDI_CAPS) + assert status == 200, payload + value = payload["value"] + return value, lambda: _request("DELETE", f"{server.http_endpoint}/session/{value['sessionId']}") + + +def test_endpoints_and_status(bidi_server): + assert bidi_server.port > 0 + assert bidi_server.bidi_endpoint == f"ws://127.0.0.1:{bidi_server.port}/session" + assert bidi_server.http_endpoint == f"http://127.0.0.1:{bidi_server.port}" + assert bidi_server.status() == {"ready": True, "message": ""} + + +def test_session_bootstrap_advertises_bidi_endpoint(bidi_server): + value, done = _with_session(bidi_server) + try: + assert value["capabilities"]["browserName"] == "Lightpanda" + assert value["capabilities"]["webSocketUrl"] == f"{bidi_server.bidi_endpoint}/{value['sessionId']}" + finally: + assert done() == (200, {"value": None}) + + +def test_extra_args_passthrough(bidi_server, binary): + assert _request("GET", f"{bidi_server.http_endpoint}/json/version")[0] == 404 # CDP off by default + args = ["--protocol", "cdp", "--advertise-host", "localhost"] + with BiDiServer(binary=binary, args=args) as server: + status, version = _request("GET", f"{server.http_endpoint}/json/version") # --protocol is additive + assert status == 200 and version["Browser"].startswith("Lightpanda") + value, done = _with_session(server) + done() + assert value["capabilities"]["webSocketUrl"].startswith(f"ws://localhost:{server.port}/session/") diff --git a/tests/test_bidi_selenium.py b/tests/test_bidi_selenium.py new file mode 100644 index 0000000..1dac250 --- /dev/null +++ b/tests/test_bidi_selenium.py @@ -0,0 +1,37 @@ +"""Drive a BiDiServer with Selenium. Selenium is a dev-only dependency: +`webdriver.Remote` needs its pip package, not a driver or browser download, +and this test skips when it is not installed. + +Only the WebDriver BiDi modules are served, so every step goes over the +websocket with an explicit browsing context.""" + +import pytest + +webdriver = pytest.importorskip("selenium.webdriver") +from selenium.webdriver.common.options import ArgOptions # noqa: E402 + + +def test_selenium_bidi_session(bidi_server, fixture_url): + options = ArgOptions() + options.web_socket_url = True + driver = webdriver.Remote(command_executor=bidi_server.http_endpoint, options=options) + try: + assert driver.caps["browserName"] == "Lightpanda" + assert driver.caps["webSocketUrl"] == f"{bidi_server.bidi_endpoint}/{driver.session_id}" + + assert driver.browsing_context.get_tree() == [] # first BiDi access opens the websocket + context = driver.browsing_context.create(type="tab") + assert [c.context for c in driver.browsing_context.get_tree()] == [context] + + driver.browsing_context.navigate(context=context, url=f"{fixture_url}/index.html", wait="complete") + + nodes = driver.browsing_context.locate_nodes(context=context, locator={"type": "css", "value": ".item"}) + assert len(nodes) == 3 + + headline = driver.script.execute("() => document.querySelector('#headline').textContent", context_id=context) + assert headline["value"] == "Hello from the fixture" + + result = driver.script.evaluate(expression="document.title", target={"context": context}, await_promise=False) + assert result["result"]["value"] == "Fixture Home" + finally: + driver.quit() # closes the websocket, then DELETE /session/ diff --git a/tests/test_cdp.py b/tests/test_cdp.py index 0008a61..a1e4edf 100644 --- a/tests/test_cdp.py +++ b/tests/test_cdp.py @@ -1,15 +1,12 @@ -"""CDPServer / AsyncCDPServer lifecycle, with the standard library only. -A real CDP client is exercised in test_cdp_playwright.py; the PDEATHSIG -test lives in test_browser.py.""" +"""CDPServer's endpoints and options, with the standard library only. A real +CDP client is exercised in test_cdp_playwright.py; the shared process +lifecycle in test_serve.py and the PDEATHSIG test in test_browser.py.""" -import json import socket -import urllib.request import pytest -from conftest import alive -from lightpanda import AsyncCDPServer, CDPServer, LightpandaError +from lightpanda import CDPServer, LightpandaError from lightpanda.client import _reserve_port @@ -22,9 +19,6 @@ def test_endpoints_and_version(cdp_server): assert version["Browser"].startswith("Lightpanda") assert version["webSocketDebuggerUrl"] == cdp_server.ws_endpoint - with urllib.request.urlopen(f"{cdp_server.http_endpoint}/json/list", timeout=5) as resp: - assert json.loads(resp.read()) == [] - def test_fixed_port(binary): port = _reserve_port() @@ -43,26 +37,3 @@ def test_port_in_use_raises(binary): def test_extra_args_passthrough(binary): with CDPServer(binary=binary, args=["--advertise-host", "localhost"]) as server: assert server.version()["webSocketDebuggerUrl"] == f"ws://localhost:{server.port}/" - - -def test_close_kills_process(binary): - server = CDPServer(binary=binary) - pid = server._proc.pid - assert alive(pid) - - server.close() # terminates and reaps - assert not alive(pid) - with pytest.raises(LightpandaError, match="closed"): - server.version() - server.close() # idempotent - - -async def test_async_lazy_start(binary): - server = AsyncCDPServer(binary=binary) - with pytest.raises(LightpandaError, match="not started"): - server.ws_endpoint - async with server: - assert server.ws_endpoint == f"ws://127.0.0.1:{server.port}/" - version = await server.version() - assert version["webSocketDebuggerUrl"] == server.ws_endpoint - await server.close() # idempotent diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..9624ff6 --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,36 @@ +"""Lifecycle shared by the `lightpanda serve` wrappers, tested once per +class pair. Protocol-specific behaviour lives in test_cdp.py / test_bidi.py.""" + +import pytest +from conftest import alive + +from lightpanda import AsyncBiDiServer, AsyncCDPServer, BiDiServer, CDPServer, LightpandaError + +PAIRS = [ + pytest.param(CDPServer, AsyncCDPServer, "version", "ws_endpoint", id="cdp"), + pytest.param(BiDiServer, AsyncBiDiServer, "status", "bidi_endpoint", id="bidi"), +] + + +@pytest.mark.parametrize("sync_cls, async_cls, probe, endpoint", PAIRS) +def test_close_kills_process(binary, sync_cls, async_cls, probe, endpoint): + server = sync_cls(binary=binary) + pid = server._proc.pid + assert alive(pid) + + server.close() # terminates and reaps + assert not alive(pid) + with pytest.raises(LightpandaError, match="closed"): + getattr(server, probe)() + server.close() # idempotent + + +@pytest.mark.parametrize("sync_cls, async_cls, probe, endpoint", PAIRS) +async def test_async_lazy_start(binary, sync_cls, async_cls, probe, endpoint): + server = async_cls(binary=binary) + with pytest.raises(LightpandaError, match="not started"): + getattr(server, endpoint) + async with server: + assert getattr(server, endpoint).startswith(f"ws://127.0.0.1:{server.port}/") + assert await getattr(server, probe)() + await server.close() # idempotent diff --git a/uv.lock b/uv.lock index c32a557..080ed75 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -11,6 +20,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -118,6 +176,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -149,6 +225,7 @@ dev = [ { name = "playwright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "selenium" }, ] docs = [ { name = "pdoc" }, @@ -161,6 +238,7 @@ dev = [ { name = "playwright", specifier = ">=1.45" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=1.0" }, + { name = "selenium", specifier = ">=4.32" }, ] docs = [{ name = "pdoc" }] @@ -258,6 +336,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -310,6 +400,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pyee" version = "13.0.1" @@ -331,6 +430,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -363,6 +471,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "selenium" +version = "4.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/8c/db97bdc1a8b41e7b6bf9d3099722ed8e7ac61328af8637f20004015c642b/selenium-4.48.0.tar.gz", hash = "sha256:045c1ec054c94e3be6c10febc509aa513b4c05e9146d1a9cf3de5375ec6ca2a1", size = 1055269, upload-time = "2026-08-27T20:05:51.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ee/5d1b0e9cb43965902f0a9f7add527134db71cabf2f26766ae2fdb7774b9a/selenium-4.48.0-py3-none-any.whl", hash = "sha256:b2a1d77019db92513e59aa2376710fe3d42b65a4d493dccd0c799c1a0d574d93", size = 9561411, upload-time = "2026-08-27T20:05:48.778Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -417,6 +560,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "trio" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/dc/a2d25ed73ad49cfd79bf18d262577c3731c98e382284e28d522f49a0df35/trio-0.34.0.tar.gz", hash = "sha256:63b9485408bdfdde544fced107045a8c0086cdc4bd0ef2f797b9e0dd111b964b", size = 607457, upload-time = "2026-08-11T00:33:42.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/1f/555f1364bed52a92a864181962b77f1b15adadeacf23b86105324363e461/trio-0.34.0-py3-none-any.whl", hash = "sha256:6c7c9f49917694dcdcd5f67abd168df5599eca480d61f29854d17a61a75c2f05", size = 511840, upload-time = "2026-08-11T00:33:40.552Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -425,3 +601,38 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +]