PythonのコードをPython ASTベースでRubyに変換を行う py2rb.py 0.1.0 をリリースしました。

去年の8月に RejectKaigi 2017 にて発表した、「PythonのコードをPython ASTベースでRubyに変換を行う py2rb.py」が、初版公開できるレベルになったので、python のパッケージとして PyPIで公開しました。

pypi.python.org

github.com


py2rb.py は、Python機械学習関連のライブラリ、特に Chainer を Ruby に移植するためにPythonからRubyへのトランスコンパイラを作成している位置づけになります。

py2rb.py 開発の経緯の詳細は、下記 blog をご覧ください。

naitoh.hatenablog.com

特徴
  • Python ASTベースで1行単位にRubyへ翻訳
    1. Python => Ruby (メソッド,クラス,変数) : decorator や yield 等は未サポート
    2. import 文を解釈し、ローカルのモジュールファイルを参照&一括変換
    3. Python標準ライブラリ=>Ruby標準ライブラリ : (Chainer で使われている 73 モジュール のうち 10モジュールを仮実装)
  • ドメインが同じライブラリ間でPython=>Rubyに置き換え
    1. numpy => Numo::NArray (Chainer で使われている 116 メソッド のうち 31メソッドを仮実装)
    2. unittest => test::unit (Chainer で使われている 33 メソッド のうち 4メソッドを仮実装)
実行方法
$ py2rb
Usage: py2rb.py [options] filename.py
    or py2rb.py [-w [-f]] [-(r|b)] [-v] filename.py
    or py2rb.py -p foo/bar/ -m [-w [-f]] [-(r|b)] [-v] foo/bar/filename.py
    or py2rb -l lib_store_directory/ [-f]

Usage のように変換対象の Python のファイル名を指定して実行すると、変換結果を標準出力に表示します。
(以前は利用しているローカルのモジュール名を個別に指定する必要がありましたが、0.1.0では import 文を解釈しローカルのモジュールファイルを自動的に参照します。)
"-w" オプションをつけると、filename.py の変換結果を filename.rb に拡張子を変えた形式で保存します。
すでに filename.rb が存在すると処理を Skip するので、上書き保存をしたい場合は "-f" オプションをあわせて指定します。
"-m" オプションをつけると参照したローカルのモジュールファイルもあわせて一括変換します。

実際に変換すると下記のようになります。

Python サンプルコード:
$ git clone git://github.com/naitoh/py2rb.git
$ cat py2rb/tests/strings/zipstring.py

s1 = "hello"
s2 = "world"
s3 = "abcd"

s4 = list(zip(s1,s2,s3))

for item in s4:
    print("----")
    for val in item:
        print(val)

Python実行例:

$ python py2rb/tests/strings/zipstring.py
----
h
w
a
----
e
o
b
----
l
r
c
----
l
l
d

変換例:
$ py2rb -r py2rb/tests/strings/zipstring.py

# frozen_string_literal: true

require 'module'

using EnumerableEx
using PythonZipEx
using PythonPrintEx
using PythonIsBoolEx
using PythonIndexEx
using PythonFindEx
using PythonSplitEx
using PythonStripEx
using PythonStringCountEx
using PythonRemoveEx
using PythonMethodEx

s1 = "hello"
s2 = "world"
s3 = "abcd"
s4 = zip_p(s1, s2, s3).to_a
for item in s4
  print("----")
  for val in item
    print(val)
  end
end

なお、ここで Python の zip メソッドを Rubyでは独自に定義した zip_p メソッドに置き換えています。
PythonRuby で互換性のないメソッドなどの場合、互換用ライブラリを使用することで互換性を確保しています。
"using PythonZipEx" をコメントアウトすれば互換用ライブラリのメソッドは個別に無効になるので、処理の置き換えなどを実施後に該当処理が不要になれば using 行を削除するだけで済みます。

先ほどは、標準出力に表示されましたが、"-w" をつけることで、ファイル保存されます。

$ py2rb -r py2rb/tests/strings/zipstring.py -w
Try  : py2rb/tests/strings/zipstring.py -> py2rb/tests/strings/zipstring.rb : [OK]

"-l" で互換用ライブラリを指定ディレクトリに保存します。今回はカレントディレクトリに保存します。

$ py2rb -l .
OK :  ./module.rb file was stored.

Ruby の -I オプションでカレントディレクトリをライブラリパスを追加し、実行時に module.rb を呼び出し可能にして実行します。

Ruby実行例:

$ ruby -I . py2rb/tests/strings/zipstring.rb
----
h
w
a
----
e
o
b
----
l
r
c
----
l
l
d

Python の実行結果と同じになりました。

大規模なソースコードの変換の例として、chainer の変換例は次のようになります。

$ git clone git://github.com/chainer/chainer.git
$ cat chainer/chainer/variable.py

import collections
import copy
import heapq
import traceback
import warnings
import weakref

import numpy

import chainer
from chainer.backends import cuda
from chainer import initializers
from chainer.initializers import constant
from chainer.utils import argument

def _check_grad_type(func, x, gx):
    if x.data is None or gx is None:
        # ``x.data is None`` implies that the data array is not retained
        return
    if not isinstance(gx, type(x.data)):
        msg = ('Type of data and grad mismatch\n%s != %s' %
               (type(x.data), type(gx)))
        typ = TypeError
    elif gx.dtype != x.data.dtype:
        msg = ('Dtype of data and grad mismatch\n%s != %s' %
               (x.data.dtype, gx.dtype))
        typ = TypeError
    elif gx.shape != x.data.shape:
        msg = ('Shape of data and grad mismatch\n%s != %s' %
               (x.data.shape, gx.shape))
        typ = ValueError
    else:
        return

    detail = ''
    if func:
        detail = 'Function `{0}` ({1}) has a bug.\n'.format(
            type(func)._impl_name, func.label)
        stack = func.stack
        if stack:
            detail += 'Stacktrace of the function is below:\n'
            for line in traceback.format_list(func.stack):
                detail += line
        detail += '''
Please report this error to the issue tracker with the stack trace,
the information of your environment, and your script:
https://github.com/chainer/chainer/issues/new.
'''.format(type(func).__name__, func.label)

    raise typ(detail + msg)
(略)

$ py2rb -p chainer chainer/chainer/variable.py

module Chainer
  module Variable
    require 'weakref'
    require_relative 'backends/cuda'
    include Chainer::Backends
    require_relative 'chainer'
    include Chainer
    require_relative 'initializers/constant'
    include Chainer::Initializers
    require_relative 'utils/argument'
    include Chainer::Utils
    def _check_grad_type(func, x, gx)
      if is_bool((x.data === nil)||(gx === nil))
        return
      end
      if is_bool(!(gx.is_a? (x.data).class))
        msg = "Type of data and grad mismatch
%s != %s" % [(x.data).class, (gx).class]
        typ = ArgumentError
      else
        if gx.dtype != x.data.dtype
          msg = "Dtype of data and grad mismatch
%s != %s" % [x.data.dtype, gx.dtype]
          typ = ArgumentError
        else
          if gx.shape != x.data.shape
            msg = "Shape of data and grad mismatch
%s != %s" % [x.data.shape, gx.shape]
            typ = TypeError
          else
            return
          end
        end
      end
      detail = ""
      if is_bool(func)
        detail = "Function `{0}` ({1}) has a bug.
".format((func).class._impl_name, func.label)
        stack = func.stack
        if is_bool(stack)
          detail += "Stacktrace of the function is below:
"
          for line in (func.stack).to_a
            detail += line
          end
        end
        detail += "
Please report this error to the issue tracker with the stack trace,
the information of your environment, and your script:
https://github.com/chainer/chainer/issues/new.
".format((func).class.__name__, func.label)
      end
      raise typ, (detail)+(msg)
    end
(略)

ここまでの規模になると、ソースコード修正が必要になりますが、大部分の機械的な置き換えが行われた後なので悩む箇所や手間が削減されます。

実際にPython からの移植を行う場合は、"-m" オプションをつけると "-p" のパス配下のファイルで指定されたpython ファイル (下記の場合は chainer/chainer/variable.py) から参照されているファイルを一括変換するので便利です。

$ py2rb -p chainer chainer/chainer/variable.py -m -w
Try  : chainer/chainer/functions/math/fmod.py -> chainer/chainer/functions/math/fmod.rb : [OK]
Try  : chainer/chainer/functions/array/expand_dims.py -> chainer/chainer/functions/array/expand_dims.rb : [OK]
Try  : chainer/chainer/functions/pooling/average_pooling_nd.py -> chainer/chainer/functions/pooling/average_pooling_nd.rb : [OK]
Warning : syntax not supported (<_ast.ExtSlice object at 0x102df11d0>)
Warning : ExtSlice not supported (path[]) in Assign(ast.Subscript)
Warning : syntax not supported (<_ast.ExtSlice object at 0x102df11d0>)
Warning : syntax not supported (<_ast.ExtSlice object at 0x102dd35f8>)
Warning : syntax not supported (<_ast.ExtSlice object at 0x102ddef28>)
()

変換処理で未サポートなどにより変換ができない部分は Warning が表示され、該当箇所以外の変換を続行します。

現在、Red Data Tool プロジェクトで行われている Chainer の Ruby移植版 Red Chainer の開発に Join し、ドッグフーディングも兼ねて私の作成した部分については py2rb.py を使いながら (py2rb.py の機能不足点も強化しながら)開発を進めています。

github.com

今後はRed Chainer の成長にあわせて py2rb.py の機能カバレッジや完成度も向上していければと考えています。

興味ある方は使ってみてください。