importlib.reload() によるリロード時の注意点 (ソースファイルのタイムスタンプとファイルサイズの変更がないと .pyc が再生成されない)

Python の実行中に import 済みのモジュール hoge のソースコードの変更を反映させたいというとき、[importlib.reload(hoge)](https://docs.python.org/ja/3.13/library/importlib.html#importlib.reload) [1] を使用してリロードすると思います。

しかし、ソースコードを変更した場合であっても、<INS>前回の import 時からソースファイルのタイムスタンプもサイズも変わらなかった場合</INS>、`__pycache__/` にキャッシュされたバイトコード `.pyc` の再生成が走らず、importlib.reload(hoge) は前回のままの `.pyc` を読み出すため、**モジュールに変更は反映されません**。

開発中に動作確認目的で importlib.reload(hoge) するときは上記の下線部の条件が満たされることはあまりないと思いますが、機械的にモジュールを書き換えるようなときには満たされ得ると思います。トラブルを避ける方法には、以下があると思います。

- そもそも、モジュールのリロードを機能として使用しようとすることは避けるべきです (モジュールのリロードは他にも想定しない動作を引き起こし得ます[^1])。
- やむを得ずテスト目的などでリロードを仕込む場合、以下の回避策があると思います。
  - 必ずキャッシュ `__pycache__/` を削除する。
  - 書き換えたモジュールのタイムスタンプを確実にずらす。
  - キャッシュを生成しない設定で Python を実行する (ただ、特定のテストだけこの設定にしづらいと思うし、パフォーマンス上推奨されないと思います)。
    - `python -B script.py` と実行するか `export PYTHONDONTWRITEBYTECODE=1` を立てて実行するとキャッシュは作成されません。

:::note info
※ 私の手元の Python のバージョンは 3.11 です。この記事の内容は importlib.reload() が追加された 3.4 以降には適合していると思いますが、何かあればご指摘ください。
:::

[^1]: 例えば、変数や関数やクラス (のデータメンバやメソッド) はリロードで削除されません [1]。古いモジュールにあった変数を消滅させたいなら `del` しなければなりません。しかし、副作用があるかもしれませんし、そのような管理をするくらいなら Python を実行したほうがよいと思います。

### 参考文献
1. https://docs.python.org/ja/3.13/library/importlib.html#importlib.reload
    - importlib.reload() のドキュメントです。
2. https://stackoverflow.com/questions/23775760/how-does-the-python-interpreter-know-when-to-compile-and-update-a-pyc-file
    - Python は `.pyc` を更新するタイミングをどうやって知るのかという質問であり、最多得票の svvac さんの回答に情報があります。

### 再現スクリプト

例えば以下のビフォーとアフターはファイルサイズが変わっていません。

```py:aaa/bbb/ccc.py (ビフォー)
def func():
    return 10
```
```py:aaa/bbb/ccc.py (アフター)
def func():
    return 11
```
なので、適当なディレクトリに以下の `script.py` を作成して実行するとすべての `assert` を通過します (隣に `./aaa/bbb/ccc.py` を作成し削除しますのでご注意ください)。注目してほしいのは、ケース3でモジュールをリロードしたのに変更が反映されていないことです。ケース4ではキャッシュを削除してからリロードしており、変更が反映されています。

他方、以下のいずれかの対応をすると、ケース3で期待通り `ccc.func() == 11` となります。
1. `python -B script.py` で実行します (キャッシュを生成しないモードにする)。
2. ケース3前でスリープします (ソースファイルのタイムスタンプをずらす)。

```py:script.py
import os
import shutil

def write_module(x):
    # aaa/bbb/ccc.py に数値 x を return するだけの関数 func() を書き込む
    with open('aaa/bbb/ccc.py', 'w') as f:
        f.write('def func():\n')
        f.write('    return ' + str(x) + '\n')

# aaa/ が既にあれば削除した後 aaa/bbb/ を作成しておく
if os.path.exists('aaa/'):
    shutil.rmtree('aaa/')
os.makedirs('aaa/bbb/')

# ----- ケース0: return 0 を書き込んで import ccc したら当然 ccc.func() は 0 である -----
write_module(0)
from aaa.bbb import ccc
assert ccc.func() == 0

# ----- ケース1: return 10 に書き換えて import ccc しても ccc.func() は 0 のままである -----
write_module(10)
from aaa.bbb import ccc
assert ccc.func() == 0

# ----- ケース2: reload(ccc) すると ccc.func() は 10 に更新される -----
write_module(10)
import importlib
importlib.reload(ccc)
assert ccc.func() == 10

# import time
# time.sleep(1)

# ----- ケース3: return 11 に書き換えて reload(ccc) しても ccc.func() は 10 のままである -----
write_module(11)
importlib.reload(ccc)
assert ccc.func() == 10

# ----- ケース4: __paycache__/ を消して reload(ccc) すると ccc.func() は 11 に更新される -----
write_module(11)
shutil.rmtree('aaa/bbb/__pycache__/')
importlib.reload(ccc)
assert ccc.func() == 11
```

### (参考) モジュール読み込み用の便利関数

以下の `get_module('aaa/bbb/ccc.py')` はソースファイルから常にリロードする関数です。ソースファイルの隣にキャッシュがあればデフォルトで削除します (背景として、モジュールに複数の版があり、どの版をチェックアウトしても読み込めることをテストするために記述したものですが、原則として同時に扱おうとせず、別々にテストしたほうがよいと思います)。

```py:utils.py
import sys
import os
import shutil
import importlib


def rmtree_if_exists(dir):
    """ディレクトリがもしあれば消します"""
    if os.path.isdir(dir):
        shutil.rmtree(dir)


def get_module(file_path, force_reload=True, remove_pycache_on_reload=True):
    """指定された Python ファイルパスをモジュールとして読み込みます

    Args:
        module_path: モジュールパス (Ex. aaa/bbb/ccc.py)
        force_reload: モジュールが既にインポート済みであった場合にリロードするか
        remove_pycache_on_reload: リロードする前に __pycache__ を削除するか
    """
    module_path = os.path.splitext(file_path)[0].replace('/', '.')
    already_imported = module_path in sys.modules

    try:
        module = importlib.import_module(module_path)
        if already_imported and force_reload:
            print('リロードします:', module_path)
            if remove_pycache_on_reload:
                pycache_path = os.path.join(os.path.dirname(file_path), '__pycache__')
                rmtree_if_exists(pycache_path)
            module = importlib.reload(module)
        return module
    except ModuleNotFoundError as exc:
        if module_path.startswith(exc.name):
            print(f'{module_path} 自体がみつかりません')
            return None  # ファイルパス自体がなければ None を返す (目的による)
        print(f'{module_path} はありますが {exc.name} がありません')
        raise exc
```

<details><summary>上記のスクリプトの単体テスト (pytest)</summary>

上記の `utils.py` の隣に以下の `test_0.py` を置いて `pytest -v` を実行するとすべてのテストがパスします (テスト中に `./aaa/bbb/ccc.py` を作成し削除しますのでご注意ください)。

```py:test_0.py
import pytest
import os
from utils import rmtree_if_exists, get_module


@pytest.fixture(scope='class')
def setup_and_teardown():
    test_file_path = 'aaa/bbb/ccc.py'
    # もし aaa/ が既にあれば削除して aaa/bbb/ を用意しておきます
    rmtree_if_exists(test_file_path.split('/')[0])
    os.makedirs(os.path.dirname(test_file_path), exist_ok=True)
    yield test_file_path
    # aaa/ を削除しておきます
    rmtree_if_exists(test_file_path.split('/')[0])


class TestClass0:
    @pytest.mark.parametrize('x, force_reload, remove_pycache_on_reload, x_expected', [
        (0,  True,  False, 0 ),  # return 0  と書き込んで import すれば module.func() は 0 である
        (10, False, False, 0 ),  # return 10 に書き換えて import しても module.func() は 0 のままである
        (10, True,  False, 10),  # reload すれば module.func() は 10 に更新される
    ])
    def test0_0(self, setup_and_teardown, x, force_reload, remove_pycache_on_reload, x_expected):
        test_file_path = setup_and_teardown
        with open(test_file_path, 'w') as f:
            f.write('def func():\n')
            f.write('    return ' + str(x))
        module = get_module(test_file_path, force_reload, remove_pycache_on_reload)
        assert module.func() == x_expected


class TestClass1:
    @pytest.mark.parametrize('x, force_reload, remove_pycache_on_reload, x_expected', [
        (0, True,  False, 0),  # return 0 と書き込んで import すれば module.func() は 0 である
        (1, False, False, 0),  # return 1 に書き換えて import しても module.func() は 0 のままである
        (1, True,  False, 0),  # reload しても依然として module.func() は 0 のままである
        (1, True,  True,  1),  # __pycache__ を削除して初めて module.func() は 1 に更新される
    ])
    def test1_0(self, setup_and_teardown, x, force_reload, remove_pycache_on_reload, x_expected):
        test_file_path = setup_and_teardown
        with open(test_file_path, 'w') as f:
            f.write('def func():\n')
            f.write('    return ' + str(x))
        module = get_module(test_file_path, force_reload, remove_pycache_on_reload)
        assert module.func() == x_expected


class TestClass2:
    def test2_0(self):
        # 存在しないファイルパスを読み込もうとした場合
        module = get_module('ddd/eee/fff.py')
        assert module is None

    def test2_1(self, setup_and_teardown):
        # ファイルパスは存在するが未インストールのパッケージを参照していた場合
        test_file_path = setup_and_teardown
        with open(test_file_path, 'w') as f:
            f.write('import ggg.hhh\n')  # 未インストールのパッケージ
            f.write('def func():\n')
            f.write('    return 0')
        with pytest.raises(ModuleNotFoundError):
            _ = get_module(test_file_path)
```

</details>