DCCツールからプロセス間通信をする01

mayaやhoudiniなど python2系のシステムから python3系の資産を利用したい場合の抜け道を紹介


目次


はじめに

2020年の CEDEC のセッション「Python による大規模ゲーム開発環境 ~Cyllista Game Engine 開発事例~」において、 python3系で開発されているエンジン及びモジュールと python2系に縛られているDCCツールとの連携の事例が紹介されていた。 そこではsubprocessmultiprocessing に触れられていたが、具体的にどのように使用するのかまでは紹介されていなかった。この記事では実際利用するにはどうするかについて紹介しよう。 なお筆者は当該セッション、講演者、団体とは無 (免責) 関係であることをあらかじめお断りしておく。

また、「python3の資産を活用する」ための記事なので、そのような資産がない場合この記事は半分くらい役に立たない。


この記事では、処理の複雑さに応じ、以下の3通りに分けて考察していく。

  • 自由度のない定型処理
  • データを受け取り処理を行う
  • 相互にデータのやり取りを伴う

一般に複数プロセスをまたがってデータのやり取りを行うような仕組みを `プロセス間通信と呼ぶ` (interprocess communication略してIPC) 。 プロセス間通信にどのような形態があるかは他のページ( Wikipedia など)を参照されたい。

事前準備

外部のPython を利用するため、事前に環境を構築しておくこと(miniconda でも venvでも何でもよい。が、ただべたにインストールした環境を利用するのはおすすめしない) また、subprocessmultiprocessing, pickle についての基本的な解説はここでは取り扱わないので、不慣れな方はまずレファレンスに当たってほしい。

簡単な例

手始めに以下をスクリプトエディタから実行してみよう。 (この記事では mayaでのコード例を記述するが、基本的に他の環境でも同様の記述となる。) subprocess を使用しDCC内のpythonから、本体とは分離したプロセスとしてpython3を起動する。

import subprocess

# パスは各自環境に合わせること
py_exe = r"C:\bin\python\win\python38.exe"

# -c オプションで実行コマンドを文字列として与えることができる
cmd = '''{py_exe} -c "print 'hello python3' "'''.format(**locals())
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

for line in proc.stdout.readlines():
    print(line)

こんな感じの結果になるだろう

  File "<string>", line 1
    print 'hello python3' 
          ^

SyntaxError: Missing parentheses in call to 'print'. Did you mean print('hello python3')?

エラーが出ているがこれは意図的なものだ。python3では print が文から関数に変更になった。 そのため括弧で包む必要がある。ようするに ↑のエラーは python3で実行されているということがわかるだろう。

ではあらためて

import subprocess

py_exe = r"C:\bin\python\win\python38.exe"
cmd = '''{py_exe} -c "print('hello python3')"'''.format(**locals())
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

for line in proc.stdout.readlines():
    print(line)

とすると想定通り

hello python3

と表示される。 なお、

cmd = '''{py_exe} --version"'''.format(**locals())

を実行すれば、より直達に実行環境のバージョンを確認可能だ。普段コマンドラインからpython.exeを使う機会がないと 馴染みがないかもしれないが、起動オプションはほかにもいくつかあるので python -h してみるとよいだろう。

少し難しい例 データを渡す

さすがに単純すぎるだろうか。ではもう少し複雑な例、つまり起動時にデータを受け取る例にしてみよう

script_to_be_executed = "C:/hoge.py"
cmd = '''{py_exe} "{script_to_be_executed}"'''.format(**locals())

...あるいは

module_to_be_executed = "cool_module"
cmd = '''{py_exe} -m "{module_to_be_executed}"'''.format(**locals())

としてやればファイルやモジュールをスクリプトとして実行できるので、つまり

script_to_be_executed = "cool_module"
some_arguments = "hoge"
cmd = '''{py_exe} "{script_to_be_executed}" {some_arguments}"'''.format(**locals())

とやればコマンドライン引数として文字列データを渡すことができる。文字列が渡せるということは json経由でのやり取りも可能だ。

では、もう少し複雑なデータを渡したい場合どうすればよいだろう? なんらかのシリアライズ・デシリアライズを行うことになるのだが、今回は pickle を使うことにする。 (いくつか落とし穴があるのだがが今回は措いておくことにする。)ついでにコマンドライン引数経由での 入力から、標準入力を使用するように変更してみよう。

以下の例から DCC側のコード


# DCC側
import pickle
import subprocess

hoge = 1
fuga = 2

py_exe = r"C:\bin\python\win\python.exe"
scriptpath = r"C:\yamahigashi.dev\content\text\dcc\2020-09-22-dcc-interprocess-communication01\pickle_test01.py"
cmd = '''{py_exe} {scriptpath}'''.format(**locals())

proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
out, err = proc.communicate(input=pickle.dumps({"hoge": hoge, "fuga": fuga}))

print(out)
print(err)

で実行する外部のコードは

# ------------------------------------------------------------------------------
# 外部側
import sys
import pickle

s = sys.stdin.buffer.read()
d = pickle.loads(s)
print(d["hoge"])  # => 1
print(d["fuga"])  # => 2

このようにすると、(外部側の)標準入力stdinを使用し DCC側のオブジェクトを送り付けることができる。 なお、送り付けるのと同様標準出力経由で何らかの結果を送り返すこともできる。

この例では単純な組み込み型である整数の辞書型のデシリアライズ・シリアライズであった。では、ユーザ定義型 だとどうなるか見てみよう・・・とおもったのだが記事を前後編にわけることにする。 次回の記事をお待ちいただきたい。


日本語やunicodeを使う場合の注意点

蛇足。 subprocess の情報を探っていて頻出の記法なのだが、日本語環境だとエラーになりやすいので注意喚起しておく。

# 避けよう
exe = "python"
script = "bad_habit.py"
arg = "hogemage"
subprocess.Popen([exe, script, arg])

Popen の引数にリストを渡しているが、こうするのでなく

# 代わりにこう書く
exe = "python"
script = "better_approach.py"
arg = "hogemage"
cmd = "{exe} {script} {arg}".format(**locals())
subprocess.Popen(cmd)

一見あまり違いがない、というか上のほうが読みやすいし理解しやすいと思うかもしれないが、リストの要素にunicodeが含まれる場合 悲しいことにエラーになる。意図せず日本語ファイルを処理しなくてはならないことはよくあり、なおかつそういうのはたいてい自分以外の環境だったりする。 このように他との接続で問題になることがある。 どのような型が渡ってくるか判断できない時点でかなりダメダメなのだが、強制できないので(あきらめる) そのような場合、subprocessなどのライブラリ内でエラーが発生するより、先に文字列結合しておけばアプリ側でのエラーになる。不具合に気づきやすいので筆者はこうしている。


まあ上記の例ではエラーの余地がないのだが、python2系でこのように日本語を含む可能性のある パスだったりを扱う場合、内部的にはbytestringとして扱い、 表示するときのみ displayable にしてやるとよい。具体的には下を参考にされたい。


def _fsencoding():
    # type: () -> Text
    """Get the system's filesystem encoding. On Windows, this is always
    UTF-8 (not MBCS) but cp932 in maya.
    """
    encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
    if encoding == 'mbcs':
        # On Windows, a broken encoding known to Python as "MBCS" is
        # used for the filesystem. However, we only use the Unicode API
        # for Windows paths, so the encoding is actually immaterial so
        # we can avoid dealing with this nastiness. We arbitrarily
        # choose UTF-8.
        if not cmds.about(q=True, batch=True):
            encoding = "cp932"
        else:
            encoding = "utf8"

    return encoding


WINDOWS_MAGIC_PREFIX = u'\\\\?\\'
def bytestring_path(path):
    # type: (Union[bytes, Text]) -> bytes
    """Given a path, which is either a bytes or a unicode, returns a str
    path (ensuring that we never deal with Unicode pathnames).
    """
    # Pass through bytestrings.
    if isinstance(path, bytes):
        return path

    # On Windows, remove the magic prefix added by `syspath`. This makes
    # ``bytestring_path(syspath(X)) == X``, i.e., we can safely
    # round-trip through `syspath`.
    if os.path.__name__ == 'ntpath' and path.startswith(WINDOWS_MAGIC_PREFIX):
        path = path[len(WINDOWS_MAGIC_PREFIX):]

    # Try to encode with default encodings, but fall back to UTF8.
    try:
        return path.encode(_fsencoding())
    except (UnicodeError, LookupError):
        return path.encode('utf8')


def displayable_path(path, separator=u'; '):
    # type: (Union[bytes, Text], Text) -> Text
    """Attempts to decode a bytestring path to a unicode object for the
    purpose of displaying it to the user. If the `path` argument is a
    list or a tuple, the elements are joined with `separator`.
    """
    if isinstance(path, (list, tuple)):
        return separator.join(displayable_path(p) for p in path)
    elif isinstance(path, unicode):
        return path
    elif not isinstance(path, bytes):
        # A non-string object: just get its unicode representation.
        return unicode(path)

    try:
        return path.decode(_fsencoding(), 'ignore')
    except (UnicodeError, LookupError):
        return path.decode('utf8', 'ignore')


exe = bytestring_path("python")
script bytestring_path("bad_habit.py")
arg = bytestring_path("hogemage")
cmd = "{exe} {script} {arg}".format(**locals())
subprocess.Popen(cmd)