Python に end を導入してみる

この記事は Python Advent Calendar 2016 9日目の記事です。

モチベーション

みなさん御存知の通り、Python は文法上のブロックがインデントによって表現されています。たとえば次のような 3 重ループを含む Python プログラムを考えてみましょう。

def warshall_floyd(g):
    n = len(g)
    for j in range(n):
        for i in range(n):
            for k in range(n):
                g[i][k] = min(g[i][k], g[i][j] + g[j][k])
    return g[0][n - 1]

for ループの終了はインデントが減ることで表現されています。これによりブロックが簡潔に表現できるなどの利点はある一方で、複数レベルのブロックが一度に終わった時いくつのレベルが終わったか分かりづらいのが難点の一つでもあります。

ところで Ruby ではブロックの終了を end というキーワードで表現します。

n = g.length
n.times do |j|
  n.times do |i|
    n.times do |k|
      g[i][k] = [g[i][k], g[i][j] + g[j][k]].min
    end
  end
end

(注: 私は Ruby にまったく詳しくないのでこの素人っぽいコードにはあまり深く突っ込まないでください……) これなら3つのブロックが終了したことが明確ですね。

では Python にも end を導入すれば Ruby みたいにブロックの終了が見やすくなるのでは? という小学生並みの発想により、そのようなモジュールをメタプログラミング的に作ってみました、というのが今回の記事の内容になります。

使用例

何はともあれ、使用例です。ちなみにこのモジュールは PyPI に登録してあるので、pip install end で簡単にインストールできます。

import end

def func():
    for i in range(3):
        print(i)
    end
end

with open('a.txt') as f:
    print(f.read())
end

try:
    time.sleep(3)
except KeyboardInterrupt:
    print('Interrupted')
finally:
    print('Slept')
end

if 28 > 3:
    print('nya-n')
# end がここに無いので SyntaxError が投げられる!

スクリプト内で import end と書くことにより、そのスクリプト内でのみチェックが有効になり、ブロックの終了を end で示すことが強制されますend をブロックの末尾に書き忘れたり、ブロックの末尾でないところに end を置くと SyntaxError が発生します

end モジュールのソースコードhttps://github.com/nya3jp/end に置いてあります。ちなみに end モジュール自身とそのユニットテストも end を利用して書かれていますので、もっと実用的(?)なコードでの使用例はそちらで見ることが出来ます。

実装方法

さて、以下では end モジュールをどのように実装したかを解説していきます。解説で用いる Python 処理系は CPython 3.3+ を仮定します。CPython 2.7 でも動作しますが、標準モジュールの名前などに多少変更があります。

基本的なアイデア

end なるキーワードは Python の文法に存在しないので、何も考えずに end と書くと NameError が発生します。まずはこれを回避したいわけですが、これは簡単で、単にグローバル変数として end という名前のものを定義してあげればいいだけです。

end = object()

def main():
    for i in range(3):
        print(i)
    end  # OK
end  # OK

変数 end の中身はなんでも構いません。そこで end.py という空のファイルを作成し、import end としてやることにします。すると end はモジュールを指すグローバル変数となり、先ほどの例と同様に end と書くことができるようになります。

# end.py
# ここにはまだ何も書いていない
# sample.py
import end

def main():
    for i in range(3):
        print(i)
    end  # OK
end  # OK

とりあえずこれだけでも見た目はそれっぽいのですが、このままでは end と書き忘れても何も怒られません。エラーが出ないと誰も end なんて書かないのであまり意味がないですね。

そこで、end.py に色々書くことにより、end モジュールが import された時に import 元のモジュール(上記の例の場合は sample.py) に対して文法チェックを走らせることにします。これは大まかに言って次の2ステップからなります。

  • end.py が import されるたびに import 元のソースコードを読み出す
  • 読み出したソースコードを文法解析して end が正しく使われているかチェックする

それぞれ解説していきます。

import 元のソースコードを読む

まずは import 元のソースコードを読むことを考えましょう。

import のフック

Python の import 文はいろいろな方法でフックすることができます。大まかに言って 2 種類くらい方法があります。

  1. ビルトイン関数 __import__() を置き換える
  2. sys.meta_path に meta path loader を登録する

実は import 文は __import__() というビルトイン関数の呼び出しの単なるラッパーです。なので、この関数を自分で定義したものに置き換えることで import をフックすることができます。これが 1 番目の方法になります。

また、デフォルトの __import__() ビルトイン関数はユーザがカスタマイズ可能なフックの手段を用意しており、それが 2 番目の sys.meta_path を使う方法になります。こちらについてはわたしが Python Advent Calendar 2015 で書いた Hacking import mechanism という記事で解説しているので、気になる方はそちらもご覧ください。

今回は 1 番目の __import__() を置き換える方法を利用します。これは、デフォルトの __import__() は meta path loader を呼び出す前に sys.modules に既にモジュールがロードされているかチェックして既にロードされていればそれを返すようなキャッシュ機構を備えているため、2 番目の sys.meta_path による方法では end モジュールの 2 回目以降の import をフックするのが難しいからです。

というわけで肩慣らしに __import__() ビルトイン関数を置き換えてみましょう。

# first_hack.py
import builtins
import functools

original_import = builtins.__import__

@functools.wraps(original_import)
def my_import(name, *args, **kwargs):
    print('import is hacked! name=%r' % name)
    return original_import(name, *args, **kwargs)

builtins.__import__ = my_import

import os  # => import is hacked! name="os"

Python 3 ではビルトイン関数は builtins モジュールに入っています(Python 2 では __builtin__)。この中にある __import__() を自分で定義した関数で置き換えることで、あっさりと import 文を乗っ取ることができました。

スタックフレームの走査

import のフックが出来たので、次に import 元がどこかを調べます。これは import が関数呼び出しに帰着される以上、スタックフレームを辿れば分かるはずです。

スタックフレーム(あるいは単にフレーム)は Python インタプリタの実行中、各関数呼び出しの各レベルにおいて今どこが実行されておりどのような環境を持っているかを示しているものです。現在のスタックフレームは inspect.currentframe() 関数で取得することが出来て、バックリンクにより関数の呼び出し元を辿っていくことができます。

これを使って、我々の __import__() を呼び出したコードのファイル名と呼び出し元の行数を出力してみましょう。

# second_hack.py
import builtins
import functools
import inspect

original_import = builtins.__import__

@functools.wraps(original_import)
def my_import(name, *args, **kwargs):
    caller = inspect.currentframe().f_back
    print('called from %s:%d' % (caller.f_globals['__name__'], caller.f_lineno))
    return original_import(name, *args, **kwargs)

builtins.__import__ = my_import

import os  # => called from second_hack.py:15

inspect.currentframe() で手に入るフレームは my_import() 内を表すフレームなので、f_back を一度辿って呼び出し元のフレームを取得します。フレームからはそこにおけるグローバル変数テーブルが frame.f_globals で参照できるので、これを使って __name__ を読んでソースコードのパスを取得します。ついでに実行中の行番号も frame.f_lineno で取得可能です。フレームオブジェクトに入っている情報の一覧は inspect モジュールのドキュメント に書いてあります。

CPython バイトコードの解析

もう import 元のファイル名が手に入ったように見えますが、実はこれでは不完全です。というのも、__import__() をフックしているのが end.py だけであればこのままで十分なのですが、他にもフックしているコードがあった場合、我々の定義した __import__() の呼び出し元は import 文を実行したユーザーコードではなく、他のフックコードになってしまう可能性があるからです。実際、Python 3 でいろいろテストをしていたところ、__import__() を置き換えるコードが標準ライブラリ内にあり、単純に親フレームを見るだけでは正しく import 元を特定できない現象に遭遇しました。

ではスタックフレームの列から import を行っているユーザーコードを正しく特定するにはどうしたらいいでしょうか。これは、各スタックフレームで実行中のバイトコードを解析することで解決します。

スタックフレームには、そのフレームで実行中のバイトコードとインストラクションポインタ(いまどの命令を実行しようとしているかを示すカウンタ)が入っています。これを使って、今まさに import 文を実行しているフレームを探し出すのです。__import__() をフックするようなコードはその性質上 import 文ではなく関数呼び出しを実行しているはずなので、そのようなフレームはスキップすることができます。

CPython には dis というモジュールが用意されており、これを使ってバイトコードを逆アセンブルすることができます。試しに import 文だけを含むような関数 f() を逆アセンブルしてみましょう。

def f():
    import os

これは次のようなバイトコードになります。

 4           0 LOAD_CONST               1 (0)
             3 LOAD_CONST               0 (None)
             6 IMPORT_NAME              0 (os)
             9 STORE_FAST               0 (os)
            12 LOAD_CONST               0 (None)
            15 RETURN_VALUE

CPython のバイトコードに馴染みのない方も多いかと思いますが、IMPORT_NAME が import 文に対応する命令であり、そのパラメータ(オペランド)として “os” という文字列が渡されている、ということだけ分かれば十分です。

import 文に対応する命令が分かったので、あとはスタックフレームを走査してバイトコードを見つつ import end を実行しているフレームを探します。そのコードは以下のような感じになります(注: いろいろ端折っています)。

import dis
import inspect

def find_importer_frame():
    frame = inspect.currentframe()
    while frame:
        code = frame.f_code
        lasti = frame.f_lasti
        if code.co_code[lasti] == dis.opmap['IMPORT_NAME']:
            arg = code.co_code[lasti + 1] + code.co_code[lasti + 2] * 256
            name = code.co_names[arg]
            if name == 'end':
                break
        frame = frame.f_back
    return frame

スタックフレームが特定できれば、ソースコードを取得するのは inspect.getsource(frame) を呼び出すだけで終わりです。

ソースコードの解析

ソースコードが手に入ったので、あとはこれを解析して、end が正しく置かれているかをチェックします。

Python には標準で Python ソースコードを解析して AST (Abstract Syntax Tree) を出力する ast モジュールバンドルされています。これを使うことでコードの解析を手軽に行なうことができます。

たとえばこのようなシンプルなソースコードを考えましょう。

def main():
    for i in range(3):
        print(i)
    print('done')

これは ast.parse() により次のような AST に変換されます。

Module(
  body=[
    FunctionDef(
      name='main',
      args=arguments(args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]),
      body=[
        For(
          target=Name(id='i', ctx=Store()),
          iter=Call(func=Name(id='range', ctx=Load()), args=[Num(n=3)], keywords=[]),
          body=[
            Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Name(id='i', ctx=Load())], keywords=[])),
          ],
          orelse=[]),
        Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Str(s='done')], keywords=[])),
       ],
       decorator_list=[],
       returns=None),
  ],
)

Module.body や FunctionDef.body がブロックの中身に対応しています。また、単に end と書くと AST では Expr(value=Name(id='end', ctx=Load())) と表現されます。よって、ブロックを見つけたらその次に end が来ること、またそれ以外のところで end が単体で現れないことを確認すれば終わりです。Python の文法要素すべてに対して場合分けをしたりしないといけないので地味に面倒な作業ではありますが、やるだけなので頑張ります。チェックに失敗した場合は SyntaxError を raise します。

以上で import 元の文法チェックが出来るようになりました!

だいたい完成

end.py が初めて import された時は、まず __import__() フックをインストールし、その場で呼び出し元の文法チェックを実行します。以降は import end が実行される度に __import__() フック内から文法チェックを呼び出します。

これでほとんど完成です。細かなところは端折っているので、詳しくは実際のソースコードをご覧ください: https://github.com/nya3jp/end/blob/master/end.py

落ち穂拾い

この end チェックは実は不完全で、正しく解析できないケースがあることが分かっています。一番の問題は一行にブロックを押し込めた時 (if a > 28: a = 3 など) の扱いで、Python物理行・論理行の仕様などを考えると字句解析レベルの情報が必要になり、ast モジュールの出力だけではどうやっても正しく解析できません。

あと、そもそもソースコードがない場合はどうにもなりません。コンパイル済みのバイトコードを実行している時とか、REPL を実行している時とかですね。REPL で使えないのはちょっと悲しい。

まとめ

以上、Python に end を導入する話でした。end がソースコードの見やすさに与える影響はさておいて、Python における(変な)メタプログラミングのサンプルとしては多少は役に立つかな? と思います。

ちなみに実際に end.py やそのユニットテストを end を使って書いてみたわけですが、感想としては、まぁ確かにブロックの終了は分かりやすくなるものの、すごく野暮ったくなるなという印象でした。別に Ruby を dis っているわけではなくて、見慣れていないせいで Python のコードっぽく見えないというか。あとエディタのシンタックスハイライトはことごとく end を無視するのでそれもマイナスですね。

そして念のため最後にひとこと: end を実用してはいけません。これを仕事のコードに導入したりすると同僚との関係が悪化すること請け合いです :)

参考文献

広告を非表示にする

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 が好きですよ。

参考文献

広告を非表示にする