Pythonのモジュールを隔離する

とりあえず以下のようなディレクトリ構成を前提とする。

main.py
foo/
    mod1.py
    mod2.py
    :
    :
bar/
    modx.py
    mody.py
    :
    :

この時、fooとbarは互いに決して参照しあわない、つまりfoo配下のモジュールは例外なくbar配下のモジュールをインポートせず、その逆もまた真という状況を作ることを考える。これをコーディング規約やコード解析によるチェックなどではなく、あくまでも「import文でインポートできない」という状態を実現するにはどうすればいいかについて考えてみたのだが、sys.path_hooksかsys.meta_pathを使えばどうにかなった。

詳しいことはPEP 302を参照してほしいが、Pythonのsysモジュールにはpath_hooksとmeta_pathという変数が定義されており、どちらもモジュールのインポートにフックを仕掛けられる。

sys.meta_pathとsys.path_hooksの違いは、sys.path_hooksはsys.pathからモジュールを探す時のフックであるのに対して、sys.meta_pathはsys.pathとは独立に動作するということだ。つまりsys.pathに登録されていないパスからモジュールをインポートする、場合によっては動的にモジュールを生成してロードさせるなどということもできるわけだ。また、モジュールのキャッシュに対する動作にも差異が見られる。(Python2系統の場合。3系統はどちらもキャッシュの動作は同じらしい。)

というわけで、そのフックの中でスタックフレームを見てインポートの可否を変えればいいわけだ。以下はサンプル。この例ではfooもbarもパッケージではないことに注意。

main.py 

# coding: utf-8
from customimporter import IsolatedImporter
sys.meta_path.append(IsolatedImporter('./foo'))
sys.meta_path.append(IsolatedImporter('./bar'))
import x
print (x.func())
import a
print (a.func())
import b
print (b.func())
import y #エラーになる

customimporter.py (改訂版) 

#coding:utf-8
import os
import pkgutil
import sys
_importers__cache = {}
class IsolatedImporter(object):
    def __init__(self, app_path):
        self.app_path = os.path.abspath(app_path)
        self.finder = pkgutil.ImpImporter(os.path.join(app_path, 'lib'))
        self.child_path = set()
    
    def add_child(self, app):
        self.child_path.add(app.importer.app_path)

    def find_module(self, fullname, path=None):
        loader = self.finder.find_module(fullname, path)
        if loader:
            self._check_path(fullname)
            _importers__cache[fullname] = self
            return loader

    def _check_path(self, fullname, level=3):
        frame = sys._getframe(level)
        srcpath = os.path.abspath(frame.f_globals['__file__'])
        if not srcpath.startswith(self.app_path):
            for c in self.child_path:
                if srcpath.startswith(c):
                    break
            else:
                raise ImportError(fullname)

def make_custom_import():
    u"""
    組み込みの__import__を置き換える。
    システム起動時に一度、適当なところで呼ぶこと。
    極めて危険なのでおすすめしない。
    """
    import __builtin__
    import functools
    origin = __builtin__.__import__
    __builtin__.__import__ = functools.partial(custom_import, importer=origin)
    
def custom_import(name, globals={}, locals={}, fromlist=[], level=-1, importer=None):
    if name in _importers__cache:
        _importers__cache[name]._check_path(name, 2)
    return importer(name, globals, locals, fromlist, level)

customimporter.py (旧版) 

# coding: utf-8
import sys
import pkgutil
import os
class IsolatedImporter(object):
    def __init__(self, lib_path):
        self.path = os.path.abspath(lib_path)
        self.finder = pkgutil.ImpImporter(self.path)
        self.script_path = os.path.abspath(sys.argv[0])
    
    def find_module(self, fullname, path=None):
        import sys
        loader = self.finder.find_module(fullname, path)
        if loader:
            frame = sys._getframe(1)
            srcpath = os.path.abspath(frame.f_globals['__file__'])
            if not (srcpath == self.script_path or
                    os.path.abspath(srcpath).startswith(self.path)):
                raise ImportError(fullname)
        return loader
        //if not loader:
        //    return None
        //return loader LoaderWrapper(loader)

// class LoaderWrapper(object):
//    def __init__(self, loader):
//        self.loader = loader
//
//    def load_module(self, fullname):
//        m = self.loader.load_module(fullname)
//        if fullname in sys.modules:
//            # Python3ではsys.modulesに登録されているとフックすら呼ばれない
//           # よってモジュールのロード後に常に削除する必要がある
//            # ただし、元のPythonコードが書き換えられると都度コンパイルされてしまう
//            # この点はかなり問題なので別の方法を考える必要がある
//            del sys.modules[fullname]
//        return m

追記: 初期のコードだとインポートしたモジュール内部でのシンボルの参照で不具合が出るので一旦取り消し。

foo/x.py 

def func():
    return 'x'

foo/y.py 

import a

bar/a.py 

def func():
    return 'a'

bar/b.py 

import a
def func():
    return 'b' + a.func()

動的モジュール組み立てを対応させる 

ここからが本題。Pythonでは実行時に動的にモジュールオブジェクトを生成できるという機能があるが、その実行時に動的に生成されたモジュールからある特定のパスの下にあるモジュールへのアクセスを禁止する一方で、別のパスへのアクセスは許可したい。この場合、動的にモジュールを組み立てる時にそのモジュールに__file__を定義しておけばよい。

以下サンプル。

import os, types
src = '''
def func1():
    import x
    return x.func()

def func2():
    import a
    return a.func()
'''
code = compile(src, 'virtual.py', 'exec')
newmod = types.ModuleType('virtual')
newmod.__dict__.update(
        {'__file__': os.path.join(os.path.abspath('foo'), 'virtual.py')}
    )
exec (code, newmod.__dict__)
print (newmod.func1())
print (newmod.func2())

実行すればfunc2だけが例外を送出することが確認できるはず。

そもそもの経緯 

面倒なのでこんな妙な事をする動機については書いていなかったが、檜山さんが書いてくれた。

要するに、ちょっと特殊なフレームワークで必要になったということ。流石に処理が若干変態的なので、将来このやり方は変えた方がいいかもしれないが。(開発機能としてはこれで問題ないだろう。)

更新履歴 

2012-05-23
思わぬ不具合が出たので修正
2012-05-21
サンプルのコメントと「そもそもの経緯」を追記
2012-05-14
公開