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"