Search code examples
pythonerlangelixirphoenix-frameworkgen-server

Erlport/Python STDOUT capture to Elixir


I'm trying to pipe STDOUT from Python/Erlport back to Elixir. I've got :python calls working fine, I just want to send the STDOUT stuff from Python back to Elixir for logging but I can't wrap my head around how to achieve that. I know it's possible even though I'm using Python 2.7.

I've got a Genserver wrapper around the :python module so that my call works like so:

pid = Python.start()
Python.call(pid, :bridge, :register_handler, [self()]) 

Python.call just looks like this:

def call(pid, module, function, args \\ []) do
  :python.call(pid, module, function, args)
end

Anything from :bridge (i.e., bridge.py) is lost to STDOUT unless I explicitly return something (obviously halting the function). What can I do to capture STDOUT?

My idea was to call something like Python.call(pid, :builtins, :print, [self()]) but that results in a bunch of errors and I really don't know if that's the right direction at all.

I actually want to pipe it into a Phoenix channel, but that's the easy part (I hope). Any advice? Thanks.


Solution

  • For anyone else getting stuck with this kinda thing: since I've got a GenServer around the :python instance, I've just leveraged handle_info:

    def handle_info({:python, message}, session) do
      message |> String.split("\n", trim: true)
      SomeWeb.Endpoint.broadcast("log", "update", %{body: message})
    
      {:stop, :normal,  session}
    end
    

    Detail

    To more fully outline my solution as @7stud advised, I'll include the wider approach based on erlport and this great post. Accordingly, I've got a Python module that looks like this:

    defmodule App.Python do
       @doc """
          Python instance pointing to priv/python.
        """
       def start() do
          path = [
             :code.priv_dir(:prefect),
             "python"
          ]|> Path.join()
    
          {:ok, pid} = :python.start([
             {:python_path, to_charlist(path)}
          ])
          pid
       end
    
       def call(pid, module, function, args \\ []) do
          :python.call(pid, module, function, args)
       end
    
       def cast(pid, message) do
          :python.cast(pid, message)
       end
    
       def stop(pid) do
          :python.stop(pid)
       end
    end
    

    It's called from a GenServer that handles its spawning and termination:

    defmodule App.PythonServer do
       @doc """
          Receives async. messages from Python instance.
        """
       use GenServer
    
       alias App.Python
    
       def start_link() do
          GenServer.start_link(__MODULE__, [])
       end
    
       def init(_args) do
          pid = Python.start()
          Python.call(pid, :bridge, :register_handler, [self()])
          App.Application.broadcast_change
    
          {:ok, pid}
       end
    
       def cast_draw(id) do
          {:ok, pid} = start_link()
    
          GenServer.cast(pid, {:id, id})
       end
    
       def call_draw(id) do
          {:ok, pid} = start_link()
    
          GenServer.call(pid, {:id, id}, 10_000)
       end
    
       def handle_call({:id, id}, _from, session) do
          result = Python.call(session, :bridge, :draw, [id])
    
          {:reply, result, session}
       end
    
       def handle_cast({:id, id}, session) do
          Python.cast(session, id)
    
          {:noreply, session}
       end
    
       def handle_info({:python, message}, session) do
          msg = message |> format_response
          {:ok, time} = Timex.now |> Timex.format("{h12}:{m}{am} {D}/{M}/{YYYY}")
          AppWeb.Endpoint.broadcast("log", "update", %{time: time, body: msg, process: message})
    
          {:stop, :normal,  session}
       end
    
       def terminate(_reason, session) do
          Python.stop(session)
          App.Application.broadcast_change
    
          :ok
       end
    
       defp format_response(message) do
          if String.contains? message, "[result] Sent" do
             message |> String.split("\n", trim: true) |> Enum.at(-2)
          else
             message |> String.split("\n", trim: true) |> Enum.take(-12) |> Enum.join("\n")
          end
       end
    end
    

    You can see at the end if STDOUT doesn't return a certain string from bridge.py (or any other Python module) it'll return a stacktrace. Speaking of, bridge.py looks like this:

    import os
    import sys
    import subprocess
    
    from erlport.erlang import set_message_handler, cast
    from erlport.erlterms import Atom
    
    message_handler = None # reference to the elixir process to send
    
    cmd = "xvfb-run -a python"
    py = os.path.join("/home/ubuntu/app/priv/python/export.py")
    
    def cast_message(pid, message):
      cast(pid, message)
    
    def register_handler(pid):
      global message_handler
      message_handler = pid
    
    def handle_message(id):
        try:
          result = draw(id)
          print result
          if message_handler:
            cast_message(message_handler, (Atom('python'), result))
        except Exception, error:
          print error
          if message_handler:
            cast_message(message_handler, (Atom('python'), error))
          pass
    
    def draw(id):
      proc = subprocess.check_output(
        "{0} {1} {2}".format(cmd, py, id), stderr = subprocess.STDOUT, shell = True
      )
      return proc
    
    set_message_handler(handle_message)