Python 3.15のlazy importを試す|CSV集計CLIの起動とエラーを比較

「読み込みは、使うとき。」の見出しとPython 3.15のlazy importをCSV集計CLIで試すテーマを示したアイキャッチ Python
カテゴリー
Python
公開日
2026.09.09

はじめに

Pythonのコマンドで--helpを見たいだけなのに、集計用のモジュールまで読み込んでいることがあります。Python 3.15のlazy importは、この読み込みを名前が初めて使われるところまで遅らせる構文です。

この記事では、関数とimportを学んだ人向けに、小さなCSV集計CLIを作ります。CLIとは、ターミナルからコマンドで操作するプログラムのことです。同じ処理を通常のimport・関数内import・lazy importで動かし、読み込みのタイミング、実行時間、依存ファイルがないときの違いを確かめます。

先に結果を伝えると、この小さなCLIではlazy importによる明確な速度優位は確認できませんでした。一方、ヘルプ表示では集計モジュールを読み込まず、必要な処理で初めて読み込む動きは確認できました。速さを約束する機能としてではなく、自分のコマンドで採用するかを判断するために試していきます。

作るもの:カテゴリ別にCSVを集計するCLI

作るのは、UTF-8の売上CSVを読み、カテゴリごとに金額を足すコマンドです。金額は円単位の整数とし、負数は返品などの差し引きとしてそのまま集計します。

入力はcategory,amount、出力はcategory,totalです。カテゴリ名を並べ替えて出力するため、元のCSVの登場順とは異なる場合があります。ファイルの書き換えや外部通信は行いません。

3方式で集計の中身を共通にするため、次のファイルを使います。

lazy-csv-lab/
  sales.csv          入力データ
  report.py          CSVの読み込みと集計
  cli_common.py      引数の処理と出力
  eager_cli.py       通常のimport
  local_cli.py       関数内import
  lazy_cli.py        lazy import
  benchmark.py       最後に使う時間測定用コード

仕組み:読み込みをどこまで遅らせるか

モジュールの読み込みでは、そのファイルのトップレベルにあるコードが実行されます。関数を定義するだけでなく、別モジュールのimportや、登録処理が走ることもあります。

方式 reportを読み込むタイミング このCLIの--help
通常のimport ファイル先頭のimport reportを実行したとき 読み込む
関数内import 集計用の関数内でimport reportを実行したとき 読み込まない
lazy import reportという名前を初めて使ったとき 読み込まない

lazy importは、読み込み処理をバックグラウンドで進める機能ではありません。読み込みを先送りし、必要になった場面で実行します。正式な仕様はPythonのLazy importsリファレンスで説明されています。

必要な環境

この教材で使用したのは、Windows 11 x64、PowerShell、通常のGILありビルドのCPython 3.15.0rc2です。外部ライブラリや有料サービスは使いません。3.15.0rc2は正式版前のリリース候補なので、学習用の環境を作って試します。

Python 3.15がまだない場合は、公式の3.15.0rc2配布ページからWindows installer (64-bit)を取得してインストールします。既存のPythonとは別のフォルダーを選び、普段使うPythonのPATHを置き換える設定は加えません。公式発表では正式版は10月1日の予定とされていますが、この記事の実行結果はRC2のものです。

従来のWindows用Pythonランチャーがある環境では、PowerShellで次を確認できます。

py -3.15 --version
Python 3.15.0rc2

pyが見つからない場合やPython Install Managerを使っている場合は、インストールした3.15のpython.exeのフルパスを使ってください。以降のpy -3.15を、& "実際のインストール先\python.exe"に置き換えられます。既存のPythonの選ばれ方はインストール方法によって変わるので、普段の作業でもバージョンを明示すると混同しにくくなります。

新しい学習用フォルダーを作り、仮想環境を用意します。同名の作業フォルダーが既にある場合は別名を選んでください。

mkdir lazy-csv-lab
cd lazy-csv-lab
py -3.15 -m venv --without-pip .venv
& .\.venv\Scripts\python.exe --version

追加パッケージが不要なので、pipを省く--without-pipを付けています。仮想環境のactivateは使わず、毎回そのPythonを直接実行します。仮想環境の基本はvenv・pipの入門記事で補えます。

手順1:入力CSVと集計モジュールを作る

sales.csv:カテゴリと金額を保存する

エディターで次の内容をsales.csvへ保存します。文字コードはUTF-8にしてください。UTF-8 BOM付きでも読み込めます。

category,amount
書籍,1200
文房具,500
書籍,1800
文房具,700

report.py:読み込み・入力確認・集計をまとめる

import csv
import io


def _summarize(filename):
    totals = {}
    with open(filename, encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file, strict=True)
        if reader.fieldnames != ["category", "amount"]:
            raise ValueError("ヘッダーはcategory,amountの順で指定してください")
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError(f"{reader.line_num}行目: 列数が一致しません")
            category = row["category"].strip()
            if not category:
                raise ValueError(f"{reader.line_num}行目: categoryが空です")
            try:
                amount = int(row["amount"])
            except ValueError as error:
                raise ValueError(
                    f"{reader.line_num}行目: amountは整数で指定してください"
                ) from error
            totals[category] = totals.get(category, 0) + amount

    output = io.StringIO(newline="")
    writer = csv.writer(output, lineterminator="\n")
    writer.writerow(["category", "total"])
    writer.writerows(sorted(totals.items()))
    return output.getvalue()


def summarize(filename):
    try:
        return _summarize(filename)
    except csv.Error as error:
        raise ValueError(f"CSVの形式が不正です: {error}") from error

csv.DictReaderは、ヘッダーをキーにして各行を辞書として読みます。今回はヘッダーの順序もcategory,amountに固定しています。列名が違うCSVを自動で推測する教材ではありません。

金額はint()で整数に変換します。1.51,200のような表記には対応しません。文字列のまま足してしまう失敗を防ぎ、カテゴリ別の合計を辞書totalsにためます。カテゴリ名の前後の空白は除き、空のカテゴリは入力エラーにします。

出力にもCSVのライターを使うため、カテゴリ名にカンマがあっても適切に引用されます。_summarize()が内部処理、summarize()が呼び出し口です。最後の関数では、CSVの引用符などの形式エラーをValueErrorへ変換し、CLI側でまとめて説明できるようにしています。

cli_common.py:引数を読んでから集計する

import argparse
import sys


def parse_args():
    parser = argparse.ArgumentParser(
        description="category,amount形式のCSVをカテゴリ別に集計します"
    )
    parser.add_argument("filename", help="UTF-8のCSVファイル")
    return parser.parse_args()


def run(summarize):
    args = parse_args()
    try:
        output = summarize(args.filename)
    except (OSError, ValueError) as error:
        print(f"入力エラー: {error}", file=sys.stderr)
        return 2
    print(output, end="")
    return 0

argparseはコマンドの引数を処理する標準ライブラリです。--helpが渡されると、parse_args()の中でヘルプを表示して正常終了するため、その後の集計には進みません。

run()は集計関数を引数で受け取ります。OSErrorはファイルがない・開けない場合など、ValueErrorはこの教材の入力不正を扱います。説明は標準エラー、集計結果は標準出力へ分け、入力エラー時は終了コード2を返します。

手順2:importの違う3つの入口を作る

eager_cli.py:先に読み込む

import report
from cli_common import run


def summarize(filename):
    return report.summarize(filename)


if __name__ == "__main__":
    raise SystemExit(run(summarize))

最初のimport reportで集計モジュールを読み込みます。ヘルプを処理する前なので、集計する予定がなくても読み込みは発生します。

local_cli.py:関数の中へ移す

from cli_common import run


def summarize(filename):
    import report

    return report.summarize(filename)


if __name__ == "__main__":
    raise SystemExit(run(summarize))

summarize()が呼ばれるまではimport reportへ到達しません。これはPython 3.15専用の機能ではなく、従来のPythonでも使える書き方です。

lazy_cli.py:モジュール先頭で遅延を宣言する

lazy import report
from cli_common import run


def summarize(filename):
    return report.summarize(filename)


if __name__ == "__main__":
    raise SystemExit(run(summarize))

lazy import reportで遅延させ、report.summarize(filename)へ進んだときに読み込みます。run()へ渡しているのは、このファイルで定義した小さなラッパー関数です。ラッパーとは、別の処理を包んで呼ぶ関数を指します。

落とし穴:report.summarizeを先に取り出さない

次のように短くすると、ヘルプより先にreportを使ってしまいます。

lazy import report
from cli_common import run

raise SystemExit(run(report.summarize))

関数の引数は、関数が呼ばれる前に評価されます。run()に入る前にreport.summarizeを取り出すので、そこで読み込みが発生します。lazyを付けた事実だけでなく、名前を初めて使う位置まで追うことが大切です。この例でも、--help時にモジュールの初期化処理が走ることを確認しました。

手順3:集計結果とヘルプを確認する

3つのコマンドを同じフォルダーで実行します。

& .\.venv\Scripts\python.exe eager_cli.py sales.csv
& .\.venv\Scripts\python.exe local_cli.py sales.csv
& .\.venv\Scripts\python.exe lazy_cli.py sales.csv

どれも次の結果になります。

category,total
文房具,1200
書籍,3000

続けてヘルプを表示します。

& .\.venv\Scripts\python.exe lazy_cli.py --help
usage: lazy_cli.py [-h] filename

category,amount形式のCSVをカテゴリ別に集計します

positional arguments:
  filename    UTF-8のCSVファイル

options:
  -h, --help  show this help message and exit

eager_cli.pylocal_cli.pyでも--helpを試せます。説明文は同じで、usageのファイル名が変わります。

動作確認:読み込み・エラー・時間を分ける

集計モジュールがないとき

依存不足は、元のファイルを削除せず、別の新しいフォルダーで試せます。missing-report-testなどの空のフォルダーへ、次の5ファイルだけをコピーします。

  • cli_common.py
  • eager_cli.py
  • local_cli.py
  • lazy_cli.py
  • sales.csv

report.py__pycache__はコピーしません。Pythonは元の仮想環境を使い、たとえば学習用フォルダー直下にテストフォルダーを作った場合は、次を元の学習用フォルダーで実行できます。

& .\.venv\Scripts\python.exe .\missing-report-test\eager_cli.py --help
& .\.venv\Scripts\python.exe .\missing-report-test\local_cli.py --help
& .\.venv\Scripts\python.exe .\missing-report-test\lazy_cli.py --help
& .\.venv\Scripts\python.exe .\missing-report-test\lazy_cli.py .\missing-report-test\sales.csv
操作 通常のimport 関数内import lazy import
--help ModuleNotFoundErrorで終了 ヘルプを表示 ヘルプを表示
CSV集計 ModuleNotFoundErrorで終了 集計関数へ進んでから同エラー reportを使う位置で同エラー

最後のエラーは、次の文で終わります。

ModuleNotFoundError: No module named 'report'

遅延させても、必要な依存がなくてよくなるわけではありません。エラーが現れる場所が変わります。元の教材フォルダーへ戻れば、ファイルを復元することなく正常な集計を再開できます。

このCLIはImportErrorを一括で握りつぶしません。実際のアプリで任意依存を扱うなら、使う場面で不足を説明する設計を加えます。利用者の入力エラーと、コードや依存環境の問題を分けてください。

CSVの入力エラー

別名のCSVを作り、次のケースを試してください。ヘッダーだけのCSVは空の集計として扱い、category,totalだけを出力します。

入力・操作 結果
amount1.5 整数で指定するよう説明し、終了コード2
カテゴリが空白だけ カテゴリが空と説明し、終了コード2
行の列数が不足・過剰 列数の不一致として終了コード2
閉じていない引用符 CSVの形式エラーとして終了コード2
存在しないファイル 入力エラーとして終了コード2

終了コードはPowerShellの$LASTEXITCODEで確認できます。日本語、UTF-8 BOM、引用されたカンマ入りカテゴリ、負の金額、文字コードの不一致についても確認しました。これは提示したサンプルの検証で、あらゆるCSVを受け付けるという意味ではありません。

起動から終了までの時間を測る

以下をbenchmark.pyとして保存します。3方式それぞれについてヘルプ表示と4行のCSV集計を測り、各条件を3回ずつ予備実行してから30回ずつ実行します。順序を混ぜ、毎回新しいPythonプロセスを起動します。

import json
import os
import platform
import random
import statistics
import subprocess
import sys
import time
from pathlib import Path


def main():
    root = Path(__file__).resolve().parent
    cases = [
        (name, task, arguments)
        for task, arguments in [("help", ["--help"]), ("csv", ["sales.csv"])]
        for name in ["eager", "local", "lazy"]
    ]
    results = {f"{name}/{task}": [] for name, task, _ in cases}
    environment = dict(os.environ, PYTHONIOENCODING="utf-8", NO_COLOR="1")

    def measure(case):
        name, task, arguments = case
        start = time.perf_counter_ns()
        process = subprocess.run(
            [sys.executable, f"{name}_cli.py", *arguments],
            cwd=root, env=environment, capture_output=True,
        )
        elapsed = (time.perf_counter_ns() - start) / 1_000_000
        if process.returncode != 0:
            raise RuntimeError(process.stderr.decode("utf-8", errors="replace"))
        return elapsed

    for _ in range(3):
        for case in cases:
            measure(case)
    order = random.Random(315)
    for _ in range(30):
        order.shuffle(cases)
        for case in cases:
            results[f"{case[0]}/{case[1]}"].append(measure(case))
    summary = {}
    for key, values in results.items():
        deciles = statistics.quantiles(values, n=10, method="inclusive")
        summary[key] = {
            "median_ms": round(statistics.median(values), 3),
            "p10_ms": round(deciles[0], 3),
            "p90_ms": round(deciles[8], 3),
        }
    print(json.dumps({
        "python": sys.version, "platform": platform.platform(),
        "rounds": 30, "warmups_per_case": 3,
        "summary": summary, "raw_ms": results,
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
& .\.venv\Scripts\python.exe benchmark.py

JSONのsummaryに中央値と10・90パーセンタイル、raw_msに各回の測定値が出ます。10〜90パーセンタイルは中央付近の測定値の広がりを見るもので、信頼区間ではありません。

Windows 11、Core i7-13620H、CPython 3.15.0rc2で得た結果は次のとおりです。単位はミリ秒です。

処理 方式 中央値 10〜90パーセンタイル
ヘルプ 通常 73.78 70.33〜77.04
ヘルプ 関数内 71.88 66.10〜77.03
ヘルプ lazy 71.30 67.25〜78.72
CSV集計 通常 59.02 54.89〜63.79
CSV集計 関数内 60.48 56.52〜63.87
CSV集計 lazy 58.39 55.35〜63.74

測っているのは、親プロセスからの起動要求から子プロセスの終了・出力回収までです。Pythonの起動、import、引数処理、CSV読み込み、結果の生成・出力も含み、import単体の時間ではありません。OSのファイルキャッシュや生成済みのバイトコードを消していないため、端末起動直後の初回実行を再現する測定でもありません。

今回の値は方式間のばらつきが重なっており、lazy importが一貫して速いとは判断できません。ヘルプと集計では仕事の内容も出力量も異なるため、両者の時間の大小をそのままimportの効果とみなすこともできません。

別の確認では、sys.modulesreportが登録されるかを観察し、通常importはヘルプでも読み込む一方、残り2方式は集計時だけ読み込むことを確かめました。また、テスト用コピーのモジュール先頭へ目印を出す処理を加え、初期化の副作用も同じタイミングで実行されることを確認しています。測定用の本体には、この目印や人工的な待ち時間は入れていません。

トラブル対処

lazy importがSyntaxErrorになる

まず仮想環境のpython.exe --versionで3.15系か確認します。Python 3.13では、--helpだけを指定してもlazy構文の解析で失敗しました。関数内import版と通常版は同じ3.13環境でも集計できました。

3.15でも、次のように関数内にlazy importを書くと構文エラーです。

def summarize(filename):
    lazy import report

lazy importはモジュールのトップレベルで使います。関数・クラス・tryブロックの中や、lazy from csv import *には書けません。関数の中へ遅らせるなら、local_cli.pyのように通常のimportを使います。

ヘルプは動くのに、集計するとModuleNotFoundErrorになる

遅延されたモジュールは、ヘルプの成功だけでは読み込めたか分かりません。report.pyが入口のファイルと同じフォルダーにあるか、拡張子が.py.txtになっていないか確認し、実際の集計まで試します。

lazyを付けたのに先に読み込まれる

run(report.summarize)のような属性の取得、デバッグのためのprint(report)など、最初の名前の利用を探します。別のモジュールが先に通常importしている場合も、既に読み込み済みになります。

日本語CSVを開くと入力エラーになる

このコードはUTF-8専用です。エディターで文字コードを確認し、元データを残したまま別ファイルとしてUTF-8で保存してください。出所不明の文字コードを自動推定する処理は含めていません。

制約と、採用を見送る場面

  • 読み込みが遅れるだけなので、実処理で必要なモジュールの読み込み時間はなくなりません。
  • import時にプラグイン登録や設定変更を行うコードでは、その実行時期も変わります。順序に依存している場合は通常importを残す判断が必要です。
  • 初回利用の待ち時間が重要な処理では、その地点の応答も測定します。今回の表だけでは評価できません。
  • 旧版Pythonへの対応が必要なら、今回の関数内importも候補です。この教材では互換用の別機能や一括遅延設定まで扱いません。
  • 小さいCLIで全依存を毎回使うなら、複雑さを増やすほどの効果がない場合があります。

この集計処理はカテゴリごとの合計をメモリに保持します。列構成が月ごとに違うデータ、巨大ファイルの性能比較、小数や桁区切り付きの金額などは対象外です。

まとめ

Python 3.15のlazy importでは、先頭にimportの宣言を置いたまま、モジュールの読み込みを最初の利用まで遅らせられます。今回のCSV集計CLIでは、ヘルプ時の不要な読み込みを避けられましたが、明確な速度優位は確認できませんでした。

使うかどうかは、読み込みが不要な経路があるか、依存不足をどこで知らせたいか、初期化の副作用に問題がないかで判断します。関数内importでも目的を満たせるなら、その方法も比較してください。

次は自分のCLIで、ヘルプ表示と実処理を一つずつ選び、importが必要になる場所を追ってみてください。基礎を振り返るならモジュールとパッケージ、エラーを扱う設計は例外処理へ進めます。

参考リンク

コメント

タイトルとURLをコピーしました