rpyodide

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.

Installation

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.

Requirements

How it works

 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} ◄────────┘

Conversions

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.

Files

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.

Python packages and 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.

Python environments

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.

Testing without a browser

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.

Using reticulate and rpyodide from one package

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.

Differences from reticulate

Future work

CRAN

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:

webR users install packages from webR repositories (repo.r-wasm.org, R-universe), so R-universe is how they get rpyodide either way.