⚙️

【Python】import system の全体像

2024/01/27に公開

概要

Python の import 文は、プログラムの実行中に他のモジュールを読みこむために使われます。
おおまかな import 文の動作として、sys.path から指定された名前のモジュールを探して見つかったものを読みこむというように理解している人が多いのではないかと思います。
この理解は概ね正しいですが、実際には sys.path の探索は import system の一部分でしかなく、他の機構が用いられることもあります。さらにはカスタマイズした実装を使って import system の動作を拡張したり置き替えたりすることさえ可能です。

Python の import system は import 文の単純そうな文法からすると意外なほどに複雑です。この記事では import system の仕組みについて、図を交えながら全体像をできるだけわかりやすく説明することを目指しました。

対象読者

  • Python のモジュールや import について理解を深めたい。
  • Import system を拡張したい、あるいは import system に変更を加えているコードの動作を理解したい。[1]
  • Import system の Language Refernce より読みやすい説明を探している。

この記事では基本的に Import system の動作について全体像をおおまかに理解することを目標とします。正直あまり頻繁に役に立つ知識ではないかもしれませんが、import まわりのトラブルシューティングや、動的なモジュール名に対する import などやや変則的な場面では知っておいて損は無いでしょう。
実用性は置いといても、Python 言語そのものに興味がある人にとっては面白く読めるのではないかと思います。

__import__()

Import 文の実行は、内部的には __import__() という組み込み関数の呼び出しによって行われます。例えば、

import foo

は基本的に以下と等価です。

foo = __import__("foo", globals(), locals(), [], 0)

from 句を含む import (e.g. from foo import bar) や相対 import (e.g. from . import foo) では、呼び出しの形式はやや変わるものの、いずれも適切な形式で __import__() の呼び出しが行われます。
次節からは、標準の import 機構すなわち __import__() の内部的な動作についてさらに詳しく見ていきます。

Import system の概観 - Finder と Loader

Import system の機構は主に

  • (ファイルシステムなどから) モジュールを探索する finder
  • モジュールオブジェクトの作成・実行を行う loader

の 2つによって構成されます。__import__() は内部で finder や loader を適切に呼びだすことによってモジュールオブジェクトを作成し、呼び出し元に返します。
以下の図に、これらの関係について概略を示します。

文章で示すと以下のような流れです。

  • Finder の find_spec() メソッドを呼びだして (ファイルシステムなどから) モジュールを探索する。
  • Loader の create_module() メソッドを呼びだしてモジュールオブジェクトを作成する。
  • Loader の exec_module() メソッドを呼び出してモジュール (.py ファイルなど) の中身を実行する。

なお、finder と loader の両方の役割を実装したオブジェクトは importer と呼びます。
以下、各部分についてさらに詳しく見ていきます。

sys.meta_path

まず __import__() はどこから finder をもってくるのでしょうか? その答えは、sys.meta_path という(meta path) finder のリストです。sys.meta_path はデフォルトでは以下のような内容になっています[2]

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib_external.PathFinder'>]
  • BuiltinImporter (builtin module を探す)
  • FrozenImporter (frozen module を探す[3])
  • PathFinder (ファイルシステム等のパスから module を探す)

の 3つがデフォルトで定義されている finder です。__import__()sys.meta_path に入っている finder を順番に使い、ある finder でモジュールが見つからなければ次の finder を試していきます。PathFinder は最も一般的に使われる finder であり、かつ動作がやや複雑です。PathFinder の動作は後から詳しく説明します。

sys.meta_path にカスタムの meta path finder を登録することでモジュールの探索方法を変更することができます。BuiltinImporter より前にカスタムの finder を置いてしまえば built-in module の名前に対する import を乗っ取ってしまうことさえ可能です。

find_spec()

各 finder は find_spec() というメソッドを定義していて、このメソッドの呼び出しによってモジュールの探索を行います。

find_spec(fullname, path, target=None)

fullname は import したいモジュールの名前です (e.g. "foo.bar.baz")。path は親モジュール (パッケージ) のない top-level module への import なら None で、top-level でなければ親モジュールの path が渡されます。たとえば、import foo によって起こる find_spec() の呼び出しは find_spec("foo", None) のようになります。

target 引数について

find_spec()target 引数について、ドキュメントには以下のように書いてあります。

When passed in, target is a module object that the finder may use to make a more educated guess about what spec to return.

importlib の実装を見ると、importlib.reload() などでは reload 対象の module を target 引数を渡しているようですが、実際 finder の実装側では使われていないように見えます。基本的に気にしなくて良いでしょう。

find_spec() は成功した場合は ModuleSpec を返します。モジュールが見つからなければ None を返し、次の finder に探索を引き継ぎます。
ModuleSpec は module の名前 (name), パス (origin), モジュールの読み込みに使う loader (loader) などを含みます。ModuleSpec が loader を含むということは必然的に、見つかったモジュールをどのような手段でロードするべきか決定しておくのも finder の仕事ということになります。

create_module()

find_spec() が無事にモジュールを見つけて ModuleSpec を返してきたら、今度は ModuleSpec に基づいてモジュールオブジェクトをつくります。そのときに使われるのが loader の実装する create_module() メソッドです。

create_module(spec)

create_module はモジュールオブジェクトを直接返すか、あるいは None を返しても良いことになっています。ここでの None は失敗を意味するわけではなく、import 機構のデフォルトの方法で spec の情報からモジュールを初期化するという意味になります。

exec_module()

create_module() の時点ではまだモジュール内部に存在する関数や変数の定義は読みこまれていません。
モジュールの中身を実際に実行してこれらの定義をモジュールオブジェクトと紐付けるのは exec_module() の役割です。

exec_module(module)

__import__() の疑似コード

ここまででおおよそ __import__() の内部動作を説明しました。
ここまでのまとめとして、import foo という例について、__import__() の動作を単純化した疑似コードを以下に示します。

# Pick up meta path finder from sys.meta_path
for finder in sys.meta_path:
    # find module
    spec = finder.find_spec("foo", None)
    if spec is not None:
        break
if spec:
    # create module
    loader = spec.loader
    mod = loader.create_module(spec)
    if mod is None:
        mod = importlib.util.module_from_spec(spec)
    # execute module
    loader.execute_module(mod)
    return mod
# If no module found
raise ModuleNotFoundError

PathFinder の動作

ここまでで finder, loader からなる import system のおおまかな仕組みを説明しました。この節では標準の meta path finder のうち、与えられたパスからモジュールを探す PathFinder の動作を詳しく説明します。

以下の図は、PathFinder の動作をおおまかに表しています。

  • sys.path から取りだした検索対象の path を sys.path_hooks から取りだした path_hook に渡して PathEntryFinder をつくる。
  • PathEntryFinder の find_spec() メソッドにより実際の検索処理を行う。

PathFinder において、実際にそれぞれのパスの検索を担うのは PathFinder 本体ではなく PathEntryFinder というオブジェクトです。PathEntryFinder も find_spec() メソッドを実装していてこちらが .py ファイル等の実質的な検索処理を担います。

PathEntryFinder は、検索対象の path ごとに新しくつくられます。よく知られているように検索対象の path は sys.path から参照されます。PathEntryFinder をつくるための関数は sys.path_hooks というリストに格納されていて、sys.path_hooks から取りだした hook を hook(path) のように呼びだすことで PathEntryFinder がつくられます。

sys.path_hooks にカスタムの PathEntryFinder を返すようなフックを登録することで sys.path に登録されたパスに対するモジュールに対する検索をカスタマイズすることができます。標準の PathEntryFinder (FileFinder) はファイルシステム上のファイルしか検索しませんが、sys.path にはファイルシステムのパス以外の文字列を登録することも可能なので、カスタムの PathEntryFinder によって例えば URL のようなファイルシステム以外の検索ソースを扱うことも可能です。

PathFinder の疑似コード

以上をまとめると、PathFinder の find_spec() の単純化した動作は次のような疑似コードで表すことができます。

# search for sys.path
for p in sys.path:
    # try creating PathEntryFinder
    finder = None
    for hook in sys.path_hooks:
        try:
            finder = hook(p)
        except ImportError: 
            continue
    # call PathEntryFinder's find_spec()
    if finder:
        spec = finder.find_spec(fullname)
        if spec is not None:
            return spec
# if no module found
return None

キャッシュについて

ここまでは話を単純にするため触れてこなかったですが、実際には import system の処理の結果の一部はキャッシュされます。キャッシュがヒットした場合にはこれまで説明してきたような import system の動作を一部スキップしてキャッシュから結果を返すことがあります。

sys.modules

sys.modules は import 対象のモジュール名に対してモジュールオブジェクトをキャッシュします。実体としては、モジュール名をキーとしてモジュールオブジェクトを対応させるような辞書オブジェクトです。
sys.modules からモジュールが見つかれば import system は finder や loader を呼びだすことなくキャッシュ済みのモジュールオブジェクトを返します。これにより複数回同じモジュールの import を行った場合には同一のオブジェクトが返ってくることになり、モジュールの状態なども共有されます。

sys.path_importer_cache

sys.path_importer_cache は各 path に対して PathEntryFinder をキャッシュします。これにより、ファイルシステム上の検索結果などが再利用されることがあります。基本的にそれほど気にすることは多くないかと思いますが、sys.path_hooks にカスタムの hook を追加したりする際は少し注意が必要です。

おわりに

この記事では Python の import system における finder と loader の役割など、import system の全体像についておおまかに説明しました。おおよその全体像を頭に入れておくことで、公式リファレンスなどもだいぶ見通しよく読めるのではないかと思います。

個人的に Python のモジュールまわりはなんとなく理解がはっきりしていない部分があった感じがずっとしていたのですが、今回 import system について本腰を入れて調べたことでだいぶ解像度が上がったような感覚があります。
ときどき言語リファレンスをしっかり読んでみるのもやはりいろいろと学びがあって良いなと思いました。

関連記事・ドキュメント

この記事の内容は Language Reference の次のページにおおよそ含まれます。

https://docs.python.org/3/reference/import.html

Finder, Loader のインターフェースや、標準で使われる import system の実装は importlib に定義されています。

https://docs.python.org/3/library/importlib.html#module-importlib

Import system の解説として、日本語資料としては以下のスライドがあります。
前半部分で import hook について解説しています。

https://speakerdeck.com/knzm/python-module-import-system

実装面の解説を中心に行なっているものとしては以下があります。

https://qiita.com/yasuo-ozu/items/7e4ae538e11dd2d589a8

英語記事で import system について比較的詳細に解説しているものとして以下の2つを挙げておきます。

https://tenthousandmeters.com/blog/python-behind-the-scenes-11-how-the-python-import-system-works/

https://realpython.com/python-import/

脚注
  1. Import system を拡張する例としては標準ライブラリへの import を乗っ取る distutils hack などの例があります。実のところ筆者は distutils hack について動作を調べているうちにこの記事を書くに至りました。 ↩︎

  2. setuptools が入っている環境では DistutilsMetaFinder というのが最初に出てくるかもしれません。これは、setuptoolsdistutils への import を乗っ取るために追加しているものです (distutils hack)。python -S などで起動すると標準の 3つだけになると思います ↩︎

  3. Frozen module はここでは詳しく扱わないですが、おおまかには .py ファイルを事前にコンパイルしてつくられたモジュールのようです (正直筆者もあまり詳しくないです...)。 ↩︎

GitHubで編集を提案

Discussion