やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

ErlPortでpythonのクラスメゾット呼び出しとElixir側にAtomを返す

前回のはなし

www.okb-shelf.work

Elixirからpythonの関数を呼び出して、最終的にjanomeを使って形態素解析を行いました

本当はpythonのclassメゾットの呼び出しとElixirに対して
Atomのデータを返すということがやりたかったんですけど
そこそこ記事が長くなってしまったのでカット

今回の記事で触っていく
ErlPortの公式にclassメゾットの呼び出しについてほとんど記述が無かったので割としんどかった

python側での準備

単純なclassメゾットを用意

class EasyClassMethod:
  def output_msg(self, msg):
    decode_msg = msg.decode("utf-8")
    print(decode_msg)
    return True

classなのにただ文字列を出力するだけのカス
文字列のdecodeについては前回の記事で触れてるのでどうぞ

Elixir側での準備

ほとんど前の記事のものと同じで異なるのは:python.callを使用して
クラスのインスタンスをさりげなく作っているところです

demodule CallPython do
  def call_esay_method(msg) do
    {:ok, py_processs} = :python.start([python_path: 'python_files'])
    #EasyClassMethodのインスタンスを作ってる(object)
    obj = :python.call(py_processs, :test_call, :EasyClassMethod, [])
    #呼び出す関数はアトムの文字列で与える必要あり,引数の第1にobjectを渡す
    res = :python.call(py_processs, :test_call, :"EasyClassMethod.output_msg", [obj, msg])
    IO.puts(res)
    :python.stop(py_processs)
    :ok
  end
end

とりえあず動作を見てみると

iex(3)> CallPython.call_esay_method("hello world")
hello world
true
:ok

いい感じですね

それぞれの出力値を追っていくと
obj
謎のバイナリーデータが入っている

#objのreturn
{:"$erlport.opaque", :python,
 <<128, 2, 99, 116, 101, 115, 116, 95, 99, 97, 108, 108, 10,
   69, 97, 115, 121, 67, 108, 97, 115, 115, 77, 101, 116, 104,
   111, 100, 10, 113, 0, 41, 129, 113, 1, 46>>}

res
メゾットの戻り値が来ている。いいね

true

クラス化したJanomeParserを呼び出す

とりあえず基礎の部分は抑えたのでこの記事で作成したclassメゾットを呼び出してみる
python

class JanomeParser:
    def __init__(self):
        print("come here: init method")
        self.t = Tokenizer()
        
    def parser(self, value_str, tag=u"名詞"):
        res = self.t.tokenize(value_str)
        if isinstance(tag, list):
            return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
        else:
            return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]

Elixir

def class_caller(value_str, tag) do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    obj = :python.call(py_process, :test_call, :JanomeParser, [])
    res = :python.call(py_process, :test_call, :"JanomeParser.parser", [obj, value_str, tag])
      |> Enum.map(&(to_string(&1)))
    :python.stop(py_process)
    res
end

今まで通り呼び出すが、実は2点問題がある
とりあえず実行結果を先にお見せします

iex(3)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here: init method
** (ErlangError) Erlang error: {:python, :"builtins.ValueError", 'unsupported data type: <class \'test_call.JanomeParser\'>',
:
:
    (call_python) lib/call_python.ex:46: CallPython.class_caller/2

どうやらinitiを呼び出しまでは問題なく行われているが

self.t = Tokenizer()

ってのが上手く行かないっぽい
あとparserに渡した文字列を毎度のごとくdecodeする必要がある
ということでdecodeをしつつ、initでinstanceを生成するのを止めたら動いた

class JanomeParser(object):
  def __init__(self):
    print("come here")

  def parser(self, value_str, tag):
    def decoder(value_str, str_code="utf-8"):
      return value_str.decode(str_code)

    tag = PART_OF_SPPECH.get(decoder(tag))
    t = Tokenizer()
    res = t.tokenize(decoder(value_str))
    if isinstance(tag, list):
      return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
    else:
      return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]
iex(5)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here
["言葉", "一つ", "全部", "よう"]

いい感じだけど、class化した意味があんまりないのがつらい
外部でインスタンス作ってinitに渡してあげればいいかもなと思い試したけどダメでした(デデドン

class JanomeParser(object):
  def __init__(self, tokenizer):
    print("come here")
    self.t = tokenizer

  def parser(self, value_str, tag):
    def decoder(value_str, str_code="utf-8"):
      return value_str.decode(str_code)

    tag = PART_OF_SPPECH.get(decoder(tag))
    res = self.t.tokenize(decoder(value_str))
    if isinstance(tag, list):
      return [token.surface for token in res if token.part_of_speech.split(",")[0] in tag]
    else:
      return [token.surface for token in res if token.part_of_speech.split(",")[0] == tag]


def create_tokenizer_instance():
  return Tokenizer()
def class_caller(value_str, tag) do
    {:ok, py_process} = :python.start([python_path: 'python_files'])
    tokenizer = :python.call(py_process, :test_call, :create_tokenizer_instance, [])
    obj = :python.call(py_process, :test_call, :JanomeParser, [tokenizer])
    res = :python.call(py_process, :test_call, :"JanomeParser.parser", [obj, value_str, tag])
      |> Enum.map(&(to_string(&1)))
    :python.stop(py_process)
    res
end

実行結果

iex(8)> CallPython.class_caller("言葉一つで足りないくらい全部壊れてしまうような", "名詞")
come here: init method
** (ErlangError) Erlang error: {:python, :"builtins.ValueError", 'unsupported data type: <class \'test_call.JanomeParser\'>',
:
:
    (call_python) lib/call_python.ex:46: CallPython.class_caller/2

さっきと同じエラーが。要研究ですね

pythonからElixirにAtomを返す

python側の関数から

:ok
{:ok, value}
{:error, reason}

のようなAtomを返すようなことがしたい場合があるかもしれない
実際にElixirでパターンマッチする際にはAtomの:okやら:errorをよくマッチさせるので使い道は多そう

Elixir側でやることはほとんどなくpython側でのごちゃごちゃが必要になる
公式ドキュメントをみると細々と記述されていた

まずは「erlport」をpython側でインストールする

pip install erlport
pip3 install erlport

こうすることでAtom型をpythonで作成可能となる

from erlport.erlterms import Atom

def return_atom_to_elixir():
  #Atomメゾットには文字列を与える
  return Atom("ok")

あとはこいつをElixirから呼び出すだけなのでいつも通りですね
Elixirとpythonの型の対応は公式ドキュメントの下の方に記述されています
ほとんどの型はそのまま受け渡しできますが

に関してはこのように関数を使って定義する必要があります
他にもpython側のNoneはElixir側ではundefinedになるなど多少異なる点があるので注意
いまさらですがErlangでは文字列はなく全てbinaryとして扱われるのでpython側でdecode, encodeが必要です

感想

classメゾットの呼び出しで結構つまづいた
もっとスッキリ使えればと思う一方、ErlPortの仕組みが気になる

そういえばErlang作者の1人であるJoe Armstrong's さんが亡くなったそうです
ElixirがあるのはErlangのおかげ。ErlangがあるのはJoe Armstrong'sのおかげです
残念です。少しでも多くの人にElixirを触ってもらえるようにアウトプット頑張ります