diff --git a/.github/workflows/pyodide.yml b/.github/workflows/pyodide.yml
new file mode 100644
index 00000000..33f9c798
--- /dev/null
+++ b/.github/workflows/pyodide.yml
@@ -0,0 +1,61 @@
+name: Pyodide Test Suite
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main", "version-*"]
+
+permissions:
+ contents: read
+
+jobs:
+ pyodide:
+ name: "Pyodide"
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ python-version: "3.13"
+ enable-cache: true
+
+ - name: Install Chrome for Pyodide tests
+ uses: pyodide/pyodide-actions/install-browser@012fa537869d343726d01863a34b773fc4d96a14 # v2
+ with:
+ runner: selenium
+ browser: chrome
+ browser-version: latest
+
+ - name: Install Node for Pyodide tests
+ uses: pyodide/pyodide-actions/install-browser@012fa537869d343726d01863a34b773fc4d96a14 # v2
+ with:
+ runner: selenium
+ browser: node
+ browser-version: 22
+
+ - name: Download Pyodide
+ uses: pyodide/pyodide-actions/download-pyodide@012fa537869d343726d01863a34b773fc4d96a14 # v2
+ with:
+ version: 0.29.3
+ to: pyodide_dist
+
+ - name: Install dependencies
+ run: scripts/install
+
+ - name: Install Pyodide test dependencies
+ run: uv pip install --group emscripten
+
+ - name: Build package & docs
+ run: scripts/build
+
+ - name: Run tests
+ run: scripts/test
+
+ - name: Enforce coverage
+ run: scripts/coverage
diff --git a/.gitignore b/.gitignore
index 18967b8a..f5eb6df5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ venv*/
.python-version
build/
dist/
+pyodide_dist/
diff --git a/docs/advanced/emscripten.md b/docs/advanced/emscripten.md
new file mode 100644
index 00000000..e0d2e72a
--- /dev/null
+++ b/docs/advanced/emscripten.md
@@ -0,0 +1,94 @@
+---
+template: pyodide.html
+---
+
+# Emscripten Support
+
+httpx2 has support for running on WebAssembly / Emscripten using
+[Pyodide](https://github.com/pyodide/pyodide/).
+
+Asynchronous requests always use `fetch`. Synchronous requests use the following
+methods:
+1. If [Javascript Promise Integration](https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md)
+ (JSPI) is supported by the JavaScript runtime, the request will be made with
+ `fetch` and stack switching.
+2. Otherwise, if in a browser, the request will be made using a synchronous
+ `XMLHttpRequest`.
+3. Otherwise, if in Node, the request will fail. Synchronous requests in Node
+ require JSPI.
+
+In Emscripten, all network connections are handled by the enclosing Javascript
+runtime. As such, there is limited control over various features. In particular:
+
+- Proxy servers are handled by the runtime, so httpx2 cannot control them.
+- httpx2 has no control over connection pooling.
+- Certificate handling is done by the browser, so httpx2 cannot modify it.
+- Requests are constrained by cross-origin isolation settings in the same way as
+ any request that is originated by Javascript code.
+- Timeouts will not work in the main browser thread unless the browser supports
+ JSPI because main thread synchronous `XMLHttpRequest` does not support
+ timeouts.
+
+Setting any of the transport options that depend on these features (`verify`,
+`cert`, `http2`, `limits`, `proxy`, `uds`, `local_address`, `retries`, or
+`socket_options`) will emit a `UserWarning` and the option will be silently
+ignored.
+
+## Try it in your browser
+
+Use the following live example to test httpx2 in your web browser. You can
+change the code below and hit run again to test different features or web
+addresses.
+
+
import httpx2
+print("Sending response using httpx2 in the browser:")
+print("--------------------------------------------")
+r = httpx2.get("https://www.example.com")
+print("Status = ", r.status_code)
+print("Response = ", r.text[:50], "...")
+
+
+
+
+
+## Build it
+
+Because `httpx2` is a pure python module, building is the same as ever
+(`python -m build`), or use the built wheel from PyPI.
+
+## Testing Custom Builds of httpx2 in Emscripten
+
+Once you have a wheel you can test it in your browser. You can do this using the
+[Pyodide console](https://pyodide.org/en/stable/console.html), or by hosting
+your own web page. You will need version 0.26.2 or later of Pyodide.
+
+1. To test in Pyodide console, serve the wheel file via http (e.g. by calling
+ python -m `http.server` in the dist directory.) Then in the [Pyodide
+ console](https://pyodide.org/en/stable/console.html), type the following,
+ replacing the URL of the locally served wheel.
+
+ ```python
+ import pyodide_js as pjs
+ import ssl, certifi, idna
+ await pjs.loadPackage("")
+ import httpx2
+ # Now httpx2 should work
+ ```
+
+2. To test a custom-built wheel in your own web page, create a page which loads
+ the Pyodide JavaScript (see the
+ [instructions](https://pyodide.org/en/stable/usage/index.html) on the
+ Pyodide website). After starting the Pyodide runtime, run the following code
+ to load httpx2 and its dependencies:
+ ```js
+ await pyodide.loadPackage([httpx2_wheel_url, "ssl", "certifi", "idna"])
+ ```
+
+3. To test in Node.js, run `npm i pyodide` or download a Pyodide distribution
+ download to a known folder, then load Pyodide following the instructions on
+ the Pyodide website (https://pyodide.org/en/stable/usage/index.html). After
+ starting the Pyodide runtime, run the following code to load httpx2 and its
+ dependencies:
+ ```js
+ await pyodide.loadPackage([httpx2_wheel_url, "ssl", "certifi", "idna"])
+ ```
diff --git a/docs/overrides/pyodide.html b/docs/overrides/pyodide.html
new file mode 100644
index 00000000..bf39496a
--- /dev/null
+++ b/docs/overrides/pyodide.html
@@ -0,0 +1,123 @@
+{% extends "main.html" %}
+{% block styles %}
+ {{ super() }}
+
+
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+
+
+
+{% endblock %}
diff --git a/mkdocs.yml b/mkdocs.yml
index 2e1654a3..03ea0313 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -3,6 +3,7 @@ site_description: A next-generation HTTP client for Python.
theme:
name: 'material'
+ custom_dir: 'docs/overrides'
palette:
- scheme: 'default'
media: '(prefers-color-scheme: light)'
@@ -34,6 +35,7 @@ nav:
- Transports: 'advanced/transports.md'
- Text Encodings: 'advanced/text-encodings.md'
- Extensions: 'advanced/extensions.md'
+ - Emscripten: 'advanced/emscripten.md'
- Guides:
- Async Support: 'async.md'
- HTTP/2 Support: 'http2.md'
diff --git a/pyproject.toml b/pyproject.toml
index 470ea8dd..eaf57e13 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,11 @@ bench = [
"pyinstrument>=4.6.2",
"urllib3>=2.2.2",
]
+# extra requirements for testing on emscripten
+emscripten = [
+ "pytest-pyodide>=0.59.2; python_full_version >= '3.11'",
+ "selenium",
+]
[tool.ruff]
line-length = 120
@@ -81,6 +86,12 @@ markers = [
source_pkgs = ["httpx2", "httpcore2", "tests"]
omit = ["src/httpcore2/httpcore2/_sync/*", "tests/test_benchmark.py"]
+[tool.coverage.paths]
+source = [
+ "src/httpx2/httpx2",
+ "*/site-packages/httpx2",
+]
+
[tool.coverage.report]
exclude_also = [
"if TYPE_CHECKING:",
diff --git a/scripts/coverage b/scripts/coverage
index 3c1663c0..9aeaf93a 100755
--- a/scripts/coverage
+++ b/scripts/coverage
@@ -2,4 +2,13 @@
set -x
-uv run coverage report --show-missing --skip-covered --fail-under=100
+uv run coverage combine
+if [ -d 'pyodide_dist' ]; then
+ IGNORE_ARGS=""
+else
+ # if we don't have a pyodide environment set up, then don't test coverage
+ # for the emscripten transport
+ IGNORE_ARGS="--omit=src/httpx2/httpx2/_transports/jsfetch.py,tests/httpx2/emscripten/*"
+fi
+
+uv run coverage report ${IGNORE_ARGS} --show-missing --skip-covered --fail-under=100
diff --git a/scripts/download-pyodide b/scripts/download-pyodide
new file mode 100755
index 00000000..51b2e032
--- /dev/null
+++ b/scripts/download-pyodide
@@ -0,0 +1,31 @@
+#!/bin/sh -e
+set -x
+
+uv pip install --group emscripten
+
+mkdir -p pyodide_dist
+PYODIDE_URL="https://github.com/pyodide/pyodide/releases/download/0.29.3/pyodide-core-0.29.3.tar.bz2"
+PYODIDE_OUTPATH="/tmp/pyodide.tar.bz2"
+if command -v wget >/dev/null 2>&1; then
+ wget -q "$PYODIDE_URL" -O "$PYODIDE_OUTPATH"
+else
+ curl -sL "$PYODIDE_URL" -o "$PYODIDE_OUTPATH"
+fi
+tar -xjf "$PYODIDE_OUTPATH" -C pyodide_dist --strip-components=1
+./pyodide_dist/python - <<'EOF'
+import pyodide_js as pjs
+import asyncio
+
+asyncio.run(
+ pjs.loadPackage(
+ [
+ "coverage",
+ "idna",
+ "micropip",
+ "pytest",
+ "sqlite3",
+ "tblib",
+ ]
+ )
+)
+EOF
diff --git a/scripts/test b/scripts/test
index 8ec045b6..2d01c26d 100755
--- a/scripts/test
+++ b/scripts/test
@@ -6,7 +6,14 @@ if [ -z $GITHUB_ACTIONS ]; then
scripts/check
fi
-uv run coverage run -m pytest "$@"
+# run all host tests first
+uv run coverage run -p -m pytest "$@" --ignore=tests/httpx2/emscripten
+
+if [ -d 'pyodide_dist' ]; then
+ # run emscripten specific tests on chrome and node.js (20+)
+ uv run coverage run -p -m pytest -v --dist-dir="${PWD}/pyodide_dist" --rt=chrome-no-host tests/httpx2/emscripten/test_emscripten.py
+ uv run coverage run -p -m pytest -v --dist-dir="${PWD}/pyodide_dist" --rt=node-no-host tests/httpx2/emscripten/test_emscripten.py
+fi
if [ -z $GITHUB_ACTIONS ]; then
scripts/coverage
diff --git a/src/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md
index 51e24cee..489a5491 100644
--- a/src/httpx2/CHANGELOG.md
+++ b/src/httpx2/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## Unreleased
+
+### Added
+
+* Experimental support for running on WebAssembly / Emscripten via Pyodide, using a JavaScript `fetch`-based transport.
+
## 2.3.0 (June 1st, 2026)
### Changed
diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py
index e0ce82ed..75b775dc 100644
--- a/src/httpx2/httpx2/_client.py
+++ b/src/httpx2/httpx2/_client.py
@@ -28,8 +28,8 @@
)
from ._models import Cookies, Headers, Request, Response
from ._status_codes import codes
+from ._transports import AsyncHTTPTransport, HTTPTransport
from ._transports.base import AsyncBaseTransport, BaseTransport
-from ._transports.default import AsyncHTTPTransport, HTTPTransport
from ._types import (
AsyncByteStream,
AuthTypes,
diff --git a/src/httpx2/httpx2/_transports/__init__.py b/src/httpx2/httpx2/_transports/__init__.py
index 6a83e1d3..d9ce9c25 100644
--- a/src/httpx2/httpx2/_transports/__init__.py
+++ b/src/httpx2/httpx2/_transports/__init__.py
@@ -1,9 +1,24 @@
+import sys
+
from .asgi import ASGITransport
from .base import AsyncBaseTransport, BaseTransport
-from .default import AsyncHTTPTransport, HTTPTransport
from .mock import MockTransport
from .wsgi import WSGITransport
+if sys.platform == "emscripten": # pragma: nocover
+ # in emscripten we use javascript fetch
+ from .jsfetch import (
+ AsyncJavascriptFetchTransport,
+ JavascriptFetchTransport,
+ )
+
+ # override default transport names
+ HTTPTransport = JavascriptFetchTransport
+ AsyncHTTPTransport = AsyncJavascriptFetchTransport
+else:
+ # everywhere else we use default
+ from .default import AsyncHTTPTransport, HTTPTransport
+
__all__ = [
"ASGITransport",
"AsyncBaseTransport",
diff --git a/src/httpx2/httpx2/_transports/jsfetch.py b/src/httpx2/httpx2/_transports/jsfetch.py
new file mode 100644
index 00000000..d44c25b2
--- /dev/null
+++ b/src/httpx2/httpx2/_transports/jsfetch.py
@@ -0,0 +1,472 @@
+"""
+Custom transport for Pyodide on Emscripten.
+
+In async mode it uses the standard fetch api, which works anywhere that Pyodide
+works.
+
+In sync mode it requires the Javascript Promise Integration feature, which so
+far is only supported in some JavaScript runtimes. As of this writing it is
+supported in the following JavaScript runtimes:
+
+* Chromium-based browsers: JSPI is supported by default.
+* Node: JSPI is supported by default in Node 25 or newer. In Node 20 -- 24 you
+ need the --experimental-wasm-jspi flag.
+* Firefox: JSPI support requires activating the
+ `javascript.options.wasm_js_promise_integration` flag.
+* Safari: JSPI is supported in Technology Preview 238 (released February 26,
+ 2026). It is not yet supported in any stable release of Safari.
+
+See https://github.com/WebAssembly/js-promise-integration/
+"""
+
+from __future__ import annotations
+
+import email.parser
+import warnings
+from contextlib import contextmanager
+from types import TracebackType
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AsyncIterator,
+ Awaitable,
+ Iterable,
+ Iterator,
+ Tuple,
+ TypeVar,
+ Union,
+)
+
+import js
+from pyodide.ffi import JsException, JsProxy, can_run_sync, run_sync, to_js
+
+if TYPE_CHECKING:
+ import ssl # pragma: nocover
+
+from .._config import DEFAULT_LIMITS, Limits
+from .._exceptions import (
+ ConnectError,
+ ConnectTimeout,
+ ReadError,
+ ReadTimeout,
+ RequestError,
+)
+from .._models import Request, Response
+from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream
+from .base import AsyncBaseTransport, BaseTransport
+
+T = TypeVar("T", bound="JavascriptFetchTransport")
+A = TypeVar("A", bound="AsyncJavascriptFetchTransport")
+
+SOCKET_OPTION = Union[
+ Tuple[int, int, int],
+ Tuple[int, int, Union[bytes, bytearray]],
+ Tuple[int, int, None, int],
+]
+
+__all__ = ["AsyncJavascriptFetchTransport", "JavascriptFetchTransport"]
+
+"""
+There are some headers that trigger unintended CORS preflight requests.
+See also https://github.com/koenvo/pyodide-http/issues/22
+"""
+HEADERS_TO_IGNORE = ("user-agent",)
+
+
+# Default values of ignored options.
+# If a different value is passed for any of these is passed we'll warn.
+# trust_env and http1 are not listed here because they don't conflict with the
+# JS-fetch behaviour.
+_IGNORED_OPTION_DEFAULTS: dict[str, Any] = {
+ "verify": True,
+ "cert": None,
+ "http2": False,
+ "limits": DEFAULT_LIMITS,
+ "proxy": None,
+ "uds": None,
+ "local_address": None,
+ "retries": 0,
+ "socket_options": None,
+}
+
+
+def _warn_ignored_options(**kwargs: Any) -> None:
+ """Emit a UserWarning naming each option that is ignored on Emscripten."""
+ ignored = [
+ name
+ for name, value in kwargs.items()
+ if name in _IGNORED_OPTION_DEFAULTS and value != _IGNORED_OPTION_DEFAULTS[name]
+ ]
+ if not ignored:
+ return
+ message = (
+ "The following transport option(s) are not supported on Emscripten "
+ f"and will be ignored: {', '.join(ignored)}. "
+ "Networking is handled by the JavaScript runtime, so connection "
+ "pooling, proxies, certificate handling, and low-level socket "
+ "configuration cannot be controlled by httpx2."
+ )
+ warnings.warn(message, stacklevel=3)
+
+
+@contextmanager
+def _timeout(
+ timeout: float,
+ abort_controller_js: JsProxy,
+ TimeoutExceptionType: type[RequestError],
+ ErrorExceptionType: type[RequestError],
+) -> Iterator[None]:
+ timer_id = None
+ if timeout > 0:
+ # It looks odd that we have to call bind() here since the JsProxy will
+ # automatically remember the receiver. But when we pass it back to
+ # JavaScript, we unwrap it and forget the receiver.
+ abort = abort_controller_js.abort.bind(abort_controller_js)
+ timer_id = js.setTimeout(abort, int(timeout * 1000))
+ try:
+ yield
+ except JsException as err:
+ if err.name == "AbortError":
+ timer_id = None
+ raise TimeoutExceptionType(message="Request timed out")
+ else:
+ raise ErrorExceptionType(message=err.message)
+ finally:
+ if timer_id is not None:
+ js.clearTimeout(timer_id)
+
+
+def _run_sync_with_timeout(
+ promise: Awaitable[JsProxy],
+ timeout: float,
+ abort_controller_js: JsProxy,
+ TimeoutExceptionType: type[RequestError],
+ ErrorExceptionType: type[RequestError],
+) -> JsProxy:
+ """await a javascript promise synchronously with a timeout set via the
+ AbortController and return the resulting javascript proxy
+
+ Args:
+ promise (Awaitable): Javascript promise to await
+ timeout (float): Timeout in seconds
+ abort_controller_js (Any): A javascript AbortController object, used on timeout
+ TimeoutExceptionType (type[Exception]): An exception type to raise on timeout
+ ErrorExceptionType (type[Exception]): An exception type to raise on error
+
+ Raises:
+ TimeoutExceptionType: If the request times out
+ ErrorExceptionType: If the request raises a Javascript exception
+
+ Returns:
+ JsProxy: The result of awaiting the promise.
+ """
+ with _timeout(timeout, abort_controller_js, TimeoutExceptionType, ErrorExceptionType):
+ # run_sync here uses WebAssembly Javascript Promise Integration to
+ # suspend python until the Javascript promise resolves.
+ return run_sync(promise)
+
+
+async def _run_async_with_timeout(
+ promise: Awaitable[JsProxy],
+ timeout: float,
+ abort_controller_js: JsProxy,
+ TimeoutExceptionType: type[RequestError],
+ ErrorExceptionType: type[RequestError],
+) -> JsProxy:
+ """await a javascript promise asynchronously with a timeout set via the
+ AbortController
+
+ Args:
+ promise (Awaitable): Javascript promise to await
+ timeout (float): Timeout in seconds
+ abort_controller_js (Any): A javascript AbortController object, used on timeout
+ TimeoutExceptionType (type[Exception]): An exception type to raise on timeout
+ ErrorExceptionType (type[Exception]): An exception type to raise on error
+
+ Raises:
+ TimeoutException: If the request times out
+ NetworkError: If the request raises a Javascript exception
+
+ Returns:
+ JsProxy: The result of awaiting the promise.
+ """
+ with _timeout(timeout, abort_controller_js, TimeoutExceptionType, ErrorExceptionType):
+ return await promise
+
+
+def _compute_timeouts(extensions: dict[str, Any]) -> tuple[float, float]:
+ timeout_dict = extensions.get("timeout", {}) or {}
+ conn_timeout = timeout_dict.get("connect", 0.0) or 0.0
+ read_timeout = timeout_dict.get("read", 0.0) or 0.0
+ return (conn_timeout, read_timeout)
+
+
+def _do_fetch(request: Request, request_body: bytes | None, abort_controller_js: Any) -> Awaitable[JsProxy]:
+ headers = {k: v for k, v in request.headers.items() if k not in HEADERS_TO_IGNORE}
+ fetch_data = {
+ "headers": headers,
+ "body": to_js(request_body),
+ "method": request.method,
+ "signal": abort_controller_js.signal,
+ }
+
+ return js.fetch( # type: ignore[no-any-return]
+ request.url,
+ to_js(fetch_data, dict_converter=js.Object.fromEntries),
+ )
+
+
+def _js_response_to_python(
+ Stream: "type[EmscriptenStream] | type[AsyncEmscriptenStream]",
+ response_js: Any,
+ read_timeout: float,
+ abort_controller_js: Any,
+) -> Response:
+ headers = dict(response_js.headers.entries())
+ # fix content-encoding headers because the javascript fetch handles that
+ headers["content-encoding"] = "identity"
+ status_code = response_js.status
+
+ # get a reader from the fetch response
+ body_stream_js = response_js.body.getReader()
+ return Response(
+ status_code=status_code,
+ headers=headers,
+ stream=Stream(body_stream_js, read_timeout, abort_controller_js),
+ )
+
+
+class EmscriptenStream(SyncByteStream):
+ def __init__(
+ self,
+ response_stream_js: JsProxy,
+ timeout: float,
+ abort_controller_js: JsProxy,
+ ) -> None:
+ self._stream_js = response_stream_js
+ self.timeout = timeout
+ self.abort_controller_js = abort_controller_js
+
+ def __iter__(self) -> Iterator[bytes]:
+ while True:
+ result_js = _run_sync_with_timeout(
+ self._stream_js.read(),
+ self.timeout,
+ self.abort_controller_js,
+ ReadTimeout,
+ ReadError,
+ )
+ if result_js.done:
+ return
+ else:
+ yield result_js.value.to_py()
+
+ def close(self) -> None:
+ self._stream_js = None
+
+
+class JavascriptFetchTransport(BaseTransport):
+ def __init__(
+ self,
+ verify: ssl.SSLContext | str | bool = True,
+ cert: CertTypes | None = None,
+ trust_env: bool = True,
+ http1: bool = True,
+ http2: bool = False,
+ limits: Limits = DEFAULT_LIMITS,
+ proxy: ProxyTypes | None = None,
+ uds: str | None = None,
+ local_address: str | None = None,
+ retries: int = 0,
+ socket_options: Iterable[SOCKET_OPTION] | None = None,
+ ) -> None:
+ _warn_ignored_options(
+ verify=verify,
+ cert=cert,
+ http2=http2,
+ limits=limits,
+ proxy=proxy,
+ uds=uds,
+ local_address=local_address,
+ retries=retries,
+ socket_options=socket_options,
+ )
+
+ def __enter__(self: T) -> T: # Use generics for subclass support.
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None = None,
+ exc_value: BaseException | None = None,
+ traceback: TracebackType | None = None,
+ ) -> None:
+ pass
+
+ def handle_request(
+ self,
+ request: Request,
+ ) -> Response:
+ assert isinstance(request.stream, SyncByteStream)
+ if not can_run_sync():
+ return _no_jspi_fallback(request)
+ request_body: bytes | None = b"".join(request.stream) or None
+
+ conn_timeout, read_timeout = _compute_timeouts(request.extensions)
+ abort_controller_js = js.AbortController.new()
+ fetcher_promise_js = _do_fetch(request, request_body, abort_controller_js)
+ response_js = _run_sync_with_timeout(
+ fetcher_promise_js,
+ conn_timeout,
+ abort_controller_js,
+ ConnectTimeout,
+ ConnectError,
+ )
+ return _js_response_to_python(EmscriptenStream, response_js, read_timeout, abort_controller_js)
+
+ def close(self) -> None:
+ pass # pragma: nocover
+
+
+class AsyncEmscriptenStream(AsyncByteStream):
+ def __init__(
+ self,
+ response_stream_js: JsProxy,
+ timeout: float,
+ abort_controller_js: JsProxy,
+ ) -> None:
+ self._stream_js = response_stream_js
+ self.timeout = timeout
+ self.abort_controller_js = abort_controller_js
+
+ async def __aiter__(self) -> AsyncIterator[bytes]:
+ while self._stream_js is not None:
+ result_js = await _run_async_with_timeout(
+ self._stream_js.read(),
+ self.timeout,
+ self.abort_controller_js,
+ ReadTimeout,
+ ReadError,
+ )
+ if result_js.done:
+ return
+ else:
+ yield result_js.value.to_py()
+
+ async def aclose(self) -> None:
+ self._stream_js = None
+
+
+class AsyncJavascriptFetchTransport(AsyncBaseTransport):
+ def __init__(
+ self,
+ verify: ssl.SSLContext | str | bool = True,
+ cert: CertTypes | None = None,
+ trust_env: bool = True,
+ http1: bool = True,
+ http2: bool = False,
+ limits: Limits = DEFAULT_LIMITS,
+ proxy: ProxyTypes | None = None,
+ uds: str | None = None,
+ local_address: str | None = None,
+ retries: int = 0,
+ socket_options: Iterable[SOCKET_OPTION] | None = None,
+ ) -> None:
+ _warn_ignored_options(
+ verify=verify,
+ cert=cert,
+ http2=http2,
+ limits=limits,
+ proxy=proxy,
+ uds=uds,
+ local_address=local_address,
+ retries=retries,
+ socket_options=socket_options,
+ )
+
+ async def __aenter__(self: A) -> A: # Use generics for subclass support.
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None = None,
+ exc_value: BaseException | None = None,
+ traceback: TracebackType | None = None,
+ ) -> None:
+ pass
+
+ async def _get_body(self, request: Request) -> bytes | None:
+ assert isinstance(request.stream, AsyncByteStream)
+ body = b"".join([x async for x in request.stream])
+ if not body:
+ return None
+ return body
+
+ async def handle_async_request(
+ self,
+ request: Request,
+ ) -> Response:
+ request_body = await self._get_body(request)
+ conn_timeout, read_timeout = _compute_timeouts(request.extensions)
+ abort_controller_js = js.AbortController.new()
+ fetcher_promise_js = _do_fetch(request, request_body, abort_controller_js)
+ response_js = await _run_async_with_timeout(
+ fetcher_promise_js,
+ conn_timeout,
+ abort_controller_js,
+ ConnectTimeout,
+ ConnectError,
+ )
+ return _js_response_to_python(AsyncEmscriptenStream, response_js, read_timeout, abort_controller_js)
+
+ async def aclose(self) -> None:
+ pass # pragma: nocover
+
+
+# Use XHR to do a sync request without jspi
+def _is_in_browser_main_thread() -> bool:
+ return hasattr(js, "window") and hasattr(js, "self") and js.self == js.window
+
+
+def _no_jspi_fallback(request: Request) -> Response:
+ assert isinstance(request.stream, SyncByteStream)
+ try:
+ js_xhr = js.XMLHttpRequest.new()
+
+ req_body: bytes | None = b"".join(request.stream)
+ if not req_body:
+ req_body = None
+ _, timeout = _compute_timeouts(request.extensions)
+
+ # XMLHttpRequest only supports timeouts and proper
+ # binary file reading in web-workers
+ if not _is_in_browser_main_thread():
+ js_xhr.responseType = "arraybuffer"
+ if timeout > 0.0:
+ js_xhr.timeout = int(timeout * 1000)
+ else:
+ # this is a nasty hack to be able to read binary files on
+ # main browser thread using xmlhttprequest
+ js_xhr.overrideMimeType("text/plain; charset=ISO-8859-15")
+
+ js_xhr.open(request.method, request.url, False)
+
+ for name, value in request.headers.items():
+ if name.lower() not in HEADERS_TO_IGNORE:
+ js_xhr.setRequestHeader(name, value)
+
+ js_xhr.send(to_js(req_body))
+
+ headers = dict(email.parser.Parser().parsestr(js_xhr.getAllResponseHeaders()))
+
+ if not _is_in_browser_main_thread():
+ body = js_xhr.response.to_py().tobytes()
+ else:
+ body = js_xhr.response.encode("ISO-8859-15")
+
+ return Response(status_code=js_xhr.status, headers=headers, content=body)
+ except JsException as err:
+ if err.name == "TimeoutError":
+ raise ConnectTimeout(message="Request timed out")
+ else:
+ raise ConnectError(message=err.message)
diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml
index 4e1ca2e6..49686c9a 100644
--- a/src/httpx2/pyproject.toml
+++ b/src/httpx2/pyproject.toml
@@ -43,9 +43,9 @@ dynamic = ["readme", "version", "dependencies"]
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = [
- "truststore>=0.10",
- "httpcore2=={{ version }}",
- "anyio",
+ "truststore>=0.10; sys_platform != 'emscripten'",
+ "httpcore2=={{ version }}; sys_platform != 'emscripten'",
+ "anyio; sys_platform != 'emscripten'",
"idna",
]
diff --git a/tests/httpx2/conftest.py b/tests/httpx2/conftest.py
index 91b43a57..af389ee0 100644
--- a/tests/httpx2/conftest.py
+++ b/tests/httpx2/conftest.py
@@ -5,6 +5,7 @@
import threading
import time
import typing
+from pathlib import Path
import pytest
import trustme
@@ -71,6 +72,10 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None:
await redirect_301(scope, receive, send)
elif scope["path"].startswith("/json"):
await hello_world_json(scope, receive, send)
+ elif scope["path"].startswith("/wheel_download"): # pragma: nocover for emscripten
+ await wheel_download(scope, receive, send)
+ elif scope["path"].startswith("/emscripten"): # pragma: nocover for emscripten
+ await hello_world_emscripten(scope, receive, send)
else:
await hello_world(scope, receive, send)
@@ -86,6 +91,51 @@ async def hello_world(scope: Scope, receive: Receive, send: Send) -> None:
await send({"type": "http.response.body", "body": b"Hello, world!"})
+# For testing on emscripten, we require cross origin isolation headers
+# to be set or else browsers won't be able to read from us from javascript
+async def hello_world_emscripten(scope: Scope, receive: Receive, send: Send) -> None: # pragma: nocover for emscripten
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [
+ [b"content-type", b"text/plain"],
+ [b"access-control-allow-origin", b"*"],
+ [
+ b"access-control-allow-methods",
+ b"PUT, GET, HEAD, POST, DELETE, OPTIONS",
+ ],
+ [b"Access-Control-Allow-Headers", b"*"],
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": b"Hello, world!"})
+
+
+# For testing on emscripten, it is useful to be able to
+# get the wheel package so that we can install it e.g.
+# on web-workers
+async def wheel_download(scope: Scope, receive: Receive, send: Send) -> None: # pragma: nocover for emscripten
+ wheel_file = sorted(Path("dist").glob("httpx2-*.whl"))[0]
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [
+ [b"content-type", b"application/x-wheel"],
+ [b"access-control-allow-origin", b"*"],
+ [
+ b"access-control-allow-methods",
+ b"PUT, GET, HEAD, POST, DELETE, OPTIONS",
+ ],
+ [b"Access-Control-Allow-Headers", b"*"],
+ ],
+ }
+ )
+ wheel_bytes = wheel_file.read_bytes()
+ await send({"type": "http.response.body", "body": wheel_bytes})
+
+
async def hello_world_json(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
@@ -102,7 +152,16 @@ async def slow_response(scope: Scope, receive: Receive, send: Send) -> None:
{
"type": "http.response.start",
"status": 200,
- "headers": [[b"content-type", b"text/plain"]],
+ "headers": [
+ [b"content-type", b"text/plain"],
+ [b"access-control-allow-origin", b"*"],
+ [
+ b"access-control-allow-methods",
+ b"PUT, GET, HEAD, POST, DELETE, OPTIONS",
+ ],
+ [b"Access-Control-Allow-Headers", b"*"],
+ [b"Cache-control", b"no-store,private,no-cache,must-revalidate"],
+ ],
}
)
await sleep(1.0) # Allow triggering a read timeout.
@@ -273,7 +332,9 @@ def serve_in_thread(server: TestServer) -> typing.Iterator[TestServer]:
@pytest.fixture(scope="session")
-def server(free_tcp_port_factory: typing.Callable[[], int]) -> typing.Iterator[TestServer]:
+def server(
+ cert_pem_file: str, cert_private_key_file: str, free_tcp_port_factory: typing.Callable[[], int]
+) -> typing.Iterator[TestServer]:
config = Config(app=app, lifespan="off", loop="asyncio", port=free_tcp_port_factory())
server = TestServer(config=config)
yield from serve_in_thread(server)
diff --git a/tests/httpx2/emscripten/conftest.py b/tests/httpx2/emscripten/conftest.py
new file mode 100644
index 00000000..97f2a1c6
--- /dev/null
+++ b/tests/httpx2/emscripten/conftest.py
@@ -0,0 +1,121 @@
+# Emscripten-specific test fixtures
+from __future__ import annotations
+
+from typing import Any, Callable, Iterator
+
+import pytest
+
+import httpx2
+
+try:
+ import pytest_pyodide
+ from pytest_pyodide.runner import SeleniumChromeRunner
+
+ _has_pytest_pyodide = True
+except ImportError: # pragma: nocover
+ # pytest-pyodide (and a browser/node runtime) is only available when a
+ # Pyodide environment has been set up via `scripts/download-pyodide`. When
+ # it isn't installed we skip collecting the emscripten tests entirely so
+ # that a plain `pytest` run on the host still works.
+ _has_pytest_pyodide = False
+ collect_ignore_glob = ["*"]
+
+
+if _has_pytest_pyodide:
+ # Make our ssl certificates work in Chrome
+ pyodide_config = pytest_pyodide.config.get_global_config()
+ pyodide_config.set_flags("chrome", ["ignore-certificate-errors"] + pyodide_config.get_flags("chrome"))
+
+
+def patch_javascript_setup(
+ orig: Callable[[SeleniumChromeRunner], None],
+) -> Callable[[SeleniumChromeRunner], None]:
+ """Remove WebAssembly.Suspending when jspi is False
+
+ Pyodide uses WebAssembly.Suspending to feature detect JSPI. Removing it
+ ensures that we actually use the no-JSPI code path when self.jspi is False.
+ """
+
+ def javascript_setup(self: SeleniumChromeRunner) -> None:
+ orig(self)
+ if not self.jspi:
+ self.run_js(
+ "delete WebAssembly.Suspending;",
+ pyodide_checks=False,
+ )
+
+ return javascript_setup
+
+
+if _has_pytest_pyodide:
+ SeleniumChromeRunner.javascript_setup = patch_javascript_setup(SeleniumChromeRunner.javascript_setup)
+
+
+def selenium_runner_helper(
+ request: pytest.FixtureRequest,
+ has_jspi: bool,
+ wheel_url: httpx2.URL,
+ is_worker: bool,
+) -> SeleniumChromeRunner:
+ if has_jspi:
+ fixture_name = "selenium_jspi"
+ else:
+ fixture_name = "selenium"
+ if is_worker:
+ fixture_name += "_worker"
+ result = request.getfixturevalue(fixture_name)
+ if result.browser == "node":
+ # stop node.js checking our https certificates
+ result.run_js('process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;')
+
+ result.run_js(
+ f"""
+ await pyodide.loadPackage("micropip");
+ await pyodide.runPythonAsync(`
+ import micropip
+ await micropip.install([{str(wheel_url)!r}, "h2"])
+ `);
+ """
+ )
+ return result
+
+
+@pytest.fixture
+def selenium_runner(request: pytest.FixtureRequest, runtime: str, has_jspi: bool, wheel_url: httpx2.URL) -> Any:
+ worker = False
+ return selenium_runner_helper(request, has_jspi, wheel_url, worker)
+
+
+@pytest.fixture
+def selenium_worker_runner(request: pytest.FixtureRequest, runtime: str, has_jspi: bool, wheel_url: httpx2.URL) -> Any:
+ worker = True
+ return selenium_runner_helper(request, has_jspi, wheel_url, worker)
+
+
+@pytest.fixture(scope="session")
+def server_url(request: pytest.FixtureRequest, server: Any) -> Iterator[httpx2.URL]:
+ yield server.url.copy_with(path="/emscripten")
+
+
+@pytest.fixture
+def wheel_url(server_url: httpx2.URL) -> Iterator[httpx2.URL]:
+ ver = httpx2.__version__
+ yield server_url.copy_with(path=f"/wheel_download/httpx2-{ver}-py3-none-any.whl")
+
+
+def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
+ """Generate Webassembly Javascript Promise Integration based tests
+ only for platforms that support it.
+
+ Currently:
+ 1) NodeJS requires JSPI because it doesn't support XMLHttpRequest
+ 2) Firefox doesn't support JSPI
+ 3) Chrome supports JSPI on or off.
+ """
+ if "has_jspi" in metafunc.fixturenames: # pragma: no cover
+ if metafunc.config.getoption("--runtime").startswith("node"):
+ metafunc.parametrize("has_jspi", [True])
+ elif metafunc.config.getoption("--runtime").startswith("firefox"):
+ metafunc.parametrize("has_jspi", [False])
+ else:
+ metafunc.parametrize("has_jspi", [True, False])
diff --git a/tests/httpx2/emscripten/test_emscripten.py b/tests/httpx2/emscripten/test_emscripten.py
new file mode 100644
index 00000000..1ac5624e
--- /dev/null
+++ b/tests/httpx2/emscripten/test_emscripten.py
@@ -0,0 +1,171 @@
+from typing import Any, Callable
+
+import pytest
+from pytest_pyodide.decorator import run_in_pyodide_coverage
+from pytest_pyodide.runner import SeleniumChromeRunner
+
+from httpx2 import URL
+
+
+def run_in_pyodide(func: Callable[..., Any]) -> Callable[..., Any]:
+ args = {"include": ["*/httpx2/*", "*/tests/*"]}
+ return run_in_pyodide_coverage(coverage_args=args)(func) # type: ignore[no-any-return]
+
+
+@pytest.fixture
+def timeout_url(server_url: URL, request: pytest.FixtureRequest) -> URL:
+ return server_url.copy_with(path="/slow_response", query=request.node.callspec.id.encode("UTF-8"))
+
+
+@run_in_pyodide
+def test_get(selenium_runner: SeleniumChromeRunner, server_url: URL, wheel_url: URL) -> None:
+ import httpx2
+
+ response = httpx2.get(server_url)
+ assert response.status_code == 200
+ assert response.reason_phrase == "OK"
+ assert response.text == "Hello, world!"
+ assert response.http_version == "HTTP/1.1"
+
+
+@run_in_pyodide
+def test_post_http(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import httpx2
+
+ response = httpx2.post(server_url, content=b"Hello, world!")
+ assert response.status_code == 200
+ assert response.reason_phrase == "OK"
+
+
+@run_in_pyodide
+async def test_async_get(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import httpx2
+
+ async with httpx2.AsyncClient() as client:
+ response = await client.get(server_url)
+ assert response.status_code == 200
+ assert response.text == "Hello, world!"
+ assert response.http_version == "HTTP/1.1"
+ assert response.headers
+ assert repr(response) == ""
+
+
+@run_in_pyodide
+async def test_async_get_timeout(selenium_runner: SeleniumChromeRunner, timeout_url: URL) -> None:
+ import pytest
+
+ import httpx2
+
+ async with httpx2.AsyncClient() as client:
+ with pytest.raises(httpx2.TimeoutException):
+ await client.get(timeout_url, timeout=0.1)
+
+
+@run_in_pyodide
+def test_sync_get_timeout(selenium_runner: SeleniumChromeRunner, has_jspi: bool, timeout_url: URL) -> None:
+ """test timeout on https and http"""
+ import pytest
+
+ import httpx2
+
+ if not has_jspi:
+ # Requires JSPI b/c otherwise if we are using XMLHttpRequest in a main
+ # browser thread then this will never timeout, or at least it will use the
+ # default browser timeout which is VERY long!
+ pytest.skip("Requires JSPI")
+
+ with pytest.raises(httpx2.TimeoutException):
+ httpx2.get(timeout_url, timeout=0.1)
+
+
+@run_in_pyodide
+def test_sync_get_timeout_worker(selenium_worker_runner: SeleniumChromeRunner, timeout_url: URL) -> None:
+ import pytest
+
+ import httpx2
+
+ with pytest.raises(httpx2.TimeoutException):
+ httpx2.get(timeout_url, timeout=0.1)
+
+
+@run_in_pyodide
+def test_get_worker(selenium_worker_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import httpx2
+
+ response = httpx2.get(server_url)
+ assert response.status_code == 200
+ assert response.reason_phrase == "OK"
+ assert response.text == "Hello, world!"
+
+
+@run_in_pyodide
+def test_sync_get_error(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import pytest
+
+ import httpx2
+
+ # test connection error
+ # 255.255.255.255 should always return an error
+ error_url = str(server_url).split(":")[0] + "://255.255.255.255/"
+ with pytest.raises(httpx2.ConnectError):
+ httpx2.get(error_url)
+
+
+@run_in_pyodide
+async def test_async_get_error(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import pytest
+
+ import httpx2
+
+ # test connection error
+ # 255.255.255.255 should always return an error
+ error_url = str(server_url).split(":")[0] + "://255.255.255.255/"
+ with pytest.raises(httpx2.ConnectError):
+ async with httpx2.AsyncClient(timeout=1.0) as client:
+ await client.get(error_url)
+
+
+@run_in_pyodide
+async def test_async_post_json(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import httpx2
+
+ async with httpx2.AsyncClient() as client:
+ response = await client.post(server_url, json={"text": "Hello, world!"})
+ assert response.status_code == 200
+
+
+@run_in_pyodide
+def test_ignored_options_warn(selenium_runner: SeleniumChromeRunner, server_url: URL) -> None:
+ import warnings
+
+ import pytest
+
+ import httpx2
+
+ # No warning when using defaults.
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ httpx2.HTTPTransport()
+ httpx2.AsyncHTTPTransport()
+
+ # Each unsupported option should produce a single UserWarning naming it.
+ cases: list[tuple[str, Any]] = [
+ ("verify", False),
+ ("cert", "client.pem"),
+ ("http2", True),
+ ("proxy", "http://localhost:8080"),
+ ("uds", "/tmp/sock"),
+ ("local_address", "127.0.0.1"),
+ ("retries", 3),
+ ("socket_options", []),
+ ]
+ for key, value in cases:
+ match = rf"The following transport option\(s\) are not supported on Emscripten and will be ignored: {key}."
+ with pytest.warns(UserWarning, match=match):
+ httpx2.HTTPTransport(**{key: value})
+ with pytest.warns(UserWarning, match=match):
+ httpx2.AsyncHTTPTransport(**{key: value})
+ if key in ("verify", "cert", "http2", "proxy"):
+ # Warning is also surfaced when constructing a Client.
+ with pytest.warns(UserWarning, match=match):
+ httpx2.Client(**{key: value})
diff --git a/uv.lock b/uv.lock
index 7b0c8f57..92396c72 100644
--- a/uv.lock
+++ b/uv.lock
@@ -49,6 +49,10 @@ docs = [
{ name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" },
{ name = "zensical", specifier = ">=0.0.41" },
]
+emscripten = [
+ { name = "pytest-pyodide", marker = "python_full_version >= '3.11'", specifier = ">=0.59.2" },
+ { name = "selenium" },
+]
[[package]]
name = "aiohappyeyeballs"
@@ -1346,10 +1350,10 @@ provides-extras = ["asyncio", "http2", "socks", "trio"]
name = "httpx2"
source = { editable = "src/httpx2" }
dependencies = [
- { name = "anyio" },
- { name = "httpcore2" },
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "idna" },
- { name = "truststore" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
]
[package.optional-dependencies]
@@ -1374,17 +1378,17 @@ zstd = [
[package.metadata]
requires-dist = [
- { name = "anyio" },
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'" },
{ name = "click", marker = "extra == 'cli'", specifier = "==8.*" },
{ name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" },
- { name = "httpcore2", editable = "src/httpcore2" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'", editable = "src/httpcore2" },
{ name = "idna" },
{ name = "pygments", marker = "extra == 'cli'", specifier = "==2.*" },
{ name = "rich", marker = "extra == 'cli'", specifier = ">=10,<16" },
{ name = "socksio", marker = "extra == 'socks'", specifier = "==1.*" },
- { name = "truststore", specifier = ">=0.10" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'", specifier = ">=0.10" },
{ name = "zstandard", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=0.18.0" },
]
provides-extras = ["brotli", "cli", "http2", "socks", "zstd"]
@@ -1398,6 +1402,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
+[[package]]
+name = "hypothesis"
+version = "6.153.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sortedcontainers", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c3/8c661bb893725eedeb003e85f3050274da2d77abf0847c4d61b4af53969c/hypothesis-6.153.6.tar.gz", hash = "sha256:8f7663251c57c9ee1fb6c0e919a6027cbda98d52b210dea441957d11d644c271", size = 475551, upload-time = "2026-05-27T17:43:32.524Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/33/f3ec54e6fb89c2279f0dd911ba512321e70038e447d1984c35fad61840f8/hypothesis-6.153.6-py3-none-any.whl", hash = "sha256:a892e3460e4dd8cfb8525682d8901be8f5e2d2c7b352359b71a44e5def2b89c8", size = 541876, upload-time = "2026-05-27T17:43:30.807Z" },
+]
+
[[package]]
name = "id"
version = "1.6.1"
@@ -2389,6 +2405,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
[[package]]
name = "pillow"
version = "12.2.0"
@@ -2496,6 +2524,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
+[[package]]
+name = "playwright"
+version = "1.38.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*'",
+]
+dependencies = [
+ { name = "greenlet", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+ { name = "pyee", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/0d/4b433d7d740d3ab7e2ca0d3de6db500a6b20fe49001388d5b25d6740982d/playwright-1.38.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:22e4a49d61a20a21d6a4a90891d4d08df5091f3719272d7a31c4c7f0ff436683", size = 33054229, upload-time = "2023-09-18T22:14:26.275Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/b0/c59fcdda1a05cdfce282ba5c410d57e696aec614bba9a4f5c43bee7731fc/playwright-1.38.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:324e317c6ddc919a01e98ed182a54c88c0b6e775e91aea2996ed320b436c0f27", size = 31401812, upload-time = "2023-09-18T22:14:32.587Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/39/47b46c5ef5eb5b7cf8ebf0417c9be6330daed9576748bbd01a1330546cd1/playwright-1.38.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:ce5c2d2c49c97ea856129ac895dc7277df3c877db4a998340bd08efc3696e7fb", size = 33054228, upload-time = "2023-09-18T22:14:38.188Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ae/19318074f87b4c3c0b0f45ee5a41bbcf013b7d46ce235d018adb1da97f7b/playwright-1.38.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:d0288c8932d7f14bc231e4a6761ecf76fff879d1601cfa3b6f6aefd544468911", size = 35411426, upload-time = "2023-09-18T22:14:44.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/dc/0ebfe0c9050da64efe036fe2938ab13d5a46bfb47c1f851858d10ce6022d/playwright-1.38.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33d6500d94c5e4608d3a74372d6f50ecbebca55dc55eaee3f70b21eaf02b17aa", size = 35302903, upload-time = "2023-09-18T22:14:50.016Z" },
+ { url = "https://files.pythonhosted.org/packages/83/4a/be5e34171c55f1ac0246a177add83933ac50898bb54db858155e15a264d4/playwright-1.38.0-py3-none-win32.whl", hash = "sha256:1c46a7ed7702b9f97b57737132f25e2052ef2e9541c3613d896e92739d2ea4ee", size = 29141808, upload-time = "2023-09-18T22:14:57.692Z" },
+ { url = "https://files.pythonhosted.org/packages/61/68/42f5eae2bdc06e6274cde931add3a076fdf18fdbaddda11c61eb95cd88b8/playwright-1.38.0-py3-none-win_amd64.whl", hash = "sha256:801029161725bd9a8c1ea2d29125074f7e54bfa7b0ef85c6dfb667023a0702c8", size = 29141817, upload-time = "2023-09-18T22:15:03.126Z" },
+]
+
+[[package]]
+name = "playwright"
+version = "1.60.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13'",
+ "python_full_version == '3.12.*'",
+]
+dependencies = [
+ { name = "greenlet", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "pyee", version = "13.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
+ { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
+ { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
+ { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2619,6 +2691,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -2628,6 +2709,37 @@ 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 = "9.0.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*'",
+]
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/d0/32803671d5d9dc032c766ad6c0716db98fa9b2c6ad9ec544f04849e9d3c7/pyee-9.0.4.tar.gz", hash = "sha256:2770c4928abc721f46b705e6a72b0c59480c4a69c9a83ca0b00bb994f1ea4b32", size = 20232, upload-time = "2022-02-04T19:53:54.032Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/54/10695d113f03688d79d24bbd7eca30b0e386cbc6d2743ffb68dcae131bf6/pyee-9.0.4-py2.py3-none-any.whl", hash = "sha256:9f066570130c554e9cc12de5a9d86f57c7ee47fece163bbdaa3e9c933cfbdfa5", size = 14919, upload-time = "2022-02-04T19:53:51.384Z" },
+]
+
+[[package]]
+name = "pyee"
+version = "13.0.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13'",
+ "python_full_version == '3.12.*'",
+]
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version >= '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -2724,6 +2836,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
]
+[[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.0.3"
@@ -2742,6 +2863,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest", marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+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 = "pytest-codspeed"
version = "4.5.0"
@@ -2786,6 +2920,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/ff/aec307ae2a6193db3f87dd5fd75738007bbb212b5237d31a4ab638215f42/pytest_httpbin-2.0.0-py2.py3-none-any.whl", hash = "sha256:d977f8095796e27a45911bbafa3587c081c9025e060e2fdb559794db2d45e82d", size = 9877, upload-time = "2023-05-08T20:12:03.997Z" },
]
+[[package]]
+name = "pytest-pyodide"
+version = "0.59.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hypothesis", marker = "python_full_version >= '3.11'" },
+ { name = "pexpect", marker = "python_full_version >= '3.11'" },
+ { name = "playwright", version = "1.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+ { name = "playwright", version = "1.60.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "pytest", marker = "python_full_version >= '3.11'" },
+ { name = "pytest-asyncio", marker = "python_full_version >= '3.11'" },
+ { name = "selenium", marker = "python_full_version >= '3.11'" },
+ { name = "tblib", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/c3/c0703de1dd7872c5d73b53139a8b7683f5b065cf7d18dcb9bbc327a01c2d/pytest_pyodide-0.59.2.tar.gz", hash = "sha256:bff1d8f285ba9b72e2f67b71d3f7030585598bb8a2a9de7c1bbbd7eda11adae8", size = 172816, upload-time = "2026-04-27T19:19:13.749Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/ee/eaa0e5249e3555cbd722a3f66d8ca897fa33e838a578db00d3923ff6643c/pytest_pyodide-0.59.2-py3-none-any.whl", hash = "sha256:4517c09be4321e1f37c0263fd2f2f15cb7fd44b38dcbf1a93a4fe0d12737fa16", size = 54265, upload-time = "2026-04-27T19:19:12.468Z" },
+]
+
[[package]]
name = "pytest-trio"
version = "0.8.0"
@@ -3134,6 +3287,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
+[[package]]
+name = "selenium"
+version = "4.44.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/2d/4a/6d0a4f4a07e2a91511a51398203ee82bf6ce644a448aaa35c59b44aa9531/selenium-4.44.0.tar.gz", hash = "sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e", size = 993133, upload-time = "2026-05-12T22:48:19.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/bc/885047e975e996cb317db31c4551caa915aafc6befea990f082c7233adc2/selenium-4.44.0-py3-none-any.whl", hash = "sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24", size = 9654291, upload-time = "2026-05-12T22:48:16.836Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -3170,6 +3340,15 @@ 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 = "tblib"
+version = "3.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/8a/14c15ae154895cc131174f858c707790d416c444fc69f93918adfd8c4c0b/tblib-3.2.2.tar.gz", hash = "sha256:e9a652692d91bf4f743d4a15bc174c0b76afc750fe8c7b6d195cc1c1d6d2ccec", size = 35046, upload-time = "2025-11-12T12:21:16.572Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl", hash = "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", size = 12893, upload-time = "2025-11-12T12:21:14.407Z" },
+]
+
[[package]]
name = "tomli"
version = "2.4.1"
@@ -3259,6 +3438,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/ff/9bd795273eb14fac7f6a59d16cc8c4d0948a619a1193d375437c7f50f3eb/trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264", size = 42224, upload-time = "2023-12-01T02:54:54.1Z" },
]
+[[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 = "trustme"
version = "1.2.1"
@@ -3319,6 +3513,11 @@ 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 = "uvicorn"
version = "0.46.0"
@@ -3365,6 +3564,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
+]
+
[[package]]
name = "werkzeug"
version = "3.1.8"
@@ -3377,6 +3585,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
+[[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" },
+]
+
[[package]]
name = "yarl"
version = "1.23.0"