rpyodide lets you call Python from R in the web browser. R runs in
webR and Python runs in
Pyodide, both compiled to WebAssembly. The API
follows reticulate, so most
reticulate code works unchanged.
library(rpyodide)
py_run_string("x = [i ** 2 for i in range(5)]")
py$x
np <- import("numpy") # loaded from the Pyodide distribution on first use
np$linalg$inv(matrix(c(2, 1, 1, 3), 2))
pd <- import("pandas", convert = FALSE)
df <- r_to_py(mtcars) # data.frame -> pandas.DataFrame
py_to_r(df$describe()) # pandas.DataFrame -> data.frame
py_install("tabulate") # micropip: Pyodide packages or pure-Python PyPI wheels
The project repository has a minimal working example: a web page
(demo/index.html) that installs rpyodide into webR and runs the code
above, with a box for your own code. It is tested in headless Chrome.
rpyodide is a pure R package.
R-universe builds it for
WebAssembly, so in webR install it with:
webr::install("rpyodide", repos = c("https://muschellij2.r-universe.dev", "https://repo.r-wasm.org"))
It also installs in regular R, from GitHub or R-universe:
# install.packages("pak")
pak::pak("muschellij2/rpyodide")
Outside webR, Pyodide sessions can’t start, but CPython
sessions can. is_webr() tells you which
situation you’re in.
webR 0.6.0 or later, using its SharedArrayBuffer channel. The page must be cross-origin isolated, so it has to be served with these headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
On hosts that can’t set headers, such as GitHub Pages, a service worker like coi-serviceworker can add them.
Network access to the Pyodide distribution. By default that’s
https://cdn.jsdelivr.net/pyodide/v314.0.7/full/. Change it with
options(rpyodide.pyodide_url = ...) or
pyodide_start(pyodide_url = ...).
page (main thread) webR worker Pyodide
────────────────── ─────────── ───────
webR relays worker <──────────── R: webr::eval_js(...)
messages postMessage(json, {async: false})
│ R blocks until a reply
▼
shim worker (classic) ──► nested module worker: Pyodide + rpyodide_bridge.py
▲ │
└──────────────── {uuid, result: json} ◄────────┘
Worker with a proxy. The real
worker runs on the page’s main thread, and
postMessage(msg, {async: false}) blocks until the worker replies.
That makes R → Python calls synchronous, which is what a
reticulate-style API needs.inst/python/rpyodide_bridge.py). Python objects that can’t be
converted stay in the worker, and R holds a numbered reference to
them. When R garbage-collects a reference, the Python object is freed
on the next call.| R | Python | back to R |
|---|---|---|
NULL |
None |
NULL |
| length-1 logical, integer, double, character |
bool, int, float, str
|
same |
| longer vector | list |
list |
| named vector or list | dict |
named list |
| matrix or array |
numpy.ndarray (column-major order is kept) |
matrix or array |
data.frame |
pandas.DataFrame (row names become the index) |
data.frame |
POSIXct |
pandas.Timestamp, or datetime64[ns] array |
POSIXct (UTC) |
difftime |
seconds | difftime |
| factor, Date |
str values |
character |
I(x) |
always a list
|
list |
NA / NaN / Inf
|
None or nan / nan / inf
|
NA / NaN / Inf
|
pandas.Series |
vector, named by the index (unless it is a default 0, 1, 2, … index) | |
pandas.Index, DatetimeIndex
|
vector, POSIXct
|
Whole-number doubles stay Python floats, as in reticulate, so use 5L
where Python needs an int. Set convert = FALSE in import(),
py_eval() or r_to_py() to keep Python objects instead of converting
them.
webR and Pyodide have separate virtual file systems. When R passes
Python the absolute path of an existing file, such as a CSV that R wrote
to tempdir(), rpyodide copies the file to the same path in Pyodide
first. So pd$read_csv(tempfile_path) just works. A file is copied
again only if it changes. To copy other files, such as a Python module
to import, use py_upload(). Turn automatic copying off with
options(rpyodide.mirror_files = FALSE). Files larger than
getOption("rpyodide.mirror_max_bytes") (512 MB by default) aren’t
copied.
py_require()
py_require(c("numpy", "pandas>=2")) # recorded; installed when Python starts
py_require(python_version = ">=3.12") # checked: Pyodide ships one Python per release
py_require("stepcount", deps = FALSE) # install without its (unmeetable) pins
py_require() # show the requirements
As with reticulate::py_require(), requirements accumulate and are
installed when Python starts, or immediately if it is already running.
Packages built for Pyodide (numpy, scipy, pandas, scikit-learn, …) come
from the Pyodide distribution. Others come from PyPI and must be pure
Python or have WebAssembly wheels.
A Pyodide interpreter has one environment: one site-packages, and
one version of each package. To get separate environments, rpyodide
runs one Pyodide per web worker:
pyodide_start("ml", packages = "scikit-learn")
pyodide_start("plain", lockfile_url = "https://example.org/pinned/pyodide-lock.json")
use_pyodide("ml") # make "ml" the active session
py_eval("1 + 1", session = "plain")
pyodide_sessions()
pyodide_stop("plain")
Objects belong to the session that made them, and using one in another session is an error.
cpython_start() runs the same Python bridge in a local CPython process
instead of Pyodide, so code written for rpyodide can be tested in
regular R, in continuous integration and on CRAN:
cpython_start("test") # needs processx and Python >= 3.8
py_eval("[x ** 2 for x in range(3)]")
pyodide_stop("test")
rpyodide’s own tests use this. They run wherever Python is installed
and are skipped where it isn’t. Tests that need NumPy or pandas are
skipped if those aren’t installed. In CPython sessions, packages come
from the local Python (install them with pip), and files aren’t copied
because Python shares R’s file system.
Most of rpyodide’s function names match reticulate’s, so a package can
choose its Python backend at run time:
py_backend <- function() {
if (identical(R.version$os, "emscripten")) asNamespace("rpyodide")
else asNamespace("reticulate")
}
np <- py_backend()$import("numpy")
List rpyodide in Suggests. See the webr branch of
stepcount for a full example:
both of its models (a random forest and a PyTorch network) run in the
browser, and the results are identical to those on the desktop.
python_version in py_require() is a check, not a choice.deps = FALSE
and require compatible versions yourself.urllib and requests don’t
work. Downloads have to go through the browser (for example
pyodide.http) and are subject to the server’s CORS rules.py_func()), iterators and generators (iterate()), with(),
Python operators on objects (+, ==, …), py_capture_output(),
virtualenv_*() and conda_*(). Use Pyodide sessions instead of
environments.import
statements, import() and NumPy/pandas conversions. Imports Pyodide
can’t see, such as __import__("x"), need py_install() or
py_require() first.pyarrow is in the Pyodide distribution (the full R arrow
package isn’t built for webR). It would only be used when both are
installed, and it would remove most of the JSON encoding cost. Parquet
through duckdb (available on both sides) is another option for very
large tables. A lighter, dependency-free first step is to send numeric
vectors as base64 binary rather than JSON numbers.with(), py_capture_output() and more of
reticulate’s API.rpyodide has only been tested
in Chrome.R CMD check --as-cran passes. The package is pure R, and its only
imports are jsonlite and utils. CRAN’s machines have Python, so the
tests run the R code and the Python bridge together against CPython (see
Testing without a browser). What CRAN
can’t test:
dev/ and demo/ in the project
repository), and examples
that need webR are wrapped in \dontrun{}.postMessage), which is a webR internal, not a documented API, and
could change in a new webR release.webR users install packages from webR repositories (repo.r-wasm.org,
R-universe), so R-universe is how they get rpyodide either way.