Skip to content

Commit 12bfaf1

Browse files
committed
Add Python API for sending messages to Elixir
1 parent 51e5cfb commit 12bfaf1

5 files changed

Lines changed: 181 additions & 23 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,31 @@ Note that currently the `~PY` sigil does not work as part of Mix project
150150
code. This limitation is intentional, since in actual applications it
151151
is preferable to manage the Python globals explicitly.
152152

153+
## Python API
154+
155+
Pythonx provides a Python module named `pythonx` with extra interoperability
156+
features.
157+
158+
### `pythonx.send(pid, tag, object)`
159+
160+
Sends a Python object to an Elixir process identified by `pid`.
161+
162+
The Elixir process receives the message as a `{tag, object}` tuple,
163+
where `tag` is an atom and `object` is a `Pythonx.Object` struct.
164+
165+
**Parameters:**
166+
167+
- `pid` (`pythonx.PID`) – Opaque PID object, passed into the evaluation.
168+
- `tag` (`str`) – A tag appearning as atom in the Elixir message.
169+
- `object` (`Any`) – Any Python object to be sent as the message.
170+
171+
### `pythonx.PID`
172+
173+
Opaque Python object that represents an Elixir PID.
174+
175+
This object cannot be created within Python, it needs to be passed
176+
into the evaluation as part of globals.
177+
153178
## How it works
154179

155180
[CPython](https://github.com/python/cpython) (the reference

c_src/pythonx.cpp

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
extern "C" void pythonx_handle_io_write(const char *message,
1616
const char *eval_info_bytes, bool type);
1717

18+
extern "C" void pythonx_handle_send(const char *pid_bytes, const char *tag,
19+
pythonx::python::PyObjectPtr *py_object,
20+
const char *eval_info_bytes);
21+
1822
namespace pythonx {
1923

2024
using namespace python;
@@ -385,36 +389,46 @@ import ctypes
385389
import io
386390
import sys
387391
import inspect
392+
import types
393+
import sys
388394
389395
pythonx_handle_io_write = ctypes.CFUNCTYPE(
390396
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool
391397
)(pythonx_handle_io_write_ptr)
392398
399+
pythonx_handle_send = ctypes.CFUNCTYPE(
400+
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.py_object, ctypes.c_char_p
401+
)(pythonx_handle_send_ptr)
402+
403+
404+
def get_eval_info_bytes():
405+
# The evaluation caller has __pythonx_eval_info_bytes__ set in
406+
# their globals. It is not available in globals() here, because
407+
# the globals dict in function definitions is fixed at definition
408+
# time. To find the current evaluation globals, we look at the
409+
# call stack using the inspect module and find the caller with
410+
# __pythonx_eval_info_bytes__ in globals. We look specifically
411+
# for the outermost caller, because intermediate functions could
412+
# be defined by previous evaluations, in which case they would
413+
# have __pythonx_eval_info_bytes__ in their globals, corresponding
414+
# to that previous evaluation. When called within a thread, the
415+
# evaluation caller is not in the stack, so __pythonx_eval_info_bytes__
416+
# will be found in the thread entrypoint function globals.
417+
call_stack = inspect.stack()
418+
eval_info_bytes = next(
419+
frame_info.frame.f_globals["__pythonx_eval_info_bytes__"]
420+
for frame_info in reversed(call_stack)
421+
if "__pythonx_eval_info_bytes__" in frame_info.frame.f_globals
422+
)
423+
return eval_info_bytes
424+
393425
394426
class Stdout(io.TextIOBase):
395427
def __init__(self, type):
396428
self.type = type
397429
398430
def write(self, string):
399-
# The evaluation caller has __pythonx_eval_info_bytes__ set in
400-
# their globals. It is not available in globals() here, because
401-
# the globals dict in function definitions is fixed at definition
402-
# time. To find the current evaluation globals, we look at the
403-
# call stack using the inspect module and find the caller with
404-
# __pythonx_eval_info_bytes__ in globals. We look specifically
405-
# for the outermost caller, because intermediate functions could
406-
# be defined by previous evaluations, in which case they would
407-
# have __pythonx_eval_info_bytes__ in their globals, corresponding
408-
# to that previous evaluation. When called within a thread, the
409-
# evaluation caller is not in the stack, so __pythonx_eval_info_bytes__
410-
# will be found in the thread entrypoint function globals.
411-
call_stack = inspect.stack()
412-
eval_info_bytes = next(
413-
frame_info.frame.f_globals["__pythonx_eval_info_bytes__"]
414-
for frame_info in reversed(call_stack)
415-
if "__pythonx_eval_info_bytes__" in frame_info.frame.f_globals
416-
)
417-
pythonx_handle_io_write(string.encode("utf-8"), eval_info_bytes, self.type)
431+
pythonx_handle_io_write(string.encode("utf-8"), get_eval_info_bytes(), self.type)
418432
return len(string)
419433
420434
@@ -426,6 +440,24 @@ class Stdin(io.IOBase):
426440
sys.stdout = Stdout(0)
427441
sys.stderr = Stdout(1)
428442
sys.stdin = Stdin()
443+
444+
pythonx = types.ModuleType("pythonx")
445+
446+
class PID:
447+
def __init__(self, bytes):
448+
self.bytes = bytes
449+
450+
def __repr__(self):
451+
return "<pythonx.PID>"
452+
453+
pythonx.PID = PID
454+
455+
def send(pid, tag, object):
456+
pythonx_handle_send(pid.bytes, tag.encode("utf-8"), object, get_eval_info_bytes())
457+
458+
pythonx.send = send
459+
460+
sys.modules["pythonx"] = pythonx
429461
)";
430462

431463
auto py_code = PyUnicode_FromStringAndSize(code, sizeof(code) - 1);
@@ -449,6 +481,16 @@ sys.stdin = Stdin()
449481
"pythonx_handle_io_write_ptr",
450482
py_pythonx_handle_io_write_ptr));
451483

484+
auto py_pythonx_handle_send_ptr = PyLong_FromUnsignedLongLong(
485+
reinterpret_cast<uintptr_t>(pythonx_handle_send));
486+
raise_if_failed(env, py_pythonx_handle_send_ptr);
487+
auto py_pythonx_handle_send_ptr_guard =
488+
PyDecRefGuard(py_pythonx_handle_send_ptr);
489+
490+
raise_if_failed(env,
491+
PyDict_SetItemString(py_globals, "pythonx_handle_send_ptr",
492+
py_pythonx_handle_send_ptr));
493+
452494
auto py_exec_args = PyTuple_Pack(2, py_code, py_globals);
453495
raise_if_failed(env, py_exec_args);
454496
auto py_exec_args_guard = PyDecRefGuard(py_exec_args);
@@ -699,6 +741,37 @@ fine::Ok<> set_add(ErlNifEnv *env, ExObject ex_object, ExObject ex_key) {
699741

700742
FINE_NIF(set_add, ERL_NIF_DIRTY_JOB_CPU_BOUND);
701743

744+
ExObject pid_new(ErlNifEnv *env, ErlNifPid pid) {
745+
ensure_initialized();
746+
auto gil_guard = PyGILGuard();
747+
748+
// ErlNifPid is self-contained struct, not bound to any env, so it's
749+
// safe to copy [1].
750+
//
751+
// [1]: https://www.erlang.org/doc/apps/erts/erl_nif.html#ErlNifPid
752+
auto py_pid_bytes = PyBytes_FromStringAndSize(
753+
reinterpret_cast<const char *>(&pid), sizeof(ErlNifPid));
754+
raise_if_failed(env, py_pid_bytes);
755+
756+
auto py_pythonx = PyImport_AddModule("pythonx");
757+
raise_if_failed(env, py_pythonx);
758+
759+
auto py_PID = PyObject_GetAttrString(py_pythonx, "PID");
760+
raise_if_failed(env, py_PID);
761+
auto py_PID_guard = PyDecRefGuard(py_PID);
762+
763+
auto py_PID_args = PyTuple_Pack(1, py_pid_bytes);
764+
raise_if_failed(env, py_PID_args);
765+
auto py_PID_args_guard = PyDecRefGuard(py_PID_args);
766+
767+
auto py_pid = PyObject_Call(py_PID, py_PID_args, NULL);
768+
raise_if_failed(env, py_pid);
769+
770+
return ExObject(fine::make_resource<ExObjectResource>(py_pid));
771+
}
772+
773+
FINE_NIF(pid_new, ERL_NIF_DIRTY_JOB_CPU_BOUND);
774+
702775
ExObject object_repr(ErlNifEnv *env, ExObject ex_object) {
703776
ensure_initialized();
704777
auto gil_guard = PyGILGuard();
@@ -1368,16 +1441,16 @@ FINE_INIT("Elixir.Pythonx.NIF");
13681441

13691442
// Below are functions we call from Python code
13701443

1371-
extern "C" void pythonx_handle_io_write(const char *message,
1372-
const char *eval_info_bytes,
1373-
bool type) {
1444+
pythonx::EvalInfo eval_info_from_bytes(const char *eval_info_bytes) {
13741445
// Note that we allocate EvalInfo first, so it will have the proper
13751446
// alignment and memcpy simply restores the original struct state.
13761447
auto eval_info = pythonx::EvalInfo{};
13771448
std::memcpy(&eval_info, eval_info_bytes, sizeof(pythonx::EvalInfo));
13781449

1379-
auto env = enif_alloc_env();
1450+
return eval_info;
1451+
}
13801452

1453+
ErlNifEnv *get_caller_env(pythonx::EvalInfo eval_info) {
13811454
// The enif_whereis_pid and enif_send functions require passing the
13821455
// caller env. Stdout write may be called by the evaluated code from
13831456
// the NIF call, but it may also be called by a Python thread, after
@@ -1387,6 +1460,17 @@ extern "C" void pythonx_handle_io_write(const char *message,
13871460
bool is_main_thread = std::this_thread::get_id() == eval_info.thread_id;
13881461
auto caller_env = is_main_thread ? eval_info.env : NULL;
13891462

1463+
return caller_env;
1464+
}
1465+
1466+
extern "C" void pythonx_handle_io_write(const char *message,
1467+
const char *eval_info_bytes,
1468+
bool type) {
1469+
auto eval_info = eval_info_from_bytes(eval_info_bytes);
1470+
1471+
auto env = enif_alloc_env();
1472+
auto caller_env = get_caller_env(eval_info);
1473+
13901474
// Note that we send the output to Pythonx.Janitor and it then sends
13911475
// it to the device. We do this to avoid IO replies being sent to
13921476
// the calling Elixir process (which would be unexpected). Additionally,
@@ -1406,3 +1490,23 @@ extern "C" void pythonx_handle_io_write(const char *message,
14061490
<< std::endl;
14071491
}
14081492
}
1493+
1494+
extern "C" void pythonx_handle_send(const char *pid_bytes, const char *tag,
1495+
pythonx::python::PyObjectPtr *py_object,
1496+
const char *eval_info_bytes) {
1497+
auto eval_info = eval_info_from_bytes(eval_info_bytes);
1498+
1499+
auto caller_env = get_caller_env(eval_info);
1500+
auto env = enif_alloc_env();
1501+
1502+
auto pid = ErlNifPid{};
1503+
std::memcpy(&pid, pid_bytes, sizeof(ErlNifPid));
1504+
1505+
auto msg = fine::encode(
1506+
env, std::make_tuple(
1507+
fine::Atom(tag),
1508+
pythonx::ExObject(
1509+
fine::make_resource<pythonx::ExObjectResource>(py_object))));
1510+
enif_send(caller_env, &pid, env, msg);
1511+
enif_free_env(env);
1512+
}

lib/pythonx/encoder.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,9 @@ defimpl Pythonx.Encoder, for: MapSet do
196196
set
197197
end
198198
end
199+
200+
defimpl Pythonx.Encoder, for: PID do
201+
def encode(term, _encoder) do
202+
Pythonx.NIF.pid_new(term)
203+
end
204+
end

lib/pythonx/nif.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Pythonx.NIF do
3131
def list_set_item(_object, _index, _value), do: err!()
3232
def set_new(), do: err!()
3333
def set_add(_object, _key), do: err!()
34+
def pid_new(_pid), do: err!()
3435
def object_repr(_object), do: err!()
3536
def format_exception(_error), do: err!()
3637
def decode_once(_object), do: err!()

test/pythonx_test.exs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ defmodule PythonxTest do
5959
assert repr(Pythonx.encode!(MapSet.new([1]))) == "{1}"
6060
end
6161

62+
test "pid" do
63+
assert repr(Pythonx.encode!(IEx.Helpers.pid(0, 1, 2))) == "<pythonx.PID>"
64+
end
65+
6266
test "identity for Pythonx.Object" do
6367
object = Pythonx.encode!(1)
6468
assert Pythonx.encode!(object) == object
@@ -449,6 +453,24 @@ defmodule PythonxTest do
449453
end
450454
end
451455

456+
describe "python API" do
457+
test "pythonx.send sends message to the given pid" do
458+
pid = self()
459+
460+
assert {_result, %{}} =
461+
Pythonx.eval(
462+
"""
463+
import pythonx
464+
pythonx.send(pid, "message_from_python", ("hello", 1))
465+
""",
466+
%{"pid" => pid}
467+
)
468+
469+
assert_receive {:message_from_python, %Pythonx.Object{} = object}
470+
assert repr(object) == "('hello', 1)"
471+
end
472+
end
473+
452474
defp repr(object) do
453475
assert %Pythonx.Object{} = object
454476

0 commit comments

Comments
 (0)