Search code examples
pythonelixirgen-server

handle_info/2 doesn't get the message sent by Python when cast a message using ErlPort and GenServer


I am trying to mix Elixir with Python using ErlPort, so I decided to read some tutorials about it and the documentation related about everything involved. I understand how works the logic and what does each function. However, I am having problems casting a message and receiving the Python response.

Based on what I read and what I have done I understand that when I cast a message with cast_count/1, this is handle by handle_cast/2 and then is handle by the Python function handle_message() and then this one cast the message with the function cast_message() and the imported one cast()from erlport.erlang. Finally, Elixir should handle the message received from Python with handle_info/2. I think this function is not being executed but I don't know the reason, although I have investigated a lot this stuff in different sources and in the documentation of GenServer and ErlPort.

In my case I have the next structure: lib/python_helper.ex to make ErlPort works and lib/server.ex to call and cast the Python functions.

lib/python_helper.ex

defmodule WikiElixirTest.PythonHelper do
  def start_instance do
    path =
      [:code.priv_dir(:wiki_elixir_test), "python"]
      |> Path.join()
      |> to_charlist()

    {:ok, pid} = :python.start([{:python_path, path}])
    pid
  end

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

  def cast(pid, message) do
    pid
    |> :python.cast(message)
  end

  def stop_instance(pid) do
    pid
    |> :python.stop()
  end
end

lib/server.ex

defmodule WikiElixirTest.Server do
  use GenServer
  alias WikiElixirTest.PythonHelper

  def start_link() do
    GenServer.start_link(__MODULE__, [])
  end

  def init(_args) do
    session = PythonHelper.start_instance()
    PythonHelper.call(session, :counter, :register_handler, [self()])

    {:ok, session}
  end

  def cast_count(count) do
    {:ok, pid} = start_link()
    GenServer.cast(pid, {:count, count})
  end

  def call_count(count) do
    {:ok, pid} = start_link()
    GenServer.call(pid, {:count, count}, :infinity)
  end

  def handle_call({:count, count}, _from, session) do
    result = PythonHelper.call(session, :counter, :counter, [count])
    {:reply, result, session}
  end

  def handle_cast({:count, count}, session) do
    PythonHelper.cast(session, count)
    {:noreply, session}
  end

  def handle_info({:python, message}, session) do
    IO.puts("Received message from Python: #{inspect(message)}")
    {:stop, :normal, session}
  end

  def terminate(_reason, session) do
    PythonHelper.stop_instance(session)
    :ok
  end
end

priv/python/counter.py

import time
import sys
from erlport.erlang import set_message_handler, cast
from erlport.erlterms import Atom

message_handler = None


def cast_message(pid, message):
    cast(pid, (Atom('python', message)))


def register_handler(pid):
    global message_handler
    message_handler = pid


def handle_message(count):
    try:
        print('Received message from Elixir')
        print(f'Count: {count}')
        result = counter(count)
        if message_handler:
            cast_message(message_handler, result)

    except Exception as e:
        print(e)
        pass


def counter(count=100):
    i = 0
    data = []
    while i < count:
        time.sleep(1)
        data.append(i+1)
        i = i + 1

    return data


set_message_handler(handle_message)

Note: I removed @doc to light the code snippets. And yes, I know sys isn't being used at this moment and that catch Exception in Python try block is not the best approach, it is just temporal.

If I test it in iex (iex -S mix), I get the next:

iex(1)>  WikiElixirTest.Server.cast_count(19)
Received message from Elixir
Count: 19
:ok

I want to note that call_count/1 and handle_call/1 works fine:

iex(3)>  WikiElixirTest.Server.call_count(10)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

What I am doing wrong that the communication of Elixir with Python is successful but not the communication of Python with Elixir when I cast a message?


Solution

  • Well, @Everett reported in the question that I closed wrong the atom in counter.py.

    def cast_message(pid, message):
        cast(pid, (Atom('python', message)))
    

    This must be:

    def cast_message(pid, message):
        cast(pid, (Atom('python'), message))
    

    Although this doesn't solve the problem, it helps me to get with the solution. After fixed the first part ((Atom('python'), message)), when I execute cast_count/1 I get a message from Python:

    iex(1)> WikiElixirTest.Server.cast_count(2)
    Received message from Elixir
    Count: 2
    :ok
    bytes object expected 
    

    Although it confuses me a bit at the first time because I awaited something like "Received message from Python: bytes object expected", due handle_info/2. However, I decided to inspect the code of ErlPort and I found the error in the line 66 of erlterms.py, part of the Atom class. Thus the error was in the first part of the message, the atom, so I specified it as binary:

    def cast_message(pid, message):
        cast(pid, (Atom(b'python', message)))
    

    Then I checked it:

    iex(2)> WikiElixirTest.Server.cast_count(2)
    :ok
    Received message from Elixir        
    Count: 2        
    Received message from Python: [[1, 2]]
    

    And it works nicely! So the problem there, even taking into account my mistyping of the message tuple, was that Atom() needs a binary.

    Probably this misunderstanding may be due the first ErlPort tutorial I followed uses Python 2, version in which str is a string of bytes and in Python 3 it would be necessary to convert the string to bytes, due str is a string of text.