---
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 <double>
#> $waiting <double>

# a subset keeps the same backing pages
arrow::as_arrow_table(mizu_as_arrow(df["waiting"]))
#> Table
#> 136000 rows x 1 columns
#> $waiting <double>
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`).
