Hacking import mechanism
この記事は Python Advent Calendar 2015 22日目の記事です。
概要
Python の import システムには様々なフックが存在し、柔軟にカスタマイズが出来るようになっています。 この記事では、カスタマイズの一例として、インターネット上の .py ファイルをローカルディスクにダウンロードすることなく直接 import できるようにする方法を紹介し、それを通して import システムのカスタマイズ方法について説明します。
とりあえずコード
とりあえず、インターネット上から直接 Python モジュールを import する実際のコードを掲載します。 今はコードの全体の雰囲気と、こんなに短いコードで出来るんだということだけ感じてもらえれば十分です。この記事を最後まで読み終わる頃にはこのコードが理解できるようになっているはずです。
import imp import sys import urllib class HttpPathEntryFinder(object): def __init__(self, path): if not path.startswith('https://'): raise ImportError() self._base_url = path def find_module(self, fullname): prefix = self._base_url + '/' + fullname.split('.')[-1] return ( self._try_load(prefix + '/__init__.py', path=prefix) or self._try_load(prefix + '.py', path=None)) def _try_load(self, url, path): try: urlobj = urllib.urlopen(url) if urlobj.getcode() != 200: return None return HttpLoader(urlobj.read(), url=urlobj.geturl(), path=path) except Exception: return None class HttpLoader(object): def __init__(self, content, url, path): self._content = content self._url = url self._path = path def load_module(self, fullname): module = imp.new_module(fullname) module.__file__ = self._url if self._path: module.__path__ = [self._path] exec(self._content, module.__dict__) return module sys.path_hooks.append(HttpPathEntryFinder) # GitHub の URL をそのまま sys.path に突っ込む! sys.path.append('https://raw.githubusercontent.com/nya3jp/scratch/master') # GitHub から直接 import! import hello
このコードを実行すると、GitHub 上に置かれている Python コード
https://raw.githubusercontent.com/nya3jp/scratch/master/hello/__init__.py
を直接 import し、
Hello! This is hello module on GitHub.
というメッセージを出力します。
注意
本編に入る前にいくつか注意事項です。
- この記事中で示すサンプルコードは、ざっくりとした理解を助けるために、エラーチェックなどの細かい処理は省いてあります。実際に使う場合には参考文献を必ず参照して下さい。
- この記事では Python2.7 を使って解説します。本当は Python3 の方が import メカニズムが整備されているしドキュメントも充実しているのですが、私がメインで使っているのが Python2 なので……
- これは黒魔術です。そもそも import をカスタマイズしようと思っている時点で何か間違えている可能性が高いです。ここで解説していることを実際に使いたくなってしまった場合、本当にこれが必要なのかどうか、他にもっとシンプルに問題を解決する方法がないか考え直しましょう。でも、こういった方法が存在することを知っておくことは有益だと私は信じています。
import をカスタマイズする
import 文と __import__
組み込み関数
そもそも Python における import 文は実際には何をしているのでしょうか。実は import 文が行なうことは、以下の2つの処理に分けられます。
- モジュールをロードする
- ロードしたモジュールを現在の名前空間に束縛する
1の処理は、モジュール名を与えるとモジュールをロードして返すような組み込み関数 __import__
への呼び出しとして実装されています。2は単に __import__
の呼び出し結果であるモジュールをローカルの名前空間に束縛するだけです。なので、
import neko
は、次の Python コードとほぼ等価です。
neko = __import__('neko')
この時点で、import 文をカスタマイズする明らかな手段として __import__
を再定義するという方法が思いつくかと思います。実際にこれは可能で、例えば次のコードは全ての import をただの print に置き換えてしまいます。
def my_import(name, *args, **kwargs): print 'Importing', name __builtins__.__import__ = my_import import neko # => "Importing neko"
これを使えば万事解決、import のカスタマイズがし放題…… なのは確かにそうなのですが、この方法では Python の import システムをすべて自分で再実装して新たな __import__
を定義してやらなければならないため、かなりの労力がかかってしまいます(実際にどこらへんが大変かは割愛しますが、例えば PEP 302 の Motivations セクションに書いてあります)。
__import__
のフック
そこで PEP 302 において、組み込み関数の __import__
自体の動作をカスタマイズするためのフックが提案されました。これにより __import__
の定義をすべてすり替えるよりもずっと容易に import のカスタマイズをすることができます。
__import__
の最も主要なフックポイントが sys.meta_path
です。これはデフォルトでは空の Python リストなのですが、これに Meta Path Finder を登録してやることにより、__import__
の動作をカスタマイズすることができます。
__import__
は呼ばれると次のような動作をします。
sys.meta_path
に登録された Meta Path Finder を順に呼び出し、指定されたモジュールを探すように要求する。- Meta Path Finder は、要求されたモジュールが見つかった場合、Loader のインスタンスを返す。
- Loader は実行されるとモジュールをロードして返す。
なお、ここでの Meta Path Finder および Loader はいずれも Python のオブジェクトです。
ちなみに、どの Meta Path Finder でもモジュールが見つからなかった場合、sys.meta_path
に登録されているものとは別にデフォルトの Meta Path Finder にフォールバックします。これが通常のファイルシステムからの import をサポートしています。
(ちなみに Python3 ではデフォルトの Meta Path Finder も sys.meta_path
に登録されているのですが、Python2 ではそうではないようです)
では、Meta Path Finder と Loader がどのように使われるか、実際の例を見ていきましょう。
Meta Path Finder と Loader の例
次のコードは Meta Path Finder だけを使った例で、import されたモジュールをすべて標準出力に書き出します。
import sys class LoggingMetaPathFinder(object): def find_module(self, fullname, path=None): print fullname return None sys.meta_path.append(LoggingMetaPathFinder()) import collections import collections
これを CPython 2.7.6 で実行すると、次のような出力が出てきます。
collections _collections operator keyword heapq itertools _heapq thread
import が実行されると、Meta Path Finder に対して find_module
メソッドの呼び出しが行われます。find_module
は指定されたモジュールの名前があるかどうか探索し、ある場合は Loader インスタンスを、なければ None
を返します。
この例では先ほどの __import__
を直接書き換える例と違って、LoggingMetaPathFinder.find_module()
で None
を返すことにより、本来の import メカニズムにフォールバックし、ちゃんと再帰的にモジュールがロードされていることが分かりますね。
また、import collections
を2回呼んでいるのにも関わらず、出力が1回しかされていないことにも注目です。これは、2回目の __import__
呼び出しでは Meta Path Finder にモジュールを探させる前に sys.modules
にキャッシュされているモジュールがヒットしているためです。このように、キャッシュ機構を自動的に活用できるのも、フックを利用する利点の1つです。
さて、次はちゃんと Loader も定義してみましょう。次のコードは、どんな名前のモジュールが import されても何かしらのモジュールを返すような Finder / Loader を定義します。
import imp import sys class ConstantMetaPathFinder(object): def find_module(self, fullname, path=None): return ConstantLoader() class ConstantLoader(object): def load_module(self, fullname): module = imp.new_module(fullname) module.hello = 'Hi, I am %s.' % fullname return module sys.meta_path.append(ConstantMetaPathFinder()) import neko print neko.hello # => "Hi, I am neko." import wanko print wanko.hello # => "Hi, I am wanko."
import neko
を実行すると、sys.meta_path
の先頭に登録されている ConstantMetaPathFinder
の find_module
メソッドが呼び出され、ConstantLoader
のインスタンスが返ります。そしてその load_module
メソッドが neko
モジュールをロードするために呼び出されます。
この例ではモジュールの名前に応じてメンバを設定したほとんど空のモジュールを imp.new_module
を使って生成して返しています。
これで import で要求されたモジュールを自分で作成して返す方法が分かりましたね!
パッケージのサポート
先ほどの例は上手く行ったかのように見えましたが、実は階層化されたモジュールをロードしようとすると失敗します。
import imp import sys class ConstantMetaPathFinder(object): def find_module(self, fullname, path=None): return ConstantLoader() class ConstantLoader(object): def load_module(self, fullname): module = imp.new_module(fullname) module.hello = 'Hi, I am %s.' % fullname return module sys.meta_path.append(ConstantMetaPathFinder()) import neko.koneko # => ImportError: No module named koneko
この原因は、koneko
の親である neko
がパッケージとして認識されていないからです。パッケージはモジュールのうち __path__
が設定されているもののことをいいます。これには通常、文字列のリストが設定されます。空リストでもOKです。
import imp import sys class ConstantMetaPathFinder(object): def find_module(self, fullname, path=None): return ConstantLoader() class ConstantLoader(object): def load_module(self, fullname): module = imp.new_module(fullname) module.hello = 'Hi, I am %s.' % fullname module.__path__ = [] # とりあえず空リストを設定してすべてパッケージにしてやる return module sys.meta_path.append(ConstantMetaPathFinder()) import neko.koneko print neko.koneko.hello # => "Hi, I am neko.koneko."
上手く import できましたね。
この __path__
とは何者でしょうか? これは、パッケージの中にあるモジュールを import しようとした際、Meta Path Finder の find_module
の第2引数 path
に渡されるものです。トップレベルのモジュールを import する際には None
が渡されますが、これは sys.path
を参照せよという意味になります。
どういうことか、実際の例で説明しましょう。デフォルトの Meta Path Finder でロードされた実際のパッケージの __path__
を見てみます。
import email print email.__path__ # => ['/usr/lib/python2.7/email']
これは email
パッケージ以下のモジュールを探す時のサーチパスが ['/usr/lib/python2.7/email']
であることを示しています。よって、例えば次に email.utils
モジュールを import する際には、/usr/lib/python2.7/email
から直接 utils
モジュールを探すことになります。sys.path
に含まれている /usr/lib/python2.7
から再びディレクトリツリーを降りてくるということはしません。
このことは、__path__
を書き換えてみると分かります。
import email email.__path__ = [] import email.utils # => ImportError: No module named utils
パッケージの __path__
が空になってしまったため、その下のモジュールがロードできなくなりました。
このように、パッケージの __path__
は意味のないマーカーではなく、そのパッケージの下のモジュールを探す時のサーチパスを格納しているリストなのです。トップレベルのモジュールの場合は、その代わりに sys.path
が使われます。
Path-Based Finder
Meta Path Finder の話に戻りましょう。デフォルトの Meta Path Finder の一種として、Path-Based Finder というものがあります。これは sys.path_hooks
に登録された Path Entry Finder を参照しながら Loader を返すもので、素の Meta Path Finder を定義するよりもサーチパス (sys.path
, __path__
) を扱いやすくなっています。
Path-Based Finder はおおまかに言って次のような実装がなされています:
class PathBasedFinder(object): def find_module(self, fullname, path=None): if path is None: path = sys.path for hook in sys.path_hooks: for p in path: try: entry_finder = hook(p) return entry_finder.find_module(fullname) except ImportError: pass return None
Path Entry Finder は Meta Path Finder とほぼ同じプロトコルを持ちますが、サーチパスが find_module
に与えられるのではなく、sys.path_hooks
に登録された関数 (通常は Path Entry Finder のコンストラクタ) に一つ一つ与えられることが異なります。もし与えられたサーチパスがその Path Entry Finder によってサポートされていないものの場合は ImportError
を送出することにより次の Path Entry Finder に処理を任せることができます。
この機構を使うと、ファイルシステムパスではないもの、たとえば URL やデータベースのパスなどをサーチパスとして使うような Path Entry Finder を sys.path_hooks
に登録しておくことにより、sys.path
にそのような非標準のサーチパスを追加することができるようになります。
そして最初のコードに戻る
ここまで来れば、一番最初に載せたコードが読めるようになっていると思います。再掲しましょう。
import imp import sys import urllib class HttpPathEntryFinder(object): def __init__(self, path): if not path.startswith('https://'): raise ImportError() self._base_url = path def find_module(self, fullname): prefix = self._base_url + '/' + fullname.split('.')[-1] return ( self._try_load(prefix + '/__init__.py', path=prefix) or self._try_load(prefix + '.py', path=None)) def _try_load(self, url, path): try: urlobj = urllib.urlopen(url) if urlobj.getcode() != 200: return None return HttpLoader(urlobj.read(), url=urlobj.geturl(), path=path) except Exception: return None class HttpLoader(object): def __init__(self, content, url, path): self._content = content self._url = url self._path = path def load_module(self, fullname): module = imp.new_module(fullname) module.__file__ = self._url if self._path: module.__path__ = [self._path] exec(self._content, module.__dict__) return module sys.path_hooks.append(HttpPathEntryFinder) # GitHub の URL をそのまま sys.path に突っ込む! sys.path.append('https://raw.githubusercontent.com/nya3jp/scratch/master') # GitHub から直接 import! import hello
HTTP 経由でモジュールを探す Path Entry Finder HttpPathEntryFinder
を定義し、sys.path_hooks
に登録しています。HttpPathEntryFinder
のコンストラクタは https://
から始まるパスの場合のみ成功し、そうでない場合は ImportError
を送出しています。
HttpPathEntryFinder.find_module
では、読み込む対象がパッケージである場合と単なるモジュールである場合の両方を試しています。_try_load
により __init__.py
が見つかった場合、最終的にロードされるモジュールに __path__
が設定されるように HttpLoader
のインスタンスを生成します。そうでない場合、__path__
は設定されません。
HttpLoader.load_module
は前に出てきた例のように imp.new_module
を使って空のモジュールを作成し、exec 文によりそのモジュールをグローバル名前空間としてダウンロードしたスクリプトを実行しています。また、pretty print 時に分かりやすいよう、__file__
を URL に設定しています。
最後に sys.path
に URL をおもむろに挿入し、import 文を実行すると、GitHub からモジュールを直接 import することができます。
おわりに
この記事では Meta Path Finder (Path-Based Finder) の機構を使って import をカスタマイズする方法を紹介しました。
大事なことなのでもう一度書きますが、このテクニックを使いたくなったらたぶん何かが間違っています。使う前に、本当にこのテクニックが必要なのか、もっとシンプルな方法がないか考え直しましょう。
でも私は、こうやって内部のメカニズムがユーザーの手の届く所に出ていていろいろ遊べる Python が好きですよ。