graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

今度こそ from import を reload する。

この記事は Maya Advent Calendar 2019 - Qiita の 22 日目の記事になります。
前日の記事は @lie_871221 さんの mayaコマンドを使ったGUIの書き方 - Qiita でした。


Python API 2.0 についてなにか書こうと思ってたんだけど、ネタに詰まったので、過去に書いた Python コードをリファインしたときの話を書いてみる。
Python で from import を reload する。 - graphics.hatenablog.com

Maya というよりほぼ 100% Python の話なんだけど、まぁなんていうか、reload 自体おおよそ Maya Python 固有の話なので。。

以前の方法の問題点

reload 済みモジュールの考慮が甘かった

前回は「(1) コード解析」→「(2) import されたモジュールのリロード」→「(3) from import されたシンボルの上書き」をすべてのスクリプトに対して行っていたのだけど、(2) と (3) の処理が密結合してしまっていて、reload 済みモジュールに対して (2) だけでなく (3) もキャンセルしてしまっていた。

f:id:hal1932:20191223024038p:plain

たとえば上記のように import が行われている場合、lib2 の import 時には lib4 は既に import 済みの状態になっているので、⑤の段階では reload(lib4) をスキップした上で、lib4 から lib2 にコピーされているシンボルの上書きをする必要がある。

これに関連して、import 順に対応した reload の順序も整理する必要がある。
上記の場合、①→②→③→④→⑤の順に import されるので、「(2) import されたモジュールのリロード」は lib3 → lib4 → lib1 → lib2 の順で、「(3) from import されたシンボルの上書き」は②→③→①→⑤→④の順で、それぞれ行う必要がある。*1

この順番が崩れると、例えば lib4 内に Class4 というシンボルが定義されているとき、各モジュールで参照されている Class4 の id が異なる状態になってしまう。
つまり、

# lib4.py
class Class4(object): pass

# lib1.py
class Class1(Class4):
    def __init__(self):
        super(Class1, self).__init__()

# lib2.py
class Class2(Class4):
    def __init__(self):
        super(Class2, self).__init__()

# app.py
class App(Class2):
    def __init__(self):
        super(App, self).__init__()

上記のようなコードがあった場合、class App の継承ツリーが壊れて super(App, self) がエラーを吐く。*2

相対 import への対応が不十分だった

不十分というか、そもそも実装してなかった。

改善策

コード解析処理の変更

moduleA から moduleB が import されているとき、便宜的に、B は A の「子モジュール」と呼ぶことにする。

「(1) コード解析」「(2) 子モジュールのリロード」「(3) from import されたシンボルの上書き」が順番に実行されてたので、それぞれを切り離せるようにした。

"""以前の実装"""
def force_reload(module):
    for imported_module in _get_imported_module(module):  # (1)
        if is_already_loaded(module):
            continue
        reload(imported_module)  # (2)
        force_reload(imported_module) 
        for symbol in _get_module_symbols(imported_module):
            apply_new_symbols(module, symbol)  # (3)
"""修正版"""
def force_reload(module):
    items = _get_import_items(module)  # (1)
    reload_modules(items)  # (2)
    apply_symbols(items)  # (3)

def _get_import_items(module):
    children = _parse_ast_tree(module)
    result = []
    for child in children:
        result.extend(_get_import_items(child))
    result.append(_ModuleItem(module, children))
    return result

def _reload_modules(items):
    for item in items:
        reload(item.module)

def _apply_symbols(items):
    for item in items:
        ... # from import された item.module.__dict__ のシンボル更新を適用する

「修正後」の _get_import_items() について、子モジュールを reload してから自分を reload する必要があるので、その順番になるように再帰呼び出しのタイミングを整えてやる必要がある。ここが一番大事というか、それ以外は for ループの構成が変わったことを除けば前回とあまり変わらない。

ast.alias の考慮

f:id:hal1932:20191223053312p:plain

from import を AST で解析すると class ImportFrom のインスタンスが返ってくるんだけど、そのときの変数の中身は上記のとおり。ImportFrom.names の型が List[ast.alias] になってる。たとえば上記の場合、node.level = 2, node.module = 'module_a', node.names[0].name = 'module_b', node.names[0] = '_mod_b' というふうになる。

このとき、names[*].name の参照先がモジュールではない場合に、モジュール内シンボルの上書きが行われる。


つまり、以下の場合では ..module_a.module_b のシャローコピーが作成され、_mod_b として参照される。この場合は、reload(module_b) を行うだけで _mod_b 経由での module_b 内シンボルへの参照も更新される。

from ..module_a import module_b as _mod_b


一方で、以下の場合では ..module_a.module_b.func_b のハードコピーが作成され、_func_b として参照される。この場合、reload(module_b) を行ったあとにもう一度 from import を行う必要がある。

from ..module_a.module_b import func_b as _func_b

同様に、以下の場合では ..module_a.moduleb 内のシンボルすべてのハードコピーが作成される。

from ..module_a.module_b import *


というわけで、これの参照解決を行うにあたっては、まず '{}.{}'.format(node.module, alias[*].name) が sys.modules 内に存在するかどうかを調べて、存在すれば ast.Import の場合と同様に扱う。存在しなければ、シンボルがハードコピーされる from improt として扱えばよい。


以上、明日の記事は @paty-6991 さんの「リグの軽量化について」です。

*1:正確には②→③→①と⑤→④の順序を崩さなければよいので、⑤→④→②→③→①でも別に問題はない。

*2:issubclass(App, Class4) が False を返すようになる。