Metadata-Version: 2.4
Name: locutor
Version: 0.1.0
Summary: Call R and Julia from Python, as a function in memory or as a script.
Author-email: Pedro Henrique Coimbra <pedro-henrique.herig-coimbra@inrae.fr>
License-Expression: EUPL-1.2
Keywords: R,Julia,interop,runner,subprocess
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: R
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENCE
Requires-Dist: numpy>=1.24
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# locutor

Call R and Julia from Python: a function in memory, or a script in a process.

```python
import locutor

# a function: one persistent child per language, nothing written to disk
rotated = locutor.call("methods/rotation.R:double_rotation", u, v, w)
total = locutor.call("stats.jl:total", series, skip_missing=True)

# a script: a process of its own, arguments on its command line
locutor.run("fetch.R", "2026-11-13", out="data/raw.csv")
```

The name is the Latin for *one who speaks*. It is the sibling of
[`regorator`](https://pypi.org/project/regorator/), which
registers, and of `ordinator`, which orders: this one speaks to the languages
the other two are not written in.

## What it is for

A function written in R or Julia that you want to call from Python **many
times, with arrays, and get the same numbers back** — without embedding the
other language in the Python process.

- **In memory.** One child per language is started at the first call and kept.
  No file is written and no process started for a call.
- **Exact.** A double is never printed and parsed. Every float crosses as its
  64 bits, so a NaN arrives as it left, payload included, and R's `NA` is still
  `NA` going back.
- **Separate.** The other language runs in its own process. A crash there is an
  exception here, and its threads, signal handlers and C libraries never meet
  Python's.
- **No dependency on either language.** `numpy` is the only requirement. R
  needs the `jsonlite` package; Julia needs nothing. A language that is not
  installed is refused at its first call, with what to install.

And the plain case beside it: a script that takes a command line and exits.

## Install

```
pip install locutor
```

or, from a checkout, `pip install -e .`.

Python >= 3.9 and `numpy`. `Rscript` and `julia` are looked for on PATH.

## Calling a function

`locutor.call("path/file.ext:function", *args, **kwargs)` picks the language by
the file's extension and uses that language's shared session.

```python
import numpy as np, locutor

x = np.arange(4.0)
locutor.call("lib.R:twice", x, by=3)      # array([0., 3., 6., 9.])
locutor.call("lib.jl:twice", x, by=3)     # the same, from Julia
locutor.call("lib.py:twice", x, by=3)     # the same, imported in this process
```

The child reads a file once, into a namespace of its own, so two files may
define a function of one name. Keyword arguments are keyword arguments in
Julia and named arguments in R.

For a package's function, a file of helpers, or another interpreter than the
one on PATH, configure the language and call that:

```python
with locutor.R(packages=["stats"], include="helpers.R") as r:
    r.call("stats::median", x)            # a package's function
    r.call("my_helper", x, window=5)      # a function helpers.R defines

jl = locutor.Julia(program="/opt/julia-1.11/bin/julia", packages=["Statistics"])
jl.call("Statistics.mean", x)
jl.close()
```

Building one starts nothing; its child starts at the first call and ends at
`close()`, at the end of the `with`, or when Python exits.

### What crosses

| Python | R | Julia |
| --- | --- | --- |
| a float | a numeric vector of one | a `Float64` |
| a numeric array | a numeric vector | a `Vector{Float64}` |
| an N-d array | a matrix or array | a `Matrix` / `Array` |
| an int, a bool, a str | an integer, a logical, a character | an `Int`, a `Bool`, a `String` |
| `None` | `NULL` | `nothing` |
| a list of one kind of those | the vector of that kind | the vector of that kind |
| a dict | a named list | a named tuple |
| any other list | a list | a `Vector{Any}` |

Coming back, a named list or named tuple is a Python list that carries
`.names`; a matrix keeps its shape; `NA`, `NaN` and Julia's `missing` in a
float array are all NaN. Anything else — a function, a struct, a DataFrame, an
xarray object going the other way — is refused by name rather than converted
to whatever its values happen to coerce to.

### Errors and output

- `stop()` in R or an exception in Julia is a `RuntimeError` here with the
  other side's message, and the session answers the next call.
- A child that *ends* — a crash, a `quit()` — is a `ChildProcessError`, and the
  next call starts another.
- What the called code prints, on either stream, goes to the `locutor` logger
  at `INFO`, and never into an answer.

## Running a script

`locutor.run(script, *arguments, **options)` runs it with the interpreter its
extension names and waits. Positional arguments go on the command line as they
are; keyword options go as `--key value` (`True` is the bare flag, a list is
the key followed by its values).

```python
done = locutor.run("report.jl", "FR-Gri", year=2022, sites=["a", "b"])
done.returncode, done.stdout, done.stderr
```

A status other than zero raises `RuntimeError` with the end of the script's
error stream; `check=False` returns the `Result` instead. `capture=False`
leaves the script's output on this process's streams. `cwd`, `env` and
`timeout` are passed through.

## From a shell

```
locutor languages
locutor run script.R 2026-11-13 --out data/raw.csv
locutor call stats.jl:total "[1, 2.5, 4]" --skip_missing true
```

`run` hands everything after the script to the script, untouched, and exits
with its status. `call` reads each value as JSON where it is JSON and as text
where it is not, and prints the answer as JSON.

## How it works

A session is one child process (`Rscript`, `julia`) running
`locutor/servers/session.R` or `session.jl`. A request is one JSON line on its
input and the answer one JSON line on its output; the doubles of both travel as
raw little-endian float64 on one loopback TCP connection the child opens back
when it starts. The lines and the doubles go separate ways because the child's
output is also where the called code prints: an answer line is marked, and raw
doubles on that stream would be corrupted by the first `print`. The connection
is `127.0.0.1` only, and is taken only from the child that repeats a token it
was handed on its input.

Measured on Linux (R 4.5.3, Julia 1.11.7), one 36 000-sample series there and
back: 0.16 ms for R and 0.09 ms for Julia, against 0.17–0.25 ms for rpy2 and
0.05 ms for juliacall embedded in the interpreter. Starting the child takes
about 0.1 s for R and about 1 s for Julia including its first compilation.

Adding a language is one module in `locutor/languages/` (what it is called, how
it is started, how a package is installed) and one file in `locutor/servers/`
that holds its end of the conversation.

## What it is not

- **Not a workflow tool.** It does not order steps, read a configuration, or
  record what ran. `ordinator` does that, and is meant to call this.
- **Not an embedding.** For sharing objects between languages without copying,
  or calling back into Python from R or Julia, use rpy2 or juliacall.
- **Not tested on Windows or macOS yet.** Everything here was run on Linux.

## Tests

```
python -m pytest
```

The tests that need R or Julia run once per language installed and skip the
other, saying what to install.

## Licence

EUPL-1.2. See [`LICENCE`](LICENCE).
