graphics.hatenablog.com

テクニカルアーティストの技術を書き殴るためのメモ帳

Python で from import を reload する。

※ このエントリは Maya Python Advent Calendar 2017 - Qiita の 3 日目です。

Maya-Python といえば reload ですね。
reload といえば from import ですね。
つらいのでなんとかします。

前置き

Maya-Python あるいは Python をよく知らない人のために軽く説明など。Maya とか Python のことはだいたいわかってるよってひとは 状況の把握 からどうぞ。

そもそもなんで reload するのか?

ドキュメント をみるとわかるとおり、reload(lib) すると lib モジュールを動的にリビルドすることができる。いわゆる「ホットリロード」というやつ。

たぶんふつうに Python 書いてたらそうそうやることもない。インタプリタ再起動したほうがずっと楽だし、そもそも「スクリプトの再実行」=「インタプリタの再起動」なので。でも Maya-Python ではとてもよくお世話になる。なぜなら Maya-Python さん、インタプリタと Maya 本体とがとても強く密結合しているので、片方だけを再起動するということができない。なので、インタプリタを再起動するためには Maya を再起動することになる。当然、作業中のシーンも一旦閉じて開き直すことになるのだけど、これらに分単位の時間*1がかかるケースが多々ある。正直やってらんない。

なので Maya-Python では、モジュールのスクリプトを書き換えたら再実行ではなく、reload する。

なんで from import したいのか?

たとえば某 PyMel あたりを参考に Node クラス*2をつくるとすると、素直にやればたぶんこうなる。

lib/
├ __init__.py
└ node.py
# __init__.py
from node improt *
# node.py
__all__ = ['Node']

class Node(object):
    pass

こういうふうに書くと、使う側は

import lib
node = lib.Node()

みたいなコードが書ける。

でもこれ、__init__.py 側が import node になってると、使う側は node = lib.node.Node() みたいに書かなきゃいけなくてダサい。とはいえ使う側に from lib.node import Node とか書かせちゃうのはライブラリ制作者としてちょっとどうかと。

なのでライブラリつくるときは、from import をわりとよく使う。少なくとも自分は。

from import すると何が困るのか?

Python は、モジュールを import すると、そのモジュールを丸ごと自分の名前空間内に取り込む。こんなかんじ。

print globals().keys()
import os
from sys import path
print globals().keys()

# 実行結果
# ['__builtins__', '__name__', '__file__', '__doc__', '__package__']
# ['__builtins__', '__file__', '__package__', 'path', '__name__', 'os', '__doc__']

このときにちょっとつらいのが、上の例でいうと os みたいに import したモジュールは参照を取り込むんだけど、path みたいに from improt したモジュールやシンボルはコピーを取り込む*3*4ことになる。だから、たとえばこれを reload すると、os は更新されるけど path は更新されない。

公式の対処法としては、「reload したあとに必要に応じて from import しなおす」「そもそも from import を使わない」というもの。前者は「from import しているもの」を完全に把握している場合しか使えないし、後者は前述の理由で使いたくない。

なので開発効率の都合上、「from import も込みでいいかんじに更新してくれる reload」が欲しいのです。便宜上、これを「完全な reload」と呼ぶことにする。で、前置きが長くなったけどそれを作ってみたというのがこのエントリの本題。

状況の把握

今回のサンプルプロジェクトは これ を使うとして、とりあえずインポートしてみる。lib の名前空間にいろいろ取り込まれてるのがわかる。

>>> import lib
>>> for k,v in lib.__dict__.items(): print '{}: {}'.format(k, type(v))
LIB2_GLOBAL_CONST: <type 'int'>
lib3_f2: <type 'function'>
lib3_f1: <type 'function'>
lib2_global_var: <type 'NoneType'>
libsrc1: <type 'module'>
libsrc2: <type 'module'>
libsrc3: <type 'module'>
(以下略)

次に、reload の前後で「from import で取り込んだ関数シンボルのアドレス」を比較してみる。__init__.py 上で直接定義した test のアドレスはちゃんと変わってるけど、他のは見事に変わってない。

>>> for k,v in lib.__dict__.items():
>>>     if type(v) == types.FunctionType: print v
<function lib3_f2 at 0x0000000003739AC8>
<function lib3_f1 at 0x0000000003739A58>
<function test at 0x0000000003739C18>
(以下略)
>>> reload(lib)
<module 'lib' from 'C:\tmp\reload_symbols\lib\__init__.py'>
>>> for k,v in lib.__dict__.items():
>>>     if type(v) == types.FunctionType: print v
<function lib3_f2 at 0x0000000003739AC8>
<function lib3_f1 at 0x0000000003739A58>
<function test at 0x00000000037AAC18>
(以下略)

シンボルを特定して個別に reload すると、ちゃんと変わる。

>>> lib.libsrc3.lib3_f2
<function lib3_f2 at 0x0000000003739AC8>
>>> reload(lib.libsrc3)
<module 'lib.libsrc3' from 'C:\tmp\reload_symbols\lib\libsrc3.pyc'>
>>> lib.libsrc3.lib3_f2
<function lib3_f2 at 0x00000000037AAD68>

ただ、個別に reload したからといって、lib に from improt で配置された lib.lib3_f2 のシンボルが更新されるわけではない。何故か。reload(lib.libsrc3) はあくまで sys.modules['lib.libsrc3'] を更新する操作であって、import で配置したシンボルは更新しない。reload することで更新後のモジュールオブジェクトが手に入るのは、import lib.libsrc3 することで sys.modules['lib.libsrc3'] への参照が自分の名前空間内に作成されて、その参照先が reload によって更新されるからだ。*5

一方、lib.lib3_f2 は lib 以下に作成された sys.modules['lib.libsrc3'].lib3_f2 のコピーであって、sys.modules['lib.libsrc3'] を直接参照しているわけではない。よって、reload(lib.libsrc3) による影響が発生することはない。

>>> lib.__dict__.['lib3_f2']
<function lib3_f2 at 0x0000000003739AC8>
>>> reload(lib.libsrc3)
<module 'lib.libsrc3' from 'C:\tmp\reload_symbols\lib\libsrc3.pyc'>
>>> for k,v in lib.__dict__.items():
>>> lib.__dict__.['lib3_f2']
<function lib3_f2 at 0x0000000003739AC8>

状況のまとめ

  • import A すると、sys.module['A'] が存在しなければ新規に作成され、sys.modules['A'] への参照が 'A' という名前で自分の名前空間内に作成される。
  • from A import B すると、sys.module['A'] が存在しなければ新規に作成され、その中からシンボル B が検索されて、'A.B' のコピーが 'B' という名前で自分の名前空間内に作成される。
  • reload(A) すると、sys.modules['A'] が更新され、結果的に自分の名前空間内にある 'A' も更新される。

解決策

とりえあず __init__.py を exec してみる。

さて、ああだこうだ調べてみたけど、いちおう公式では「reload 後に from import しなおす」という解決策が挙げられている。どれを from import しなおせばいいのかわからないのが問題なんだけど、よく考えたらそれ全部スクリプト本体に書いてある。じゃあ reload してからそれ exec すればよくね? スクリプト自体が .pyc になってるとダメだけど、そういう状況で「完全な reload」は必要ない。

def reload1(target_module_obj):
    reload(target_module_obj)
    with open(target_module_obj.__file__, 'r') as f:
        module_source = f.read()
    exec(module_source, target_module_obj.__dict__, target_module_obj.__dict__)

上の例では target_module_obj の名前空間内に from import する必要があるから、exec の globals には target_module_obj.__dict__ を渡すことになる。インポート結果はローカル名前空間に取り込まれるから、locals にも同じ __dict__ を渡せばいい。ただこれには落とし穴があって、「状況のまとめ」に書いたとおり import にはキャッシュ*6が効いていて、これを無効することはできない。

というわけで from import の前には、上の例でいうところの target_module_obj だけではなく、from import したいシンボルがはいってるモジュールも reload する必要がある。そして、それを特定するためには target_module_obj.__file__ の中身を解読する必要がある。

結局のところ、「完全な reload」を実現するには、以下の 2 ステップをちゃんと実装する必要がある。

  1. 任意の名前空間内で from import に使われているモジュールを特定して reload *7する。
  2. reload したモジュールから任意のシンボルを取り出して、任意の名前空間内にコピーする。

from import されているモジュールとシンボルを特定する

これを完全に行うには、ざっくりわけて「テキスト解析」「ソースコードから構築した抽象構文木の走査」「pyc の逆アセンブル」の 3 つの方法がある。

テキスト解析

おそらく最も単純な方法。

def find_symbols(module_obj):
    target_reg = re.compile('^from ([A-Za-z\.].+?) import (.+?)$')
    with open(module_obj.__file__, 'r') as f:
        for line in f:
            m = target_reg.match(line)
            if m is None:
                 continue
            module_name = m.group(1)
            symbol_names = m.group(2)
            print 'from {} import {}'.format(module_name, symbol_names)

サンプルコードを見てのとおり、おそらく大抵の TA/TD なら問題なく扱えると思う。ただ、Python の文脈に依存せずに自前で文法を弄ってしまうというのは、個人的にはあまりオススメできる考え方ではない。

抽象構文木

テキスト解析を言語処理系に任せられる点で、自前でテキスト解析するよりは使いやすい。

抽象構文木 (Abstract Syntax Tree) というのは、コンパイラが解釈しやすいように構造化されたソースコード、まぁようするにコンパイルフローの中で生成される中間形式なのだけど、Python では ast というモジュールを使ってこれをユーザーコードから利用できる。ヘルパーメソッド が充実してるおかげで意外と扱いやすい。基本的にはソースコード解析を行うためのもので、たとえば pyflakes みたいなライブラリで使われることが多い。

ast では ImportFrom というノードが from import に相当しているので、今回の用途ではこれを抜き出してあげればいい。

def find_symbols(module_obj):
    result = {}

    source = inspect.getsource(module_obj)
    tree = ast.parse(source)
    
    for node in tree.body:
        if node.__class__ != ast.ImportFrom:
            continue

        module_name = '{}.{}'.format(module_obj.__name__, node.module)
        target_module = sys.modules[module_name]

        symbol_names = [x.name for x in node.names]
        if symbol_names[0] == '*':
            if '__all__' in target_module.__dict__:
                symbol_names = target_module.__dict__['__all__']
            else:
                symbol_names = [x for x in target_module.__dict__ if not x.startswith('__')]

        result[target_module] = symbol_names

    return result

再帰的に import を辿るときは、モジュールオブジェクトの __file__ をみながら順番に拾ってあげればいい。重複と循環に注意。

アセンブル

どうしても「.pyc 形式で提供されているモジュール」を扱いたいとなれば、これはもう逆アセンブルするしかない。

いちおう Python には dis というモジュールがあって、それの disassemble() を使うと逆アセンブルができる。ただ残念なことに dis のメソッド群は結果を標準出力に書き出してしまうので、いったん StringIO とかで文字列を拾ってからテキスト解析することになる。正直めんどくさい。

with open(target_module_obj.__file__, 'rb') as f:
    f.read(8)
    module_code = marshal.load(f)

stdout = cStringIO.StringIO()
sys.stdout = stdout
dis.disassemble(module_code)
sys.stdout = sys.__stdout__

assembly_str = stdout.getvalue()
stdout.close()

あとは、幸か不幸か Python では Lib/dis.py で dis モジュールの実装が公開されているので、それを参考に自前で逆アセンブルしてしまうというのも、例によってヘルパ関数がちゃんとあるのでそれほど難しくはない。

ただそもそもの話として、開発中は .pyc が生成されないようにしてることもあれば、逆に .pyc だけで動作しているならそれは十中八九プロダクト環境なので、そもそも .pyc を前提とした「完全な reload」は必要なのか?という話題はあると思う。個人的には、技術云々ではなくスクリプト運用の観点からみて、この方法を採用したいとはあまり思えない。

名前空間内にあるシンボルを書き換える

さて、from import の特定さえできてしまえば、あとは何も難しくはない。lib モジュールで from A import B されたシンボルをリロードするには、reload(A) してから、lib.__dict__['B'] に A.__dict__['B'] を上書きしてあげればいい。

たとえばさっき「抽象構文木」の例で挙げたコードを使うなら、ざっとこんなかんじ。

# target_module_obj が「完全な reload」の対象
for module_obj, symbol_names in find_symbols(lib).items():
    reload(module_obj)
    for symbol_name in symbol_names:
        lib.__dict__[symbol_name] = module_obj.__dict__[symbol_name]

解決策のまとめ

from import の対象モジュール・シンボルを特定する方法はざっくり以下の3つ。

それぞれに良し悪しはあるけど、個人的には ast を使う方法が一番使いやすいかなと。

全体のまとめ

  • Maya-Pythonインタプリタ再起動のコストが高いので from import の利用が事実上必須
  • from import してるモジュールは reload しても思い通りに再読み込みできなくて困る
  • from import で読み込んだモジュールは reload 後に再度 from import を実行する必要がある

という現状に対して、

  • テキスト解析/AST/逆アセンブルを使って from import されているモジュール/シンボルを特定して
  • リロード対象モジュールの __dict__ に手動で再代入することで from import の reload をやってみた

という記事なのでした。

地味に厄介な reload 問題を解決して快適な Maya-Python ライフを!
github.com

*1:あまり細かい数値は書けないけど、Maya 自体の再起動と各種プラグインの読み込みにかかるのがだいたい数10秒程度。加えて大きめのシーンを開くのにかかる時間が、モバイルゲームで数10秒、コンソールゲームのAAAや映像系で数分、ハリウッド級の映画だと10分以上くらいなら、「まぁそんなこともあるよね」と思える雰囲気。

*2:だってほら、某PyNodeとか重いじゃないですか、しかもMaya公式でサポートしてないし。速いの欲しくないですか。

*3:より正確にいうと、たとえば from sys import path の場合、「sys の参照」と「sys.path のコピーに path と名付けたもの」を自分の名前空間内に取り込む。

*4:なんでそんな仕組みになってるかは知らないけど、少なくとも値型シンボルの取り込みはだいぶ面倒なことになりそう。PyObjectをごにょごにょするとその部分の実装がCのレイヤまで落ちてしまうので__dict__周りの仕様が複雑になるであろうことは想像に難くない。

*5:たとえば C++ のスマートポインタなんかがその形式で、あれはたいてい内部にダブルポインタを保持してる。そうして「参照の参照」を書き換えることで、「参照」を再作成せずに swap や relocate を実現できる。

*6:たとえばPython 2.7.14の場合、import.cの2689行目のimport_submodule関数内に「sys.modulesに格納済みのモジュールがimportされたときは、sys.modulesの中身をそのまま返す」という処理がある。

*7:sys.modules.pop + import でも構わないけど、__builtins__ に reload が存在する以上、あえてそうする必要はないと思う。