Where do I add the Python code?
One option is to place the scripts in the priv
folder. According to the Phoenix directory structure docs, this folder is intended for resources that are needed in production but are not directly part of the application’s source code. Because these scripts fit that description, the priv
folder is a sensible location—feel free to choose a different path if you prefer.
How can I run it?
I tested two approaches, each with its own trade-offs. Choose the one that best suits your use case. Important: To capture any output from the Python script in Elixir, your script must print
its result.
The first approach uses System.cmd/3
:
defmodule MyModule
@python_dir Application.app_dir(:my_app, ["priv", "python_code"])
def run_script(args) do
case System.cmd(
"bash",
[
"-c",
"python3 code.py '#{prepare_args_for_script(args)}'"
],
cd: @python_dir,
env: [{"PYTHONPATH", System.find_executable("python3")}]
) do
{result, 0} ->
result
{reason, _} ->
{:error, reason}
end
end
defp prepare_args_for_script(args) do
Jason.encode!(%{
name: args.my_name,
age: args.my_age,
dream: args.my_dream
})
end
end
-
System.cmd/3
executes the specified command with the given arguments. -
You can pass additional data to the script—in this example, a JSON payload—so I encode it with
prepare_args_for_script/1
. -
The
cd
option sets the working directory where the Python code resides. -
The
env
option defines environment variables for the script.
This command runs when run_script/1
is called. In the case … do
block, the {result, 0}
clause handles a successful execution; any other exit status is treated as an error.
The second approach uses Erlang Ports with a GenServer:
defmodule MyServer do
use GenServer
require Logger
@python_dir Application.app_dir(:my_app, ["priv", "python_code"])
def start_link(_args) do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def run_external_script do
GenServer.cast(__MODULE__, :run_external_script)
end
@impl true
def init(_args) do
Process.flag(:trap_exit, true)
{:ok, nil}
end
@impl true
def handle_cast(:run_external_script, state) do
python_executable = System.find_executable("python3")
port =
Port.open({:spawn_executable, System.find_executable("bash")}, [
:binary,
{:args,
[
"-c",
"python3.11 code.py '#{prepare_args_for_script(args)}'"
]},
{:cd, ~c"#{@python_dir}"},
{:env, [{~c"PYTHONPATH", ~c"#{python_executable}"}]}
])
{:noreply, port}
end
@impl true
def handle_info({port, {:data, result}}, state) do
# `result` is the script’s output; handle it as needed
{:noreply, state}
end
@impl true
def handle_info({:EXIT, port, reason}, _state) do
Logger.warning("Script exited: #{inspect(reason)}")
{:noreply, nil}
end
end
- This implementation uses a GenServer to manage the external process.
- It opens a port to run the script and stores the port in the GenServer state.
-
Whenever the script emits data, the
handle_info/2
callback receives it. -
We use
System.find_executable/1
to locatebash
. -
The
~c
sigil produces Erlang charlists, which are required by Ports for:cd
and:env
settings.
The main advantage of the Port-based version is asynchronous execution, but it is more complex. Choose the approach that best matches your requirements.