🐍

🐍 ファイルが見つからない! Python のファイルパス管理戦略

に公開

File not found! Python File Path Handling Strategies.

1. 概要 (Overview)

Python スクリプトでは動いてたのに、EXE にしたらファイルが見つからない!😥

・・・みたいなことは無いでしょうか?

Python でのファイルパスの管理について、私が実践している方法を紹介します。

Python スクリプトでの実行、EXE ファイルでの実行による違いや、
実行するカレントディレクトリの差異などにより、プログラム内で指定するファイルパスを
一意に決められません。

そのファイルパスを自動的に解決し、ファイルパスの管理をシンプルにする方法です。

2. ファイルパスの管理戦略 (File Path Handling Strategies)

  1. 汎用性のため、.env ではルート(/) からの相対パスで指定する
  2. 実行時の様々な状況でもファイルを見つけられるように、実行時の絶対パスを生成する
  3. 絶対パスを生成する関数を提供するモジュールを追加し、各モジュールはその関数を利用する
  4. 絶対パスの生成時に "実行時の状況" の差異を吸収する

2.1. 実行時の状況

実行形式 × 実行するカレントディレクトリ

実行形式 実行するカレントディレクトリ
Python スクリプト実行 プロジェクト直下
^ 対話モードや Notebook
PyInstaller でビルドした EXE ファイル実行 EXE ファイルがある場所 / EXE ファイルをダブルクリック
^ EXE ファイルとは別ディレクトリ
(コンソールで EXE ファイルのパス指定)

EXE ファイルでの実行時のデータの配置場所

データの配置場所 -
EXE ファイルの内部 実行時に一時ディレクトリに展開
(PyInstaller の --onefile--add-data オプションでビルド)
EXE ファイルの外部 _internal/ 内に配置
(PyInstaller の --onedir--add-data オプションでビルド)
^ Python スクリプト実行時と同じ位置関係に配置
(手動などで PyInstaller の管理外)

3. 実装例 (Implementation Example)

※以降は Python 3.12.10、pyinstaller 6.13.0 で動作を確認しています

ソースコードは GitHub で公開しています。

https://github.com/Bubbles877/python-utilities/tree/main/path_utils

3.1. 実行時の絶対パスを取得する

先ず、以下のようにプロジェクトのルートディレクトリからの相対パスを指定して、
絶対パスを生成する関数を呼び出せるようにすることを目指します。

import util.path_utils as path_utils


env_file_path = path_utils.runtime_path(".env")
setting_file_path = path_utils.runtime_path("data/setting.txt")

※相対パスは実践では .env で指定する想定です

runtime_path() では以下のように基準を変えながら絶対パスを生成し、
それが存在するか探索を試行します。

from pathlib import Path
from typing import Optional


def runtime_path(relative_path: str) -> Optional[str]:
    # 1. プロジェクトのルートディレクトリ基準
    path = Path(project_root()) / relative_path
    if path.exists():
        return str(path)

    # 2. EXE ファイル実行時の EXE 内部のルートディレクトリ基準
    if exe_internal_root_dir := exe_internal_root():
        path = Path(exe_internal_root_dir) / relative_path
        if path.exists():
            return str(path)

    # 3. カレントワーキングディレクトリ基準
    path = Path.cwd() / relative_path
    return str(path) if path.exists() else None

探索して見つかればその絶対パスを返し、見つからなければ None を返します。

3.2. プロジェクトのルートディレクトリの絶対パスを取得する

import sys
from functools import lru_cache
from pathlib import Path


@lru_cache(maxsize=1)
def project_root() -> str:
    if getattr(sys, "frozen", False):
        # PyInstaller でビルドされている場合 (EXE ファイル実行)
        return str(Path(sys.executable).resolve().parent)

    # 注: 後述
    if dir := _resolve_project_root_from_module_depth():
        return dir

    # 対話モードや Notebook で実行されている場合はカレントワーキングディレクトリとする
    return str(Path.cwd())

frozenTrue であれば EXE ファイル実行であることが分かります。
そして、EXE ファイル実行時は sys.executable で EXE ファイルの絶対パスが取得できます。

なお、プロジェクトのルートディレクトリの絶対パスはプログラム実行中には変化しないため、
最初の呼び出し結果を @lru_cache によりキャッシュし、再計算を防いで計算コストを
削減しています。

3.3. EXE 内部のルートディレクトリの絶対パスを取得する

import sys
from functools import lru_cache
from typing import Optional


@lru_cache(maxsize=1)
def exe_internal_root() -> Optional[str]:
    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
        return str(sys._MEIPASS)

    # ビルドされていない場合
    return None

EXE ファイル実行時は --onefile でも --onedir でも sys._MEIPASS
データのルートディレクトリが取得できます。

3.4. このモジュール基準でプロジェクトのルートディレクトリの絶対パスを解決して取得する

from functools import lru_cache
from pathlib import Path
from typing import Optional


@lru_cache(maxsize=1)
def _resolve_project_root_from_module_depth() -> Optional[str]:
    if globals().get("__file__") is None:
        # 対話モードや Notebook で実行されている場合
        return None

    dir = Path(__file__).resolve().parent

    for _ in range(_module_depth_from_project_root()):
        dir = dir.parent

    return str(dir)


@lru_cache(maxsize=1)
def _module_depth_from_project_root() -> int:
    # e.g. "util.path_utils" -> ["util", "path_utils"]
    package_parts = __name__.split(".")
    return len(package_parts) - 1

これはプロジェクトのルートディレクトリからの、このモジュールの階層数を計算しています。
e.g. util/path_utils.py -> 1

こうすることで、このモジュールファイルをどこに配置していても対応できるようにしています。

そして、その階層数だけ親ディレクトリを辿ることで、プロジェクトのルートディレクトリが取得できます。

以上の処理で全てとなります。

補足

・・・ただし、このやり方には注意があります。
"プロジェクトのルートディレクトリ = パッケージの親ディレクトリ" という前提にしており、
仮にもし設定でパッケージのルートを変えている場合には対応していません。

その場合の対応としては、プロジェクトのルートディレクトリを示す "マーカーファイル" を決めて、
親ディレクトリを順に辿りながらそれを探す、という方法もあります。

4. まとめ (Conclusion)

Python でのファイルパスの管理について、私が実践している方法を紹介しました。

実行時の様々な状況でもファイルを見つけられるように、実行時のファイルパスを自動的に解決し、
.env ではルート(/) からの相対パスで指定することでシンプルに管理できるようにしています。

特に EXE ファイル化した際にもファイルパスの管理を変えることなく、ファイルが見つかるようになります。😄

関連・参考 (References)

Discussion