Skip to content

Commit 1398ea9

Browse files
Add Python API for sending messages to Elixir (#38)
1 parent 51e5cfb commit 1398ea9

File tree

6 files changed

+230
-23
lines changed

6 files changed

+230
-23
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,46 @@ 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_tagged_object(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+
> #### Long-running evaluation {: .warning}
166+
>
167+
> If you are sending messages from Python to Elixir, it likely means
168+
> you have a long-running Python evaluation. If the evaluation holds
169+
> onto GIL for long, you should make sure to only do it from a single
170+
> Elixir process to avoid bottlenecks. For more details see the
171+
> "Concurrency" notes in `Pythonx.eval/3`.
172+
173+
> #### Decoding {: .warning}
174+
>
175+
> The Elixir process receives a `Pythonx.Object`, which you may want
176+
> to decode right away. Keep in mind that `Pythonx.decode/1` requires
177+
> GIL, so if the ongoing evaluation holds onto GIL for long, decoding
178+
> itself may be blocked.
179+
180+
**Parameters:**
181+
182+
- `pid` (`pythonx.PID`) – Opaque PID object, passed into the evaluation.
183+
- `tag` (`str`) – A tag appearning as atom in the Elixir message.
184+
- `object` (`Any`) – Any Python object to be sent as the message.
185+
186+
### `pythonx.PID`
187+
188+
Opaque Python object that represents an Elixir PID.
189+
190+
This object cannot be created within Python, it needs to be passed
191+
into the evaluation as part of globals.
192+
153193
## How it works
154194

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

c_src/pythonx.cpp

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

18+
extern "C" void
19+
pythonx_handle_send_tagged_object(const char *pid_bytes, const char *tag,
20+
pythonx::python::PyObjectPtr *py_object,
21+
const char *eval_info_bytes);
22+
1823
namespace pythonx {
1924

2025
using namespace python;
@@ -385,36 +390,46 @@ import ctypes
385390
import io
386391
import sys
387392
import inspect
393+
import types
394+
import sys
388395
389396
pythonx_handle_io_write = ctypes.CFUNCTYPE(
390397
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool
391398
)(pythonx_handle_io_write_ptr)
392399
400+
pythonx_handle_send_tagged_object = ctypes.CFUNCTYPE(
401+
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.py_object, ctypes.c_char_p
402+
)(pythonx_handle_send_tagged_object_ptr)
403+
404+
405+
def get_eval_info_bytes():
406+
# The evaluation caller has __pythonx_eval_info_bytes__ set in
407+
# their globals. It is not available in globals() here, because
408+
# the globals dict in function definitions is fixed at definition
409+
# time. To find the current evaluation globals, we look at the
410+
# call stack using the inspect module and find the caller with
411+
# __pythonx_eval_info_bytes__ in globals. We look specifically
412+
# for the outermost caller, because intermediate functions could
413+
# be defined by previous evaluations, in which case they would
414+
# have __pythonx_eval_info_bytes__ in their globals, corresponding
415+
# to that previous evaluation. When called within a thread, the
416+
# evaluation caller is not in the stack, so __pythonx_eval_info_bytes__
417+
# will be found in the thread entrypoint function globals.
418+
call_stack = inspect.stack()
419+
eval_info_bytes = next(
420+
frame_info.frame.f_globals["__pythonx_eval_info_bytes__"]
421+
for frame_info in reversed(call_stack)
422+
if "__pythonx_eval_info_bytes__" in frame_info.frame.f_globals
423+
)
424+
return eval_info_bytes
425+
393426
394427
class Stdout(io.TextIOBase):
395428
def __init__(self, type):
396429
self.type = type
397430
398431
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)
432+
pythonx_handle_io_write(string.encode("utf-8"), get_eval_info_bytes(), self.type)
418433
return len(string)
419434
420435
@@ -426,6 +441,24 @@ class Stdin(io.IOBase):
426441
sys.stdout = Stdout(0)
427442
sys.stderr = Stdout(1)
428443
sys.stdin = Stdin()
444+
445+
pythonx = types.ModuleType("pythonx")
446+
447+
class PID:
448+
def __init__(self, bytes):
449+
self.bytes = bytes
450+
451+
def __repr__(self):
452+
return "<pythonx.PID>"
453+
454+
pythonx.PID = PID
455+
456+
def send_tagged_object(pid, tag, object):
457+
pythonx_handle_send_tagged_object(pid.bytes, tag.encode("utf-8"), object, get_eval_info_bytes())
458+
459+
pythonx.send_tagged_object = send_tagged_object
460+
461+
sys.modules["pythonx"] = pythonx
429462
)";
430463

431464
auto py_code = PyUnicode_FromStringAndSize(code, sizeof(code) - 1);
@@ -449,6 +482,16 @@ sys.stdin = Stdin()
449482
"pythonx_handle_io_write_ptr",
450483
py_pythonx_handle_io_write_ptr));
451484

485+
auto py_pythonx_handle_send_tagged_object_ptr = PyLong_FromUnsignedLongLong(
486+
reinterpret_cast<uintptr_t>(pythonx_handle_send_tagged_object));
487+
raise_if_failed(env, py_pythonx_handle_send_tagged_object_ptr);
488+
auto py_pythonx_handle_send_tagged_object_ptr_guard =
489+
PyDecRefGuard(py_pythonx_handle_send_tagged_object_ptr);
490+
491+
raise_if_failed(env, PyDict_SetItemString(
492+
py_globals, "pythonx_handle_send_tagged_object_ptr",
493+
py_pythonx_handle_send_tagged_object_ptr));
494+
452495
auto py_exec_args = PyTuple_Pack(2, py_code, py_globals);
453496
raise_if_failed(env, py_exec_args);
454497
auto py_exec_args_guard = PyDecRefGuard(py_exec_args);
@@ -699,6 +742,37 @@ fine::Ok<> set_add(ErlNifEnv *env, ExObject ex_object, ExObject ex_key) {
699742

700743
FINE_NIF(set_add, ERL_NIF_DIRTY_JOB_CPU_BOUND);
701744

745+
ExObject pid_new(ErlNifEnv *env, ErlNifPid pid) {
746+
ensure_initialized();
747+
auto gil_guard = PyGILGuard();
748+
749+
// ErlNifPid is self-contained struct, not bound to any env, so it's
750+
// safe to copy [1].
751+
//
752+
// [1]: https://www.erlang.org/doc/apps/erts/erl_nif.html#ErlNifPid
753+
auto py_pid_bytes = PyBytes_FromStringAndSize(
754+
reinterpret_cast<const char *>(&pid), sizeof(ErlNifPid));
755+
raise_if_failed(env, py_pid_bytes);
756+
757+
auto py_pythonx = PyImport_AddModule("pythonx");
758+
raise_if_failed(env, py_pythonx);
759+
760+
auto py_PID = PyObject_GetAttrString(py_pythonx, "PID");
761+
raise_if_failed(env, py_PID);
762+
auto py_PID_guard = PyDecRefGuard(py_PID);
763+
764+
auto py_PID_args = PyTuple_Pack(1, py_pid_bytes);
765+
raise_if_failed(env, py_PID_args);
766+
auto py_PID_args_guard = PyDecRefGuard(py_PID_args);
767+
768+
auto py_pid = PyObject_Call(py_PID, py_PID_args, NULL);
769+
raise_if_failed(env, py_pid);
770+
771+
return ExObject(fine::make_resource<ExObjectResource>(py_pid));
772+
}
773+
774+
FINE_NIF(pid_new, ERL_NIF_DIRTY_JOB_CPU_BOUND);
775+
702776
ExObject object_repr(ErlNifEnv *env, ExObject ex_object) {
703777
ensure_initialized();
704778
auto gil_guard = PyGILGuard();
@@ -962,6 +1036,31 @@ fine::Term decode_once(ErlNifEnv *env, ExObject ex_object) {
9621036
std::make_tuple(atoms::map_set, fine::Term(items)));
9631037
}
9641038

1039+
auto py_pythonx = PyImport_AddModule("pythonx");
1040+
raise_if_failed(env, py_pythonx);
1041+
1042+
auto py_PID = PyObject_GetAttrString(py_pythonx, "PID");
1043+
raise_if_failed(env, py_PID);
1044+
auto py_PID_guard = PyDecRefGuard(py_PID);
1045+
1046+
auto is_pid = PyObject_IsInstance(py_object, py_PID);
1047+
raise_if_failed(env, is_pid);
1048+
if (is_pid) {
1049+
auto py_pid_bytes = PyObject_GetAttrString(py_object, "bytes");
1050+
raise_if_failed(env, py_pid_bytes);
1051+
auto py_pid_bytes_guard = PyDecRefGuard(py_pid_bytes);
1052+
1053+
Py_ssize_t size;
1054+
char *pid_bytes;
1055+
auto result = PyBytes_AsStringAndSize(py_pid_bytes, &pid_bytes, &size);
1056+
raise_if_failed(env, result);
1057+
1058+
auto pid = ErlNifPid{};
1059+
std::memcpy(&pid, pid_bytes, sizeof(ErlNifPid));
1060+
1061+
return fine::encode(env, pid);
1062+
}
1063+
9651064
// None of the built-ins, return %Pythonx.Object{} as is
9661065
return fine::encode(env, ex_object);
9671066
}
@@ -1368,16 +1467,16 @@ FINE_INIT("Elixir.Pythonx.NIF");
13681467

13691468
// Below are functions we call from Python code
13701469

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

1379-
auto env = enif_alloc_env();
1476+
return eval_info;
1477+
}
13801478

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

1489+
return caller_env;
1490+
}
1491+
1492+
extern "C" void pythonx_handle_io_write(const char *message,
1493+
const char *eval_info_bytes,
1494+
bool type) {
1495+
auto eval_info = eval_info_from_bytes(eval_info_bytes);
1496+
1497+
auto env = enif_alloc_env();
1498+
auto caller_env = get_caller_env(eval_info);
1499+
13901500
// Note that we send the output to Pythonx.Janitor and it then sends
13911501
// it to the device. We do this to avoid IO replies being sent to
13921502
// the calling Elixir process (which would be unexpected). Additionally,
@@ -1406,3 +1516,24 @@ extern "C" void pythonx_handle_io_write(const char *message,
14061516
<< std::endl;
14071517
}
14081518
}
1519+
1520+
extern "C" void
1521+
pythonx_handle_send_tagged_object(const char *pid_bytes, const char *tag,
1522+
pythonx::python::PyObjectPtr *py_object,
1523+
const char *eval_info_bytes) {
1524+
auto eval_info = eval_info_from_bytes(eval_info_bytes);
1525+
1526+
auto caller_env = get_caller_env(eval_info);
1527+
auto env = enif_alloc_env();
1528+
1529+
auto pid = ErlNifPid{};
1530+
std::memcpy(&pid, pid_bytes, sizeof(ErlNifPid));
1531+
1532+
auto msg = fine::encode(
1533+
env, std::make_tuple(
1534+
fine::Atom(tag),
1535+
pythonx::ExObject(
1536+
fine::make_resource<pythonx::ExObjectResource>(py_object))));
1537+
enif_send(caller_env, &pid, env, msg);
1538+
enif_free_env(env);
1539+
}

lib/pythonx.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ defmodule Pythonx do
454454
* `dict`
455455
* `set`
456456
* `frozenset`
457+
* `pythonx.PID`
457458
458459
For all other types `Pythonx.Object` is returned.
459460

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: 28 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
@@ -132,6 +136,12 @@ defmodule PythonxTest do
132136
assert Pythonx.decode(eval_result("frozenset({1})")) == MapSet.new([1])
133137
end
134138

139+
test "pid" do
140+
pid = IEx.Helpers.pid(0, 1, 2)
141+
assert {result, %{}} = Pythonx.eval("pid", %{"pid" => pid})
142+
assert Pythonx.decode(result) == pid
143+
end
144+
135145
test "identity for other objects" do
136146
assert repr(Pythonx.decode(eval_result("complex(1)"))) == "(1+0j)"
137147
end
@@ -449,6 +459,24 @@ defmodule PythonxTest do
449459
end
450460
end
451461

462+
describe "python API" do
463+
test "pythonx.send sends message to the given pid" do
464+
pid = self()
465+
466+
assert {_result, %{}} =
467+
Pythonx.eval(
468+
"""
469+
import pythonx
470+
pythonx.send_tagged_object(pid, "message_from_python", ("hello", 1))
471+
""",
472+
%{"pid" => pid}
473+
)
474+
475+
assert_receive {:message_from_python, %Pythonx.Object{} = object}
476+
assert repr(object) == "('hello', 1)"
477+
end
478+
end
479+
452480
defp repr(object) do
453481
assert %Pythonx.Object{} = object
454482

0 commit comments

Comments
 (0)