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. モジュールをロードする
  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 の先頭に登録されている ConstantMetaPathFinderfind_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 が好きですよ。

参考文献