pandasを使うCLIでtool versionを実行すると、バージョンを表示するだけなのに0.3秒ほど待たされる。ファイルの先頭にあるimport pandasが、使わないサブコマンドでも毎回実行されるからだ。

これまではimportを関数の中に移して回避していたが、依存関係がファイルのあちこちに散らばって読みにくくなる。3.15で入ったlazy import(PEP 810)を使うと、importはファイルの先頭に書いたまま、読み込みを初めて使う時点まで遅らせられる。

使い方

importの前にlazyを付けるだけでよい。

import argparse
 
lazy import pandas as pd
 
 
def report(path: str) -> None:
    df = pd.read_csv(path)  # ここで初めてpandasが読み込まれる
    print(df.describe())
 
 
def main() -> None:
    parser = argparse.ArgumentParser(prog="tool")
    sub = parser.add_subparsers(dest="cmd", required=True)
    sub.add_parser("version")
    r = sub.add_parser("report")
    r.add_argument("path")
    args = parser.parse_args()
    if args.cmd == "version":
        print("tool 1.0")
    else:
        report(args.path)
 
 
main()

tool versionの起動時間を7回ずつ測った中央値は次のとおり(pandas 3.0.6)。

tool versionの起動時間
import pandas as pd322ms
lazy import pandas as pd39ms

-X importtimeで見ると、pandasの読み込みだけで約245msかかっていた。lazy版ではversionの実行中にpandasが一度も読み込まれない。

lazy from decimal import Decimalのようにfromの形でも使える。lazyはソフトキーワードなので、lazy = 1のような既存の変数名はそのまま使える。

中身はプロキシオブジェクト

lazy importした名前には、最初はtypes.LazyImportTypeのプロキシが入っている。初めて属性にアクセスした時点で本物のモジュールが読み込まれ、名前が差し替わる。

lazy import json
 
type(globals()["json"]).__name__  # 'lazy_import'
"json" in sys.modules             # False
json.dumps({})
type(globals()["json"]).__name__  # 'module'
"json" in sys.modules             # True

読み込みの失敗は使うときに出る

存在しないモジュールをlazy importしても、その行ではエラーにならない。初めて使った時点で例外になり、トレースバックにはimportした行と使った行の両方が出る。

started
Traceback (most recent call last):
  File "missing.py", line 1, in <module>
    lazy import not_installed_pkg
ImportError: lazy import of 'not_installed_pkg' raised an exception during resolution
 
The above exception was the direct cause of the following exception:
 
Traceback (most recent call last):
  File "missing.py", line 10, in <module>
    run()
  File "missing.py", line 7, in run
    return not_installed_pkg.do()
           ^^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'not_installed_pkg'

起動時に依存関係の欠けを検出していたCLIでは、その確認が実際に使う場面まで遅れることになる。

書ける場所の制限

lazy importを書けるのはモジュールの最上位だけで、次の場所ではSyntaxErrorになる。

SyntaxError: lazy import not allowed inside functions
SyntaxError: lazy import not allowed inside classes
SyntaxError: lazy import not allowed inside try/except blocks
SyntaxError: lazy from ... import * is not allowed

tryの中で書けないので、try: import ujson as json / except ImportError: import jsonのような「あれば使う」書き方には使えない。

ハマりどころ: importするだけで登録するプラグイン

一番気をつけたいのは、importしたときの副作用に頼っているコードだ。デコレーターで自分をレジストリに登録するプラグインは、importされないと登録されない。

# app/plugins/csv_export.py
from app.registry import register
 
@register("csv")
def export_csv(rows): ...
import app.plugins.csv_export  # 登録のためだけのimport
from app.registry import HANDLERS
print(sorted(HANDLERS))
$ python3.15 reg_eager.py
['csv']
$ python3.15 reg_lazy.py                 # lazy import app.plugins.csv_export にした版
[]
$ python3.15 -X lazy_imports=all reg_eager.py
[]

名前を一度も使わないので読み込みが起きず、エラーも出ないままハンドラーが空になる。-X lazy_imports=all(または環境変数PYTHON_LAZY_IMPORTS=all)で全体をまとめて遅延させたときも同じことが起きる。

全体を遅延させたいときは、sys.set_lazy_imports_filter()で対象を絞れる。フィルターは(importing, imported, fromlist)を受け取り、Trueを返したものだけが遅延される。

import sys
 
def only_app(importing, imported, fromlist):
    return imported.startswith("app.")
 
sys.set_lazy_imports_filter(only_app)
sys.set_lazy_imports("all")
 
import app.plugins.csv_export  # 遅延される
import json                    # すぐ読み込まれる

型注釈だけで使う名前

3.14から型注釈の評価が遅延されるようになったので、型注釈にしか出てこない名前は読み込まれない。

lazy from pandas import DataFrame
 
def summarize(df: DataFrame) -> int: ...
# この時点で "pandas" in sys.modules は False

ただし、annotationlib.get_annotations(summarize)のように型注釈を実行時に読むと、その時点でpandasが読み込まれる。型注釈を実行時に読むライブラリと組み合わせると、そのライブラリが読んだ時点で読み込まれる。

3.14以前と両立させる

lazy importは3.14ではSyntaxErrorになるので、そのファイルは3.14で動かない。両方で動かしたい場合は、モジュールに__lazy_modules__を定義する。ここに名前を挙げたモジュールは、普通のimport文でも3.15では遅延される。3.14ではただの変数として無視される。

__lazy_modules__ = ["json"]
import json
3.14.8    json loaded right after import: True
3.15.0rc3 json loaded right after import: False

References