--- title: "Python interop" format: html: toc: true vignette: > %\VignetteIndexEntry{Python interop} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- The process at the other end of a channel does not have to be R. [pymizu](https://github.com/shikokuchuo/pymizu) is the Python counterpart of mizu, built on the same shared-memory core. A channel connects the two languages as directly as two R sessions. Pass the peer program as a source string instead of a quoted expression, and set the launcher to `mizu_py_launcher()`. The launcher checks for the interpreter and pymizu before the channel is created, then spawns `python3 -m pymizu.child`. In this example, a Python process doubles the Old Faithful eruption durations. (The Python examples in this vignette run when a `python3` with pymizu and NumPy installed is on the `PATH`.) ``` r library(mizu) ch <- mizu_channel( r"( import pymizu while True: x = ch.recv(30) if pymizu.is_sentinel(x): break ch.send(x * 2) )", launcher = mizu_py_launcher() ) mizu_send(ch, faithful$eruptions) head(mizu_recv(ch, timeout = 5)) #> [1] 7.200 3.600 6.666 4.566 9.066 5.766 mizu_close(ch) ``` ## Large data crosses without copying Above a size floor, mizu writes the data to a shared-memory region once, and both sides read it in place. Nothing is serialized, parsed, or copied again. A large value you receive arrives as an ALTREP view: R reads it straight off the shared pages, and no copy lands in your session. This is the same zero-copy mechanism you get from another R process. A large value you send arrives in Python backed by those same pages. The peer below echoes back whatever it receives. This channel stays open for the next few examples: ``` r ch <- mizu_channel( r"( import pymizu while True: x = ch.recv(30) if pymizu.is_sentinel(x): break ch.send(x) )", launcher = mizu_py_launcher() ) # 1.36 million waiting times, ten megabytes big <- rep(faithful$waiting, 5000) mizu_send(ch, big) back <- mizu_recv(ch, timeout = 5) identical(back, big) #> [1] TRUE ``` Apart from the one write into the shared region, no data moved. Python’s echo came back as a reference, with no payload bytes. If you send a received view onward, it crosses the same way, as the region’s name alone. So does an unmodified data frame echoed back. The zero-copy tier covers every large value: a plain vector (no attributes), a matrix or array, row- or column-major, a character vector, or a nested Python tree of lists, dicts, and arrays. A large data frame crosses as one shared region in both directions. The floor is 256 KB for a plain vector on a channel, and 32 KB everywhere else.[^1] ## What crosses R’s built-in datasets illustrate the range of what crosses. Each form below comes back from Python `identical()` to what you sent, down to values, shape, class, and labels. ``` r echo <- function(x) { mizu_send(ch, x) mizu_recv(ch, timeout = 5) } identical(echo(mtcars), mtcars) # a data frame #> [1] TRUE identical(echo(iris), iris) # a factor column stays a factor #> [1] TRUE identical(echo(state.x77), state.x77) # a matrix keeps its dimnames #> [1] TRUE identical(echo(HairEyeColor), HairEyeColor) # a table keeps class and labels #> [1] TRUE identical(echo(islands), islands) # a named vector keeps its names #> [1] TRUE identical(echo(esoph), esoph) # ordered factors keep their order #> [1] TRUE days <- as.Date(paste(1973, airquality$Month, airquality$Day, sep = "-")) identical(echo(days), days) # a Date keeps its meaning #> [1] TRUE ``` Python sees the natural equivalent throughout: a NumPy array for a vector, a dict for a named list, a `datetime64` array for a `Date`, a `pymizu.Frame` for a data frame. ## What doesn’t cross Some values have no equivalent on the other side. `mizu_send()` declines them with a `mizu_error_not_portable` error that points at the offending value, and the channel stays usable. Among the built-in datasets, `CO2` and `ChickWeight` cross once their groupedData metadata is dropped. Their `formula` attributes are language objects, and the `units` attribute name is reserved by the interchange format. The frames themselves, their ordered-factor columns, and the `labels` attribute cross intact. ``` r tryCatch(mizu_send(ch, CO2), error = conditionMessage) #> [1] "mizu: value is not portable to the peer (an attribute set (formula, outer, labels) outside the portable whitelist at x)" mizu_close(ch) ``` `mizu_send()` also declines a `POSIXlt` time, a character matrix, an S4 object, a data.table, and a named character vector or named factor (names ride on the plain atomic vectors only). From the Python side, the declined types are nested, decimal, `date64`, `time`, and `interval` Arrow columns. ## Data frames and Arrow A data frame crosses as a `pymizu.Frame`, which `pl.from_arrow(f)` or `pa.table(f)` converts to polars or pyarrow. A polars, pyarrow, or pandas frame arrives back as a `data.frame`. A tibble’s class and extra attributes cross unchanged, so an unmodified round trip is `identical()` and a tibble comes back as a tibble. Any Arrow producer is staged directly from its buffers: an `arrow` Table or RecordBatchReader, a struct-typed `nanoarrow_array_stream`, `DBI::dbGetQueryArrow()`, duckdb, or adbc. Staging is a single write into the shared region, not a conversion to R values first. `mizu_as_arrow()` converts a received frame back to Arrow. Columns without attributes (double, integer, `integer64`, character) convert without copying: ``` r ch <- mizu_channel(quote(mizu_send(ch, mizu_recv(ch)))) mizu_send(ch, arrow::arrow_table(data.frame( eruptions = rep(faithful$eruptions, 500), waiting = rep(faithful$waiting, 500) ))) df <- mizu_recv(ch, timeout = 30) arrow::as_arrow_table(mizu_as_arrow(df)) #> Table #> 136000 rows x 2 columns #> $eruptions #> $waiting # a subset keeps the same backing pages arrow::as_arrow_table(mizu_as_arrow(df["waiting"])) #> Table #> 136000 rows x 1 columns #> $waiting mizu_close(ch) ``` Sending a stream consumes it, since a RecordBatchReader is fully materialized at send. The values survive exactly, but the Arrow schema does not. A dictionary returns as a factor, and unsigned integers widen. The [dtype matrix](https://shikokuchuo.net/pymizu/user-guide/interop.html#the-dtype-matrix) on the pymizu site has the exact per-dtype correspondence. ## Mixed-language pools Channels move values. Pools move computation, and a pool’s workers can be Python processes too. `mizu_py_launcher()` spawns the workers. `mizu_call()` builds a language-neutral task: a qualified `"mod.fn"` name or a `.source =` string, plus constant arguments. `mizu_submit_call()` submits it. Unnamed arguments become Python’s positional arguments, and named ones become its keyword arguments: ``` r p <- mizu_pool(2L, launcher = mizu_py_launcher()) t <- mizu_submit_call( p, mizu_call("numpy.quantile", faithful$eruptions, q = c(0.25, 0.5, 0.75)) ) mizu_collect(t) #> [1] 2.16275 4.00000 4.45425 quantile(faithful$eruptions, c(0.25, 0.5, 0.75)) # R's own answer #> 25% 50% 75% #> 2.16275 4.00000 4.45425 s <- mizu_submit_call(p, mizu_call(.source = "y = x * 2\ny + 1", x = 20)) mizu_collect(s) #> [1] 41 ``` A `.source =` task runs in a fresh namespace with the named arguments bound as names. The trailing expression’s value is the result. A plain `mizu_submit()` on a Python pool fails locally, and the error points to `mizu_submit_call()`. Task errors cross as `mizu_error_remote` conditions that keep the remote type. Arguments follow the same rules as channel payloads, zero-copy included. A large array crosses as a shared view, and a view already in shared memory crosses as its name alone. The reverse direction works the same way: `pymizu.Pool.create()` with `pymizu.r_launcher()`, and `Pool.submit()` with `pymizu.call()` specs. ## The cross-language map To map a Python function over an R vector, pass a `mizu_call()` spec as `mizu_map()`’s `.f`. Each element of `.x` becomes the spec’s first positional argument, or binds as `x` for a `.source =` spec. `.template`, `.collect = "view"`, and `.seed` work as they do on a same-language map: ``` r m <- mizu_map( p, faithful$waiting[1:6], mizu_call("numpy.log"), .template = numeric(1) ) mizu_collect(m) #> [1] 4.369448 3.988984 4.304065 4.127134 4.442651 4.007333 mizu_pool_stop(p) ``` [^1]: Below the floor a value crosses as one quick copy. At any size, these types cross: atomic vectors (logical, integer, double, complex, character, raw, and `integer64`, bit64’s exact layout constructed without bit64), strings (`NA_character_` reads as Python `None`, and `None` arrives as `NULL`), lists (a named list becomes a dict), and `Date`, `POSIXct`, and `difftime`. A few round trips change the container but not the values. A standalone factor returns as character strings, a length-1 `NA` returns as `NULL`, and a logical vector with missing values returns as integer. A `POSIXct` timezone becomes `"UTC"`, and `difftime` units become seconds. The bit patterns `INT32_MIN` and `INT64_MIN` collide with R’s own `NA` sentinel and read as `NA`. A `uint64` past 2^63 - 1 crosses as `NA` with a warning at send (in range, it converts exactly to `integer64`).