Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Pythonx.eval/3 options for customizing stdout and stderr destination #5

Merged
merged 1 commit into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion c_src/pythonx/pythonx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1378,7 +1378,7 @@ extern "C" void pythonx_handle_io_write(const char *message,
enif_free_env(env);
} else {
std::cerr << "[pythonx] whereis(Pythonx.Janitor) failed. This is "
"unexpected and an output ill be dropped"
"unexpected and an output will be dropped"
<< std::endl;
}
}
22 changes: 18 additions & 4 deletions lib/pythonx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ defmodule Pythonx do
> those releases the GIL. GIL is also released when waiting on I/O
> operations.

## Options

* `:stdout_device` - IO process to send Python stdout output to.
Defaults to the caller's group leader.

* `:stderr_device` - IO process to send Python stderr output to.
Defaults to the global `:standard_error`.

## Examples

iex> {result, globals} =
Expand Down Expand Up @@ -201,9 +209,12 @@ defmodule Pythonx do
>

'''
@spec eval(String.t(), %{optional(String.t()) => term()}) ::
@spec eval(String.t(), %{optional(String.t()) => term()}, keyword()) ::
{Object.t() | nil, %{optional(String.t()) => Object.t()}}
def eval(code, globals) do
def eval(code, globals, opts \\ [])
when is_binary(code) and is_map(globals) and is_list(opts) do
opts = Keyword.validate!(opts, [:stdout_device, :stderr_device])

globals =
for {key, value} <- globals do
if not is_binary(key) do
Expand All @@ -214,8 +225,11 @@ defmodule Pythonx do
end

code_md5 = :erlang.md5(code)
stdout_device = Process.group_leader()
stderr_device = Process.whereis(:standard_error)

stdout_device = Keyword.get_lazy(opts, :stdout_device, fn -> Process.group_leader() end)

stderr_device =
Keyword.get_lazy(opts, :stderr_device, fn -> Process.whereis(:standard_error) end)

result = Pythonx.NIF.eval(code, code_md5, globals, stdout_device, stderr_device)

Expand Down
30 changes: 30 additions & 0 deletions test/pythonx_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,36 @@ defmodule PythonxTest do
end) =~ "error from Python\n"
end

test "sends standard output and error to custom processes when specified" do
{:ok, io} = StringIO.open("")

Pythonx.eval(
"""
import sys
import threading

print("hello from Python")
print("error from Python", file=sys.stderr)

def run():
print("hello from thread")

thread = threading.Thread(target=run)
thread.start()
thread.join()
""",
%{},
stdout_device: io,
stderr_device: io
)

{:ok, {_, output}} = StringIO.close(io)

assert output =~ "hello from Python"
assert output =~ "error from Python"
assert output =~ "hello from thread"
end

test "raises Python error on stdin attempt" do
assert_raise Pythonx.Error, ~r/RuntimeError: stdin not supported/, fn ->
Pythonx.eval(
Expand Down
Loading