graphics.hatenablog.com

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

オンメモリに展開したPythonスクリプトをimportする。

Python 3.6 で meta_path 周りが色々整理されてたっぽいから使ってみた。
github.com

概要

 ものすごくざっくりまとめると、sys.meta_path にカスタム importer を追加すると、標準 importer でインポートできなかったモジュールのインポート処理をフォールバックしてくれる。で、そのカスタム importer の中でモジュールの雛形をつくって、そのモジュールに紐付けたい処理を実行してやればいい。仕組みとしてちゃんと理解しようとするとそこそこ面倒だけど、使うだけなら比較的簡単だった。

 更に詳しい説明はこのあたり。

importer

 ここでいう importer というのは、具体的には以下 2 点の抽象基底クラスを実装したものをいう。Python はダックタイピングが使えるから実際にこれらを継承する必要は必ずしもないんだけど、まぁあらかじめ用意された基底クラスがあると楽だし、そのまま使えばいいと思う。

 実際に必要なクラス定義はこんなかんじ。find_spec() でモジュールの雛形をつくって、exec_module() でモジュール内部の処理を実行する。

class _PackageImporter(importlib.abc.MetaPathFinder, importlib.abc.Loader):
    @abc.abstractmethod
    def find_spec(
        self,
        fullname: str,
        path: Optional[str] = None,
        target: Optional[types.ModuleType] = None
        ) -> Optional[importlib.machinery.ModuleSpec]:
        raise NotImplementedError()

    @abc.abstractmethod    
    def exec_module(self, module: types.ModuleType):
        raise NotImplementedError()

    def create_module(
        self, spec: importlib.machinery.ModuleSpec
        ) -> Optional[types.ModuleType]:
        # デフォルトのモジュール定義をそのまま使う
        return None

find_spec()

 モジュールの雛形になる importlib.machinery.ModuleSpec を作成する。ここで作成した ModuleSpec がほぼそのままモジュールに変換されるから、どんなモジュールが必要なのかをあらかじめイメージできてれば、やること自体はとてもシンプル。
 ただ注意点として、ModuleSpec を作成するのに importlib.utils.spec_from_loader() や importlib.utils.spec_from_location() みたいな便利関数があって、資料によってはこれを使うといいって書いてある。でも ipmortlib.abc.Loader を素直に実装しただけだとこれらの便利関数は意図通りに動作してくれない*1*2から、なんだかんだ自前で ModuleSpec を作成するほうが早い。
 引数 fullname には、例えば import testpkg の場合は 'testpkg' が、from testpkg import testmod の場合は 'testpkg.testmod' が渡されてくる。なので、fullname に応じたモジュールに対応するファイルパスだとか、パッケージ判定だとかを組み込んでやればいい。
 

def find_spec(self, fullname, path, target):
    loader = self # module.__loader__相当
    origin = ...  # module.__file__相当
    is_package = ... 
    spec = ModuleSpec(fullname, loader, origin=origin, is_package=is_package)
    spec._set_fileattr = True  # Trueにしないとoriginが無視される
    return spec

 

create_module()

 find_spec() で取得した ModuleSpec をもとにモジュールを作成する。None を返すと、spec.name と同じ名前の空モジュール*3を勝手につくってくれる。今回はそれでいいから何もしない。
 ここで作成されたモジュールが create_module() の引数になるから、空モジュールで困るときは、ここで自前のモジュールを作成しておく。まぁでも、空モジュールでも困らないケースは多そう。

exec_module()

 create_module() で作成した or 空モジュールに対して紐付けたい処理をここで実行する。module.__dict__ を直接操作してもいいんだろうけど、大抵の場合はモジュールのソースコードコンパイル済みバイナリがあるだろうから、global に module.__dict__ を設定してそれを実行すればいい。そうするとその中で定義されてる各種シンボルが module.__dict__ 内に書き込まれて、よくある import 済みモジュールと同じように使えるようになる。

def exec_module(self, module: types.ModuleType):
    source = ... # モジュールのソースコード
    exec(source, module.__dict__)

カスタム importer のサンプル

 Python の標準 importer ではスクリプトなり DLL なりのファイルから処理内容を取り出して個々のモジュールに紐付けてるんだけど、これをファイルじゃなくてメモリ上から取り出せるようにしてみる。スクリプト本体はあらかじめ適当な形式でパッケージングしておいて、それをメモリ上に展開したものをスクリプトファイルの代わりにする。例えば Python 標準で ZIP 化されたソースコードを直接 import できるけど、だいたいあんなかんじ。Python スクリプトの秘匿化なんかでピンポイントな需要があったりなかったりする。

 冒頭に挙げた Github のリンク先は、スクリプトを Base85 バイナリでエンコードしたバイナリを ZIP アーカイブしたパッケージデータを import できるようにしたもの。リンク先の例ではすべて Python で実装してあるから暗号化・復号化のロジックが見えちゃって意味がないけど、この部分をたとえば C++ なんかで書いてやれば、ちょっとした秘匿化の仕組みにはなるはず。あとはまぁ、復号化したソースコードがメモリ上に残らないようにちゃんとしてあげるとか、そもそもソースコードじゃなくてコンパイル済みバイナリをパッケージングするとか。

*1:例えばmodule.__file__にNoneが設定されてしまうとか。

*2:Loaderにis_package()やget_filename()が実装してあれば意図どおりに動作する。

*3: type(sys)(module_name)