pythonのimportシステム周りのことについてのメモ、あるいはimport fooとしたときに何が起きるかについて

あんまり丁寧に文章を書く気力が起きないので個人的なメモ。pythonのimport周りの話。(最後の実行例のgistはこちら)

(追記: 結局それなりに長くなった)

当初のお気持ち

はじめに

ある程度pythonのドキュメントを丁寧に読めば分かることではあるのだけれど。自分用の情報の整理のためにまとめる。

このあたりを読めば良い。

とくに1つ目のリンクでけっこう詳しく書かれているのだけれど。古い情報と今の情報が混在していて分かりづらい。

ドキュメントがむずかしい

以下の様なオブジェクトがいきなり出てきて何も知識なしに呼んでも正直何を言っているのかわからないと思う。

わからないオブジェクトたち

以下の様な役者がさらっとでてくる。

  • finder (path entry finder, meta path finder)
  • loader
  • importer
  • spec

わからない設定値たち

そしていきなり以下の様なsysモジュール以下の設定値のことが出てくる。

  • sys.path
  • sys.meta_path
  • sys.path_hooks
  • (sys.path_impoter_cache)

いきなり情報が溢れてわからない。この辺を把握できるような感じの概観を使むような説明を書いてメモしておく。

import fooしたときに起きること

pythonのコード上で以下の様なコードをインタプリタが解釈した時に何が起きるかということがあいまいにわかっていれば良い。なのでそのための説明を雑に加えてみる。

import foo

import fooが解釈されるとき裏側では以下の様な処理が行われている。とても簡略化された形では。

  1. sys.meta_path に登録されているFinderを順に実行していく
  2. Finderのfind_spec()を実行する
  3. Finderのfinder_spec()がNone以外のものだったらそのspecを使って以降の処理を進める
  4. specからLoaderを取り出す
  5. Loaderのcreate_module()でモジュールを作る
  6. Loaderのexec_module()を実行する

概要を擬似的なコードで

雑な疑似コードに落とすとこういう形(ちょっとだけ例外の扱いも追加した。完全に正確なものではない)。

# import foo で行われることを簡略化したコード

name = "foo"

# sys.modules["foo"] がない場合の処理

for finder in sys.meta_path:
    spec = finder.find_spec(name)
    if spec is None:
        continue

    # ゴニョゴニョ処理をする

    loader = spec.loader
    module = loader.create_module()

    # moduleがNoneだったらdefaultの方法でmoduleを作るなどゴニョゴニョ処理をする

    try:
        loader.exec_module(module)
        break
    except ImportError:
        continue
else:
    raise ModuleNotFoundError(name)

詳しい話はこの辺りにかかれているがたぶん最初に呼んでも分かりづらいかもしれない。

より詳細な疑似コードはこの辺り

ただし実際に行われているコードはこれよりも古い実装への対応なども含めてもう少し複雑。

import周りを理解するのに便利なモジュール群

以下の辺りのモジュールのコードを読むと良い。

  • importlib
  • importlib.util
  • importlib.machinery
  • importlib._bootstrap
  • impoortlib._bootstrap_external

基本的には裏側の実装が _bootstrap_bootstrap_external に書かれていて、それへの参照を用意するためのmoduleが importlib.machneryになっている。

finder,loader?

以下の様な対応関係になっている(説明は用語集のものを引用)。

  • finder -- インポートされているモジュールの loader の発見を試行するオブジェクトです。
  • loader -- モジュールをロードするオブジェクト。 load_module() という名前のメソッドを定義していなければなりません。ローダーは一般的に finder から返されます。
  • Importer = Finder + Loader

ドキュメントもabcも古い

幸いにも importlilb.abcにfinder,loaderのbase classが定義されているのでこれを参照すると便利。

と思いきや、ここで定義されているメソッド群は古い仕様を尊重したものなので具合が悪い。これが古い仕様の混在がドキュメントにも存在していてけっこうツライ。

importlib.abc:Finder <- builtins:object
    [method] find_module(self, fullname, path=None) # deprecated

importlib.abc:Loader <- builtins:object
    [method] create_module(self, spec)
    [method] load_module(self, fullname) # deprecated
    [method] module_repr(self, module) # deprecated

実際のfinder,loader

実際には以下の様なオブジェクトを期待している(後で余裕があればpepへのリンクを貼る)。

importlib.abc:Finder2 <- builtins:object
    [method] find_spec(self, fullname, path=None, target=None)

importlib.abc:Loader2 <- builtins:object
    [method] create_module(self, spec)
    [method] exec_module(self, module)

だいぶわかりやすくなった。そんなわけで以下の様なイメージで覚えてくれれば良い。

  • finder -- find_spec()を実装しているもの
  • loader -- create_module()とexec_module()を実装しているもの

新規に使う場合にはこういう捉え方で十分。これだけ覚えると良い。

finderからloaderへの受け渡し

実際先ほどのfinder,loaderの定義だけだとどうやってloaderが取り出されているかわからないと思う(実は用語集に言及があった(あとできづいた))。これはspecの作られ方を見てみると分かりやすい。

# 内部的には importlib._bootstrap.ModuleSpec
from importlib.machinery import ModuleSpec

spec = ModuleSpec(fullname, loader)
# spec.loder でloader が参照。

(後々の操作でloaderがNoneのときにはちょっとした探索がはしったりもするけれどそれは省略)

ここでimporterへの言及を。impoter = finder + loader というのはそういうことで典型的なimporterのfind_spec()は以下の様な実装になる。自分自身をloaderとして渡すということ(特に状態をもたない場合にはclassmethodとして実装されることもある)。

class MyImporter:
    def find_spec(self, fullname, path=None, target=None):
        loader = self
        return ModuleSpec(fullname, loader)

    # def create_module(self, spec) ...
    # def exec_module(self, module) ...

実際にimportの機能を提供しているもの

ようやく具体的な話になってきた。これまででloader,finderとspecの説明が続いてきたが各種設定値についての説明がまだ。以下のもののこと。

  • (sys.path)
  • sys.meta_path
  • sys.path_hooks
  • (sys.path_impoter_cache)

それぞれの意味について説明する前に現在のpythonの環境で設定されている値について見てみよう。

$ python -q
>>> import sys
>>> sys.meta_path
[
 <class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>
]
>>> sys.path_hooks
[
 <class 'zipimport.zipimporter'>,
 <function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x7f22f52e9598>
]

なにやらいろいろ登録されている。

通常使われるloader

そしてテキトウに re モジュールなどをimportして使われているloaderを調べる。

moduleの__loader__に利用したloaderが入る

>>> import re
>>> re.__loader__
<_frozen_importlib_external.SourceFileLoader object at 0x7f22f5155b38>

importlib.util.find_spec()でspecを取り出せる。

>>> import importlib.util as u
>>> u.find_spec("re")
ModuleSpec(name='re', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f22f5155b38>, origin='/usr/lib/python3.7/re.py')

通常はSourceFileLoaderが使われると思う(ということが分かった)。

色々なloader,finder

その他には以下の様なクラスが用意されている。それこそimportlib.machineryを見れば良い。いままでの説明があればなんとなく予想がつくと思う。

  • BuiltinImporter
  • FrozenImporter
  • WindowsRegistryFinder
  • PathFinder
  • FileFinder
  • SourceFileLoader
  • SourcelessFileLoader
  • ExtensionFileLoader

(その他LazyLoader が importlib.utilに存在するがdocumentに書いてある通り使いかたに注意が必要

通常使われているfinder

さて、loaderが分かったが、finderがわからない。またそれぞれとsys.meta_path, sys.path_hooksの対応がわからない。

重要なのはこの2つ。

  • PathFinder
  • FileFinder

それだけじゃどの設定値にどうやって設定されているのかわからないじゃん。と思ってしまう。実は以下の様な関係になっている。

めんどくさいので関係をコードで表す。

# import fooされたときに

name = "foo"  # module name

for finder in sys.meta_path:
    # ここで使われるfinderがPathFinder
    spec = finder.find_spec(name) # PathFinderのfind_spec()の中でFileLoaderが使われる

そして、PathFinderのfind_spec()の中で、sys.path_hooksに格納されているfinderを見る。

実際のコードはこんな感じ。

class PathFinder:
    """Meta path finder for sys.path and package __path__ attributes."""

    @classmethod
    def _path_hooks(cls, path):
        """Search sys.path_hooks for a finder for 'path'."""

# ...
        for hook in sys.path_hooks:
            try:
                return hook(path)
            except ImportError:
                continue
        else:
            return None

大抵の場合には、path_hooksで利用されるfinderはFileFinder。ただしsys.path_hooks に登録されているのは、これを生成するクロージャ関数になっている。

補足するとsys.pathを気にするのはFileFinderの実装。これで全体が分かる様になってきたと思う。FileFinderはファイルの拡張子とLoaderの対応を取るのでこれを元にファイルシステムを探索してファイルを読み込みむという流れになる(.pyと.pycあるいは.soとかの拡張子に対応したloaderが選択される)。

利用されるfinderのまとめ

つまり

  • PathFinder が sys.meta_path に (python本体のモジュールシステム)
  • FileFinder が sys.path_hooks に (こちらはPathFinderが利用する設定)

ということになる(正確にはsys.path_hooksに登録されるのはfinderのfactory)。

用語整理

そろそろドキュメント本体を読むのも手軽になってきたと思うので用語の整理。これは[用語集をそのまま持ってきても良い気がする

loader

モジュールをロードするオブジェクト。 load_module() という名前のメソッドを定義していなければなりません。ローダーは一般的に finder から返されます。詳細は PEP 302 を、 abstract base class については importlib.abc.Loader を参照してください。

finder

(ファインダ) インポートされているモジュールの loader の発見を試行するオブジェクトです。 Python 3.3 以降では 2 種類のファインダがあります。 sys.meta_path で使用される meta path finder と、 sys.path_hooks で使用される path entry finder です。

そして2つのfinderと言っていたものの名前もしっかりと用語集に存在していて以下のようなもの

  • meta path finder -- PathFinder がそのひとつ
  • path entry finder -- FileFinder がそのひとつ

meta path finder

sys.meta_path を検索して得られた finder. meta path finder は path entry finder と関係はありますが、別物です。 meta path finder が実装するメソッドについては importlib.abc.MetaPathFinder を参照してください。

path entry finder

sys.path_hooks にある callable (path entry hook) が返した finder です。与えられた path entry にあるモジュールを見つける方法を知っています。 パスエントリーファインダが実装するメソッドについては importlib.abc.PathEntryFinder を参照してください。

importシステムの拡張

何かしら実例もないと面白くないだろうということでちょっとしたimportシステムの拡張を作ってみる。

import foo で何が起きるか?

何を起こしても良いけれど、今回は存在しないファイルを指定してimportしても必ずdummyのmoduleが見つかってimportされるような拡張を考えてみる。

作るものは?

今回作るものは存在しないmoduleをimportした時に、dummy moduleが返され、そのdummy moduleはhello()という関数を1つ持っているというもの。

そしてhello()の戻り値はmodule名つきの文字列。以下の様なイメージ。

# xxx.py, yyy.py は存在しない
import xxx
import yyy

xxx.hello()  # => hello from xxx
yyy.hello()  # => hello from yyy

はい。

importer? loader? finder?

手軽な実装なのでimporterで良いはず。

実装

簡単なものなので実装は手軽に終わる。はい。

import textwrap
import sys
from importlib.machinery import ModuleSpec


class DummyImporter:  # importer = finder + loader
    @classmethod
    def find_spec(cls, fullname, path=None, target=None):
        loader = cls
        return ModuleSpec(fullname, loader)

    @classmethod
    def create_module(cls, spec):
        return None

    @classmethod
    def exec_module(cls, module):
        code = textwrap.dedent(
            """
        def hello():
            return f"hello from {__name__}"
        """
        )
        exec(code, module.__dict__)

finder, loaderを自作する時に注意点が幾つかある。以下のようなもの(詳しくはこの辺りにも書いてある)。

loader.exec_module()

  • python moduleの場合には module.__dict__ でexecするべき
  • 上手く動かない例外はぜんぶImportErrorにするべき

loader.create_module()

  • Noneを返せば良い感じにmoduleを作ってくれる
  • (内部的には types.ModuleType)

finder.find_spec()

  • importerとして扱うのならfind_spec()で返すloaderにselfを渡せば良い

はい。

sys.path_hooks, sys.meta_pathへの登録

作ったimporterを有効にするためにhookに登録する。今回の場合はsys.meta_pathの方が手軽ですね。。

sys.meta_path.append(DummyImporter)

実行してみる。

# xxx.py, yyy.py は存在しない
import xxx
import yyy

xxx.hello()  # => hello from xxx
yyy.hello()  # => hello from yyy

良さそう。手元で実行したい場合のgist

sys.path_hooksを使いたい場合

sys.path_hooksを使いたい場合には以下の様なコードが必要になる。

def make_finder(path):
    # print("@@", path)
    return DummyImporter


# 蛇足に理由を書いた
sys.path.append(".")
sys.path_hooks.insert(0, make_finder)

はい。

その他便利な説明の記事について

ちなみにimportシステムの拡張について、この記事の内容が全体を把握するのに手軽で便利だった。

pyfoの内部の話について書かれた内容っぽい。

github.com

clojure -> pythonの変換をするというのはちょっと面白い。

蛇足 find_spec()などについて

find_spec()などのbaseクラスで定義されていないメソッドについて、typeshedの方の型定義の方を覗いてみるのも良いかもしれない(例えばMetaPathFinderのあたり)。以下の様なコメントが書かれていてかなしい。

class MetaPathFinder(Finder):
    def find_module(self, fullname: str,
                    path: Optional[Sequence[_Path]]) -> Optional[Loader]:

    # Not defined on the actual class, but expected to exist.
    def find_spec(
        self, fullname: str, path: Optional[Sequence[_Path]],
        target: Optional[types.ModuleType] = ...
    ) -> Optional[ModuleSpec]:
...

これはなぜかと言うと既存のコードを補助するために find_spec() の存在の有無で分岐しているため。abcのbase classでfind_spec()を定義してしまうとその分岐が動かなくなってしまうという判断から(レガシーとの付き合いという感じ)。

/usr/lib/python3.7/importlib/_bootstap_external.py

class PathFinder:
    """Meta path finder for sys.path and package __path__ attributes."""

# ...

    @classmethod
    def _get_spec(cls, fullname, path, target=None):
        """Find the loader or namespace_path for this module/package name."""

# ...

                if hasattr(finder, 'find_spec'):
                    spec = finder.find_spec(fullname, target)
                else:
                    spec = cls._legacy_get_spec(fullname, finder)

defaultの実装だけを気にするなら、実行しようとしてみてNotImplementedを拾って古い方の処理に分岐というやり方もありかもだけど(2.xを特別扱いしたくないですよね的な気持ち)。そうしてしまうと古い方に対応した外部のコードでNotImplementedを拾えない場合がありそうで怖いと言うような話(と理解している)。

蛇足 FileFinderがどうやって利用するloaderを選択しているか

実際初期化時に呼ばれるコードは以下のようなもの。そんなわけで.pyと.pycや拡張モジュールの読み込みで使われるloaderが変わる。

/usr/lib/python3.7/importlib/_bootstap_external.py

def _install(_bootstrap_module):
    """Install the path-based import components."""
    _setup(_bootstrap_module)
    supported_loaders = _get_supported_file_loaders()
    sys.path_hooks.extend([FileFinder.path_hook(*supported_loaders)])
    sys.meta_path.append(PathFinder)


def _get_supported_file_loaders():
    """Returns a list of file-based module loaders.

    Each item is a tuple (loader, suffixes).
    """

    # '.abi3.so', '.so' とか(環境によって異なる)
    extensions = ExtensionFileLoader, _imp.extension_suffixes()

    # .py
    source = SourceFileLoader, SOURCE_SUFFIXES

    # .pyc
    bytecode = SourcelessFileLoader, BYTECODE_SUFFIXES
    return [extensions, source, bytecode]

蛇足 FileLoaderのsys.pathを見る部分について

ここでファイルの存在を確認していた。つまりsys.path_hooksに追加するときにはここに注意。

/usr/lib/python3.7/importlib/_bootstap_external.py

class FileFinder:
    """File-based finder."""

# ...

    def find_spec(self, fullname, target=None):
        """Try to find a spec for the specified module."""

# ...

        # Check for a file w/ a proper suffix exists.
        for suffix, loader_class in self._loaders:
            full_path = _path_join(self.path, tail_module + suffix)
            _bootstrap._verbose_message('trying {}', full_path, verbosity=2)
            if cache_module + suffix in cache:
                if _path_isfile(full_path):
                    return self._get_spec(loader_class, fullname, full_path,
                                          None, target)


def _path_isfile(path):
    """Replacement for os.path.isfile."""
    return _path_is_mode_type(path, 0o100000)


def _path_is_mode_type(path, mode):
    """Test whether the path is the specified mode type."""
    try:
        stat_info = _path_stat(path)
    except OSError:
        return False
    return (stat_info.st_mode & 0o170000) == mode

参考文献