Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/pyodide.yml
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ venv*/
.python-version
build/
dist/
pyodide_dist/
94 changes: 94 additions & 0 deletions docs/advanced/emscripten.md
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"])
```
123 changes: 123 additions & 0 deletions docs/overrides/pyodide.html
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")
Comment thread
hoodmane marked this conversation as resolved.
) {
// 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() {
Comment thread
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 %}
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this interact with source_pkgs above?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is orthogonal: [tool.coverage.run] indicates what files should get coverage information initially, [tool.coverage.paths] controls how coverage combine remaps paths. I think when you run the native tests, it uses an editable install so the file paths point into the source tree. On the other hand, in Pyodide it has to install the wheel into site-packages, and this tells it to remap the path back to the source tree path.


[tool.coverage.report]
exclude_also = [
"if TYPE_CHECKING:",
Expand Down
11 changes: 10 additions & 1 deletion scripts/coverage
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 jsfetch.py. 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 jsfetch.py, the uncovered lines are often buggy.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 jsfetch.py because each call to @run_in_pyodide generates it's own separate coverage file.

31 changes: 31 additions & 0 deletions scripts/download-pyodide
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
9 changes: 8 additions & 1 deletion scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading