-
Notifications
You must be signed in to change notification settings - Fork 22
Add Emscripten/Pyodide support #1022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4c7780a
6e1ee97
20dbaa0
2ef73ab
705fa1a
e38d344
bb6405a
de79edb
b3cb6ec
8b1a3a1
e078450
0ba4857
6e1a1b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,3 +11,4 @@ venv*/ | |
| .python-version | ||
| build/ | ||
| dist/ | ||
| pyodide_dist/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
|
||
| <div id="pyodide_editor">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], "...")</div> | ||
|
|
||
| <div id="pyodide_output"></div> | ||
|
|
||
| <div id="pyodide_buttons"></div> | ||
|
|
||
| ## 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("<URL_OF_THE_WHEEL>") | ||
| 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"]) | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| {% extends "main.html" %} | ||
| {% block styles %} | ||
| {{ super() }} | ||
| <link | ||
| href="https://cdn.jsdelivr.net/npm/ace-builds@1.36.2/css/ace.min.css" | ||
| rel="stylesheet" | ||
| /> | ||
| <style> | ||
| #pyodide_editor { | ||
| width: 100%; | ||
| height: 14em; | ||
| font-size: 0.8rem; | ||
| box-sizing: border-box; | ||
| } | ||
| #pyodide_editor .ace_gutter, | ||
| #pyodide_editor .ace_content, | ||
| #pyodide_editor .ace_text-layer { | ||
| font-size: 0.8rem; | ||
| } | ||
| #pyodide_output { | ||
| width: 100%; | ||
| height: 20em; | ||
| white-space: pre-wrap; | ||
| background-color: #1e1e1e; | ||
| color: #e6e6e6; | ||
| font-family: "Courier New", monospace; | ||
| font-size: 0.8rem; | ||
| overflow-y: scroll; | ||
| padding: 0.5em; | ||
| box-sizing: border-box; | ||
| border-radius: 4px; | ||
| } | ||
|
|
||
| #pyodide_output span { | ||
| display: block; | ||
| } | ||
| .pyodide_error { | ||
| color: #ff6b6b; | ||
| } | ||
| </style> | ||
| {% endblock %} | ||
|
|
||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script src="https://cdn.jsdelivr.net/npm/ace-builds@1.36.2/src-noconflict/ace.min.js"></script> | ||
| <script src="https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js"></script> | ||
| <script language="javascript"> | ||
| const editor = ace.edit("pyodide_editor"); | ||
| editor.setTheme("ace/theme/monokai"); | ||
| editor.session.setMode("ace/mode/python"); | ||
| editor.setOptions({ | ||
| fontSize: "0.8rem", | ||
| vScrollBarAlwaysVisible: true, | ||
| }); | ||
| const outputDiv = document.getElementById("pyodide_output"); | ||
| outputDiv.innerText = "Loading python in your browser"; | ||
|
|
||
| function stdoutLine(line) { | ||
| const content = document.createElement("span"); | ||
| content.textContent = line; | ||
| outputDiv.appendChild(content); | ||
| } | ||
|
|
||
| function stderrLine(line) { | ||
| const content = document.createElement("span"); | ||
| content.className = "pyodide_error"; | ||
| content.textContent = line; | ||
| outputDiv.appendChild(content); | ||
| } | ||
|
|
||
| async function initPyodide() { | ||
| const pyodide = await loadPyodide(); | ||
| pyodide.setStdout({ batched: stdoutLine }); | ||
| pyodide.setStderr({ batched: stderrLine }); | ||
| if ( | ||
| document.location.origin.startsWith("http://127.0.0.1") || | ||
| document.location.origin.startsWith("http://localhost") | ||
| ) { | ||
| // If we are on a development machine, use the wheel from the dist folder | ||
| await pyodide.loadPackage(["/test.whl", "ssl", "idna", "certifi"]); | ||
| } else { | ||
| // Otherwise load it from PyPI | ||
| // (once a Pyodide-supporting version is on there) | ||
| await pyodide.loadPackage("micropip"); | ||
| await pyodide.runPythonAsync("import micropip; await micropip.install('httpx2')"); | ||
| } | ||
| outputDiv.innerText = "Python runtime ready...\n"; | ||
| return pyodide; | ||
| } | ||
|
|
||
| const loadPromise = initPyodide(); | ||
|
|
||
| async function runCode() { | ||
|
hoodmane marked this conversation as resolved.
|
||
| const pyodide = await loadPromise; | ||
| const code = editor.getValue(); | ||
| await pyodide.loadPackagesFromImports(code); | ||
| try { | ||
| await pyodide.runPythonAsync(code); | ||
| } catch (e) { | ||
| stderrLine(e.message); | ||
| } | ||
| } | ||
|
|
||
| function clearOutput() { | ||
| outputDiv.innerHTML = ""; | ||
| } | ||
|
|
||
| const run_button = document.createElement("button"); | ||
| run_button.className = "md-button"; | ||
| run_button.onclick = runCode; | ||
| run_button.innerText = "Run"; | ||
| const clear_button = document.createElement("button"); | ||
| clear_button.className = "md-button"; | ||
| clear_button.onclick = clearOutput; | ||
| clear_button.innerText = "Clear output"; | ||
| clear_button.style = "float:right"; | ||
| const buttonDiv = document.getElementById("pyodide_buttons"); | ||
| buttonDiv.appendChild(run_button); | ||
| buttonDiv.appendChild(clear_button); | ||
|
|
||
| globalThis.runCode = runCode; | ||
| </script> | ||
| {% endblock %} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
| ] | ||
|
Comment on lines
+89
to
+93
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How does this interact with
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is orthogonal: |
||
|
|
||
| [tool.coverage.report] | ||
| exclude_also = [ | ||
| "if TYPE_CHECKING:", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm inclined to not have this machinery here, and not care about coverage on
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For what it's worth, the coverage information has been quite useful when editing
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a coverage purist. I understand this. Maybe we can have it self contained? e.g. needs to be 100% on the jsfetch and test file. Instead of combining I mean.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well we'll need to combine even if we just want to check |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Uh oh!
There was an error while loading. Please reload this page.