From 86f1655d3a690d4bc038cd030df3b3ae31c19d55 Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Wed, 22 Oct 2025 12:32:06 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor(tests):=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=83=A6=E3=83=BC=E3=83=86=E3=82=A3=E3=83=AA=E3=83=86?= =?UTF-8?q?=E3=82=A3API=E3=81=AE=E6=94=B9=E5=96=84=E3=81=A8=E5=85=A8?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_test_modules()とupdate_module()の導入 - make_temp_module()から辞書ベースAPIへ移行 - test_architecture_demo.pyを依存チェーンテストに変更 --- tests/test_absolute_import_basic.py | 37 +++---- tests/test_absolute_import_chained.py | 52 ++++----- tests/test_absolute_import_wildcard.py | 48 ++++----- tests/test_architecture_demo.py | 89 +++++++++++----- tests/test_relative_import_multilevel.py | 85 ++++++--------- tests/test_relative_import_package.py | 80 ++++++-------- tests/test_relative_import_parent.py | 81 ++++++-------- tests/test_relative_import_same_level.py | 77 ++++++-------- tests/test_relative_import_wildcard.py | 103 ++++++++---------- tests/test_utils.py | 128 +++++++++++++++++------ 10 files changed, 394 insertions(+), 386 deletions(-) diff --git a/tests/test_absolute_import_basic.py b/tests/test_absolute_import_basic.py index d0e08ca..e77bfbd 100644 --- a/tests/test_absolute_import_basic.py +++ b/tests/test_absolute_import_basic.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import make_temp_module + from test_utils import create_test_modules, update_module def test_simple_from_import_reload(tmp_path): @@ -13,32 +13,27 @@ def test_simple_from_import_reload(tmp_path): """ # テスト用モジュールを作成 - make_temp_module( + modules_dir = create_test_modules( tmp_path, - 'a', - textwrap.dedent( - """ - x = 1 - """ - ), + { + 'a.py': textwrap.dedent( + """ + x = 1 + """ + ), + 'b.py': textwrap.dedent( + """ + from a import x + """ + ), + }, ) - make_temp_module( - tmp_path, - 'b', - textwrap.dedent( - """ - from a import x - """ - ), - ) - - import a # noqa: F401 # type: ignore import b # type: ignore assert b.x == 1 # a.pyを書き換えて値を変更 - (tmp_path / 'a.py').write_text('x = 999\n', encoding='utf-8') + update_module(modules_dir, 'a.py', 'x = 999') # deep reloadを実行 from deep_reloader import deep_reload diff --git a/tests/test_absolute_import_chained.py b/tests/test_absolute_import_chained.py index c31ebc9..99e7d63 100644 --- a/tests/test_absolute_import_chained.py +++ b/tests/test_absolute_import_chained.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import make_temp_module + from test_utils import create_test_modules, update_module def test_chained_from_import_reload(tmp_path): @@ -13,35 +13,27 @@ def test_chained_from_import_reload(tmp_path): """ # テスト用モジュールを作成 - make_temp_module( + modules_dir = create_test_modules( tmp_path, - 'a', - textwrap.dedent( - """ - value = 1 - """ - ), + { + 'a.py': textwrap.dedent( + """ + value = 1 + """ + ), + 'b.py': textwrap.dedent( + """ + from a import value + """ + ), + 'c.py': textwrap.dedent( + """ + from b import value + """ + ), + }, ) - make_temp_module( - tmp_path, - 'b', - textwrap.dedent( - """ - from a import value - """ - ), - ) - make_temp_module( - tmp_path, - 'c', - textwrap.dedent( - """ - from b import value - """ - ), - ) - - import a # noqa: F401 # type: ignore + import a # type: ignore import b # type: ignore import c # type: ignore @@ -50,7 +42,7 @@ def test_chained_from_import_reload(tmp_path): assert c.value == 1 # a.pyを書き換えて値を変更 - (tmp_path / 'a.py').write_text('value = 777\n', encoding='utf-8') + update_module(modules_dir, 'a.py', 'value = 777') # deep reloadを実行(c からスタート) from deep_reloader import deep_reload diff --git a/tests/test_absolute_import_wildcard.py b/tests/test_absolute_import_wildcard.py index 9be5b97..8b85048 100644 --- a/tests/test_absolute_import_wildcard.py +++ b/tests/test_absolute_import_wildcard.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import make_temp_module + from test_utils import create_test_modules, update_module def test_wildcard_from_import_reload(tmp_path): @@ -13,41 +13,35 @@ def test_wildcard_from_import_reload(tmp_path): """ # テスト用モジュールを作成 - make_temp_module( + modules_dir = create_test_modules( tmp_path, - 'a', - textwrap.dedent( - """ - x = 1 - y = 2 - """ - ), + { + 'a.py': textwrap.dedent( + """ + x = 1 + y = 2 + """ + ), + 'b.py': textwrap.dedent( + """ + from a import * + """ + ), + }, ) - make_temp_module( - tmp_path, - 'b', - textwrap.dedent( - """ - from a import * - """ - ), - ) - - import a # noqa: F401 # type: ignore import b # type: ignore assert b.x == 1 assert b.y == 2 # a.pyを書き換えて値を変更 - (tmp_path / 'a.py').write_text( - textwrap.dedent( - """ + update_module( + modules_dir, + 'a.py', + """ x = 100 y = 200 - """ - ), - encoding='utf-8', + """, ) # deep reloadを実行 diff --git a/tests/test_architecture_demo.py b/tests/test_architecture_demo.py index 0086aba..415718a 100644 --- a/tests/test_architecture_demo.py +++ b/tests/test_architecture_demo.py @@ -37,7 +37,7 @@ - pytest: パッケージ構造の自動認識 * パッケージの親から実行 (`python -m pytest deep_reloader/tests/test_xxx.py`) * テストファイルパスでパッケージ名が明示され、複数パッケージ環境で識別が容易 -- 両方式共通: make_temp_module()による一時ディレクトリの自動追加とクリーンアップ +- 両方式共通: create_test_modules()による一時ディレクトリの自動追加とクリーンアップ 【3. フィクスチャ互換性】 - スクリプト: tempfile.TemporaryDirectory() @@ -74,13 +74,13 @@ # 実行コマンド例: # cd c:\Users\jiang\OneDrive\ドキュメント\maya\scripts\deep_reloader # python tests/test_architecture_demo.py - from test_utils import make_temp_module + from test_utils import create_test_modules, update_module except ImportError: # pytest実行時(パッケージとして認識される) # 実行コマンド例: # cd c:\Users\jiang\OneDrive\ドキュメント\maya\scripts # python -m pytest deep_reloader/tests/test_architecture_demo.py - from .test_utils import make_temp_module + from .test_utils import create_test_modules, update_module def test_architecture_demonstration(tmp_path): @@ -105,38 +105,77 @@ def test_architecture_demonstration(tmp_path): # ここにテストの本体を記述します # スクリプト実行・pytest実行のどちらでも同じコードが動作します - # 一時モジュールの作成 - # make_temp_module()はtest_utils.pyで定義されたヘルパー関数 + # 一時モジュールの作成(依存関係のあるモジュール構成) + # create_test_modules()はtest_utils.pyで定義されたヘルパー関数 # 自動的にsys.pathに一時ディレクトリが追加されます - make_temp_module( + modules_dir = create_test_modules( tmp_path, - 'demo_module', - textwrap.dedent( - """ - # このモジュールは一時ディレクトリに作成されます - MESSAGE = "テストが正常に動作しています" - VERSION = "1.0.0" - - def get_info(): - return f"{MESSAGE} (Version: {VERSION})" - """ - ), + { + 'config.py': textwrap.dedent( + """ + # 設定モジュール(依存される側) + APP_NAME = "DemoApp" + VERSION = "1.0.0" + """ + ), + 'utils.py': textwrap.dedent( + """ + # ユーティリティモジュール(config に依存) + from config import APP_NAME, VERSION + + def get_app_info(): + return f"{APP_NAME} v{VERSION}" + """ + ), + 'main.py': textwrap.dedent( + """ + # メインモジュール(utils に依存) + from utils import get_app_info + + def show_info(): + return f"Running: {get_app_info()}" + """ + ), + }, ) + # create_test_modules()により自動的にsys.pathが設定されているため直接インポート可能 + import main # type: ignore + + # アサーションによる検証(初期値) + assert main.show_info() == "Running: DemoApp v1.0.0" + + # 依存元のconfig.pyを更新 + # update_module()を使ってモジュールの内容を書き換えます + update_module( + modules_dir, + 'config.py', + """ + # 設定モジュール(更新版) + APP_NAME = "UpdatedApp" + VERSION = "2.5.0" + """, + ) + + # 通常のimportlib.reload()では依存関係が更新されない + # deep_reload()を使うことで、依存チェーンをすべてリロード + from deep_reloader import deep_reload + + deep_reload(main) - # モジュールのインポートとテスト - # make_temp_module()により自動的にsys.pathが設定されているため直接インポート可能 - import demo_module # type: ignore + # リロード後の値を確認 + # config.pyの変更がutils.py、main.pyまで伝播していることを確認 + import importlib - # アサーションによる検証 - assert demo_module.MESSAGE == "テストが正常に動作しています" - assert demo_module.VERSION == "1.0.0" - assert "テストが正常に動作しています" in demo_module.get_info() + new_main = importlib.import_module('main') + assert new_main.show_info() == "Running: UpdatedApp v2.5.0" # 実行方式の検出と表示 # この情報により、どちらの方式で実行されているかが分かります execution_method = _detect_execution_method() print(f"実行方式: {execution_method}") - print(f"テスト結果: {demo_module.get_info()}") + print(f"更新前: Running: DemoApp v1.0.0") + print(f"更新後: {new_main.show_info()}") + print("※ deep_reload()により依存チェーン(config -> utils -> main)がすべて更新されました") # 成功メッセージ print("OK: テスト実行デモンストレーション成功") diff --git a/tests/test_relative_import_multilevel.py b/tests/test_relative_import_multilevel.py index ecdff96..58329da 100644 --- a/tests/test_relative_import_multilevel.py +++ b/tests/test_relative_import_multilevel.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import add_temp_path_to_sys, make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import add_temp_path_to_sys, make_temp_module + from test_utils import create_test_modules, update_module def test_multilevel_relative_import(tmp_path): @@ -13,62 +13,45 @@ def test_multilevel_relative_import(tmp_path): """ # 深い階層のパッケージ構造を作成 - level1 = tmp_path / 'level1' - level2 = level1 / 'level2' - level3 = level2 / 'level3' - level1.mkdir() - level2.mkdir() - level3.mkdir() - - # 各階層に __init__.py を作成 - (level1 / '__init__.py').write_text('', encoding='utf-8') - (level2 / '__init__.py').write_text('', encoding='utf-8') - (level3 / '__init__.py').write_text('', encoding='utf-8') - - # 最上位に core.py を作成 - (level1 / 'core.py').write_text( - textwrap.dedent( - """ - core_value = "original_core" - - def core_function(): - return core_value - """ - ), - encoding='utf-8', + modules_dir = create_test_modules( + tmp_path, + { + 'core.py': textwrap.dedent( + """ + core_value = "original_core" + + def core_function(): + return core_value + """ + ), + 'level2/__init__.py': '', + 'level2/level3/__init__.py': '', + 'level2/level3/deep_module.py': textwrap.dedent( + """ + from ...core import core_value, core_function + + def deep_work(): + return f"Deep: {core_value} - {core_function()}" + """ + ), + }, + package_name='level1', ) - # 最下位から3階層上の core.py をインポート - (level3 / 'deep_module.py').write_text( - textwrap.dedent( - """ - from ...core import core_value, core_function - - def deep_work(): - return f"Deep: {core_value} - {core_function()}" - """ - ), - encoding='utf-8', - ) - - # sys.pathに一時ディレクトリを追加 - add_temp_path_to_sys(tmp_path) - from level1.level2.level3 import deep_module # noqa: F401 # type: ignore assert deep_module.deep_work() == "Deep: original_core - original_core" # core.pyを書き換えて値を変更 - (level1 / 'core.py').write_text( - textwrap.dedent( - """ - core_value = "updated_core" - - def core_function(): - return "updated_core" - """ - ), - encoding='utf-8', + update_module( + modules_dir, + 'core.py', + """ + core_value = "updated_core" + + def core_function(): + return "updated_core" + """, ) # deep reloadを実行 diff --git a/tests/test_relative_import_package.py b/tests/test_relative_import_package.py index 7f188c4..3b49545 100644 --- a/tests/test_relative_import_package.py +++ b/tests/test_relative_import_package.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import add_temp_path_to_sys, make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import add_temp_path_to_sys, make_temp_module + from test_utils import create_test_modules, update_module def test_package_level_relative_import(tmp_path): @@ -13,58 +13,44 @@ def test_package_level_relative_import(tmp_path): """ # パッケージ構造を作成 - parent_pkg = tmp_path / 'parentpkg' - child_pkg = parent_pkg / 'childpkg' - parent_pkg.mkdir() - child_pkg.mkdir() - - # __init__.py ファイルを作成 - (child_pkg / '__init__.py').write_text('', encoding='utf-8') - - # 親パッケージの __init__.py に関数を定義 - (parent_pkg / '__init__.py').write_text( - textwrap.dedent( - """ - package_version = "1.0.0" - - def package_info(): - return f"Package version: {package_version}" - """ - ), - encoding='utf-8', + modules_dir = create_test_modules( + tmp_path, + { + '__init__.py': textwrap.dedent( + """ + package_version = "1.0.0" + + def package_info(): + return f"Package version: {package_version}" + """ + ), + 'childpkg/__init__.py': '', + 'childpkg/info.py': textwrap.dedent( + """ + from .. import package_version, package_info + + def get_full_info(): + return f"Child module - {package_info()}" + """ + ), + }, + package_name='parentpkg', ) - # 子パッケージのモジュールで親パッケージから直接インポート - (child_pkg / 'info.py').write_text( - textwrap.dedent( - """ - from .. import package_version, package_info - - def get_full_info(): - return f"Child module - {package_info()}" - """ - ), - encoding='utf-8', - ) - - # sys.pathに一時ディレクトリを追加 - add_temp_path_to_sys(tmp_path) - from parentpkg.childpkg import info # noqa: F401 # type: ignore assert info.get_full_info() == "Child module - Package version: 1.0.0" # 親パッケージの __init__.pyを書き換えて値を変更 - (parent_pkg / '__init__.py').write_text( - textwrap.dedent( - """ - package_version = "2.5.0" - - def package_info(): - return f"Package version: {package_version}" - """ - ), - encoding='utf-8', + update_module( + modules_dir, + '__init__.py', + """ + package_version = "2.5.0" + + def package_info(): + return f"Package version: {package_version}" + """, ) # deep reloadを実行 diff --git a/tests/test_relative_import_parent.py b/tests/test_relative_import_parent.py index 6ced365..cfc7c5d 100644 --- a/tests/test_relative_import_parent.py +++ b/tests/test_relative_import_parent.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import add_temp_path_to_sys, make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import add_temp_path_to_sys, make_temp_module + from test_utils import create_test_modules, update_module def test_parent_level_relative_import(tmp_path): @@ -13,59 +13,44 @@ def test_parent_level_relative_import(tmp_path): """ # パッケージ構造を作成 - root_package = tmp_path / 'rootpkg' - sub_package = root_package / 'subpkg' - root_package.mkdir() - sub_package.mkdir() - - # __init__.py ファイルを作成 - (root_package / '__init__.py').write_text('', encoding='utf-8') - (sub_package / '__init__.py').write_text('', encoding='utf-8') - - # 親レベルに shared.py を作成 - (root_package / 'shared.py').write_text( - textwrap.dedent( - """ - shared_config = "initial" - - def get_config(): - return shared_config - """ - ), - encoding='utf-8', + modules_dir = create_test_modules( + tmp_path, + { + 'shared.py': textwrap.dedent( + """ + shared_config = "initial" + + def get_config(): + return shared_config + """ + ), + 'subpkg/__init__.py': '', + 'subpkg/worker.py': textwrap.dedent( + """ + from ..shared import shared_config, get_config + + def do_work(): + return f"Working with: {shared_config} - {get_config()}" + """ + ), + }, + package_name='rootpkg', ) - # 子レベルに worker.py を作成 (from ..shared import shared_config, get_config) - (sub_package / 'worker.py').write_text( - textwrap.dedent( - """ - from ..shared import shared_config, get_config - - def do_work(): - return f"Working with: {shared_config} - {get_config()}" - """ - ), - encoding='utf-8', - ) - - # sys.pathに一時ディレクトリを追加 - add_temp_path_to_sys(tmp_path) - from rootpkg.subpkg import worker # noqa: F401 # type: ignore assert worker.do_work() == "Working with: initial - initial" # shared.pyを書き換えて値を変更 - (root_package / 'shared.py').write_text( - textwrap.dedent( - """ - shared_config = "modified" - - def get_config(): - return "modified" - """ - ), - encoding='utf-8', + update_module( + modules_dir, + 'shared.py', + """ + shared_config = "modified" + + def get_config(): + return "modified" + """, ) # deep reloadを実行 diff --git a/tests/test_relative_import_same_level.py b/tests/test_relative_import_same_level.py index 01b68c4..b924424 100644 --- a/tests/test_relative_import_same_level.py +++ b/tests/test_relative_import_same_level.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import add_temp_path_to_sys, make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import add_temp_path_to_sys, make_temp_module + from test_utils import create_test_modules, update_module def test_same_level_relative_import(tmp_path): @@ -13,56 +13,43 @@ def test_same_level_relative_import(tmp_path): """ # パッケージ構造を作成 - package_dir = tmp_path / 'mypackage' - package_dir.mkdir() - - # __init__.py を作成 - (package_dir / '__init__.py').write_text('', encoding='utf-8') - - # utils.py を作成 - (package_dir / 'utils.py').write_text( - textwrap.dedent( - """ - helper_value = 42 - - def helper_func(): - return "original" - """ - ), - encoding='utf-8', + modules_dir = create_test_modules( + tmp_path, + { + 'utils.py': textwrap.dedent( + """ + helper_value = 42 + + def helper_func(): + return "original" + """ + ), + 'main.py': textwrap.dedent( + """ + from .utils import helper_value, helper_func + + def get_result(): + return f"{helper_value}-{helper_func()}" + """ + ), + }, + package_name='mypackage', ) - # main.py を作成 (from .utils import helper_value, helper_func) - (package_dir / 'main.py').write_text( - textwrap.dedent( - """ - from .utils import helper_value, helper_func - - def get_result(): - return f"{helper_value}-{helper_func()}" - """ - ), - encoding='utf-8', - ) - - # sys.pathに一時ディレクトリを追加 - add_temp_path_to_sys(tmp_path) - from mypackage import main # noqa: F401 # type: ignore assert main.get_result() == "42-original" # utils.pyを書き換えて値を変更 - (package_dir / 'utils.py').write_text( - textwrap.dedent( - """ - helper_value = 999 - - def helper_func(): - return "updated" - """ - ), - encoding='utf-8', + update_module( + modules_dir, + 'utils.py', + """ + helper_value = 999 + + def helper_func(): + return "updated" + """, ) # deep reloadを実行 diff --git a/tests/test_relative_import_wildcard.py b/tests/test_relative_import_wildcard.py index 278994b..a0cb4c2 100644 --- a/tests/test_relative_import_wildcard.py +++ b/tests/test_relative_import_wildcard.py @@ -2,9 +2,9 @@ import textwrap try: - from .test_utils import add_temp_path_to_sys, make_temp_module + from .test_utils import create_test_modules, update_module except ImportError: - from test_utils import add_temp_path_to_sys, make_temp_module + from test_utils import create_test_modules, update_module def test_wildcard_relative_import(tmp_path): @@ -13,69 +13,56 @@ def test_wildcard_relative_import(tmp_path): """ # パッケージ構造を作成 - package_dir = tmp_path / 'testpkg' - package_dir.mkdir() - - # __init__.py を作成 - (package_dir / '__init__.py').write_text('', encoding='utf-8') - - # constants.py を作成 (__all__ 付き) - (package_dir / 'constants.py').write_text( - textwrap.dedent( - """ - __all__ = ['PUBLIC_CONST', 'public_func'] - - PUBLIC_CONST = 100 - PRIVATE_CONST = 200 # __all__ にないので除外される - - def public_func(): - return "public" - - def _private_func(): # __all__ にないので除外される - return "private" - """ - ), - encoding='utf-8', + modules_dir = create_test_modules( + tmp_path, + { + 'constants.py': textwrap.dedent( + """ + __all__ = ['PUBLIC_CONST', 'public_func'] + + PUBLIC_CONST = 100 + PRIVATE_CONST = 200 # __all__ にないので除外される + + def public_func(): + return "public" + + def _private_func(): # __all__ にないので除外される + return "private" + """ + ), + 'main.py': textwrap.dedent( + """ + from .constants import * + + def get_values(): + # PUBLIC_CONST と public_func のみアクセス可能 + return f"{PUBLIC_CONST}-{public_func()}" + """ + ), + }, + package_name='testpkg', ) - # main.py を作成 (from .constants import *) - (package_dir / 'main.py').write_text( - textwrap.dedent( - """ - from .constants import * - - def get_values(): - # PUBLIC_CONST と public_func のみアクセス可能 - return f"{PUBLIC_CONST}-{public_func()}" - """ - ), - encoding='utf-8', - ) - - # sys.pathに一時ディレクトリを追加 - add_temp_path_to_sys(tmp_path) - from testpkg import main # noqa: F401 # type: ignore assert main.get_values() == "100-public" # constants.pyを書き換えて値を変更 - (package_dir / 'constants.py').write_text( - textwrap.dedent( - """ - __all__ = ['PUBLIC_CONST', 'public_func'] - - PUBLIC_CONST = 555 - PRIVATE_CONST = 666 # __all__ にないので除外される - - def public_func(): - return "updated" - - def _private_func(): # __all__ にないので除外される - return "private" - """ - ), - encoding='utf-8', + update_module( + modules_dir, + 'constants.py', + """ + __all__ = ['PUBLIC_CONST', 'public_func'] + + PUBLIC_CONST = 555 + PRIVATE_CONST = 666 # __all__ にないので除外される + + def public_func(): + return "updated" + + def _private_func(): # __all__ にないので除外される + return "private" + """, ) # deep reloadを実行 diff --git a/tests/test_utils.py b/tests/test_utils.py index 58c299f..4292b5b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,26 +2,7 @@ import sys import tempfile from pathlib import Path -from typing import Callable - -# TODO: パッケージ構築ユーティリティ関数の追加 -# make_temp_module()と同様に、パッケージ構造を簡単に作成するためのユーティリティ関数を追加する。 -# 現在はテストファイルごとにディレクトリ作成、__init__.py作成、sys.path追加を手動で行っているが、 -# これを自動化する関数が必要。 -# 例: make_temp_package(tmp_path, package_structure) のような関数で、 -# 辞書やYAMLで構造を定義して一括作成できるようにする。 -# -# 実装例: -# def make_temp_package(tmp_path: Path, package_structure: dict) -> None: -# """ -# 辞書で定義されたパッケージ構造を一括作成 -# -# Args: -# tmp_path: 一時ディレクトリのPath -# package_structure: パッケージ構造を定義した辞書 -# 例: {'pkg': {'__init__.py': '', 'mod.py': 'x=1'}} -# """ -# # 実装予定 +from typing import Callable, Dict, Optional # TODO: 循環インポートエラーの対応 # 現在、循環インポート(A → B → A のような相互依存)が存在するモジュール構造では @@ -103,37 +84,116 @@ def add_temp_path_to_sys(tmp_path: Path) -> None: Note: 重複チェック付きでsys.pathに追加します。 - 単一モジュール作成(make_temp_module)とパッケージ構造作成の両方で使用できます。 """ tmp_path_str = str(tmp_path) if tmp_path_str not in sys.path: sys.path.insert(0, tmp_path_str) -def make_temp_module(tmp_path: Path, name: str, content: str) -> Path: +def create_test_modules(tmp_path: Path, structure: Dict[str, str], package_name: Optional[str] = None) -> Path: """ - 指定されたディレクトリに一時モジュールを作成し、自動的にsys.pathに追加 + 辞書で定義されたテストモジュール構造を一括作成し、sys.pathに自動追加 Args: tmp_path: 一時ディレクトリのPath - name: モジュール名(拡張子なし) - content: モジュールの内容 + structure: モジュール構造を定義した辞書 + キー: ファイル名(相対パス) + 値: ファイルの内容 + 例: {'__init__.py': '', 'module_a.py': 'x=1', 'sub/mod.py': 'y=2'} + package_name: パッケージ名。Noneの場合はtmp_path直下にファイルを作成(パッケージなし) Returns: - 作成されたファイルのPath + パッケージディレクトリのPath(package_nameがNoneの場合はtmp_path) - Note: - add_temp_path_to_sys()を内部で呼び出して自動的にsys.pathに追加します。 - これにより、テストコード内でsys.path.insert()を毎回書く必要がなくなります。 + Example: + >>> # パッケージとして作成 + >>> pkg_dir = create_test_modules( + ... tmp_path, + ... { + ... '__init__.py': '', + ... 'module_a.py': 'x = 1', + ... 'sub/__init__.py': '', + ... 'sub/module_b.py': 'y = 2', + ... }, + ... package_name='my_package', + ... ) + >>> import my_package.module_a + >>> + >>> # パッケージなし(tmp_path直下にファイル作成) + >>> modules_dir = create_test_modules( + ... tmp_path, + ... { + ... 'a.py': 'x = 1', + ... 'b.py': 'y = 2', + ... }, + ... ) + >>> import a # 直接インポート可能 """ - # ファイル作成 - path = tmp_path / f'{name}.py' - path.write_text(content, encoding='utf-8') + # モジュール配置ディレクトリを決定 + if package_name is None: + # パッケージなし: tmp_path直下に作成 + modules_dir = tmp_path + else: + # パッケージあり: tmp_path/package_name/に作成 + modules_dir = tmp_path / package_name + modules_dir.mkdir(parents=True, exist_ok=True) + + # 構造に従ってファイルを作成 + for file_path_str, content in structure.items(): + file_path = modules_dir / file_path_str + + # 親ディレクトリを作成(サブパッケージ対応) + file_path.parent.mkdir(parents=True, exist_ok=True) + + # ファイルを作成 + file_path.write_text(content, encoding='utf-8') + + # __init__.pyが明示的に指定されていない場合、空の__init__.pyを作成 + # ただし、package_nameがNoneの場合(パッケージなし)は作成しない + if package_name is not None: + init_file = modules_dir / '__init__.py' + if '__init__.py' not in structure and not init_file.exists(): + init_file.write_text('', encoding='utf-8') # sys.pathに一時ディレクトリを自動追加 add_temp_path_to_sys(tmp_path) - return path + return modules_dir + + +def update_module(modules_dir: Path, filename: str, content: str) -> None: + """ + ディレクトリ内のモジュールファイルを更新 + + Args: + modules_dir: create_test_modules()の戻り値(モジュール群のディレクトリPath) + filename: 更新するファイル名(相対パス) + content: 新しいファイル内容(自動的にdedentされる) + + Example: + >>> # パッケージの場合 + >>> pkg_dir = create_test_modules( + ... tmp_path, + ... {'utils.py': 'x = 1'}, + ... package_name='mypackage', + ... ) + >>> update_module(pkg_dir, 'utils.py', ''' + ... x = 999 + ... def new_func(): + ... return x + ... ''') + >>> + >>> # パッケージなしの場合 + >>> modules_dir = create_test_modules( + ... tmp_path, + ... {'utils.py': 'x = 1'}, + ... ) + >>> update_module(modules_dir, 'utils.py', 'x = 999') + """ + import textwrap + + file_path = modules_dir / filename + file_path.write_text(textwrap.dedent(content), encoding='utf-8') def run_test_as_script(test_function: Callable[[Path], None], test_file_path: str) -> None: @@ -164,7 +224,7 @@ def run_test_as_script(test_function: Callable[[Path], None], test_file_path: st Example: >>> def test_my_function(tmp_path): - ... make_temp_module(tmp_path, 'test', 'x = 1') + ... modules = create_test_modules(tmp_path, None, {'test.py': 'x = 1'}) ... # テストコード(pytestと同じ形式) >>> >>> if __name__ == "__main__": From bee0140aa366567c410e45b00ac15bf08cc57209 Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 10:17:29 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E3=82=92=E7=9B=B8?= =?UTF-8?q?=E5=AF=BE=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E3=81=AB?= =?UTF-8?q?=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_absolute_import_basic.py | 14 +++++++------ tests/test_absolute_import_chained.py | 28 ++++++++++++++------------ tests/test_absolute_import_wildcard.py | 16 ++++++++------- tests/test_architecture_demo.py | 14 +++++++------ 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/tests/test_absolute_import_basic.py b/tests/test_absolute_import_basic.py index e77bfbd..d6a5ee2 100644 --- a/tests/test_absolute_import_basic.py +++ b/tests/test_absolute_import_basic.py @@ -12,10 +12,11 @@ def test_simple_from_import_reload(tmp_path): シンプルなfrom-importの更新テスト """ - # テスト用モジュールを作成 + # テスト用パッケージを作成 modules_dir = create_test_modules( tmp_path, { + '__init__.py': '', 'a.py': textwrap.dedent( """ x = 1 @@ -23,14 +24,15 @@ def test_simple_from_import_reload(tmp_path): ), 'b.py': textwrap.dedent( """ - from a import x + from .a import x """ ), }, + package_name='test_package', ) - import b # type: ignore + import test_package.b # type: ignore - assert b.x == 1 + assert test_package.b.x == 1 # a.pyを書き換えて値を変更 update_module(modules_dir, 'a.py', 'x = 999') @@ -38,10 +40,10 @@ def test_simple_from_import_reload(tmp_path): # deep reloadを実行 from deep_reloader import deep_reload - deep_reload(b) + deep_reload(test_package.b) # 更新された値を確認 - new_b = importlib.import_module('b') + new_b = importlib.import_module('test_package.b') assert new_b.x == 999 diff --git a/tests/test_absolute_import_chained.py b/tests/test_absolute_import_chained.py index 99e7d63..2d8eb8b 100644 --- a/tests/test_absolute_import_chained.py +++ b/tests/test_absolute_import_chained.py @@ -12,10 +12,11 @@ def test_chained_from_import_reload(tmp_path): c → b → a の多段 from-import が再帰的に更新されるかテスト """ - # テスト用モジュールを作成 + # テスト用パッケージを作成 modules_dir = create_test_modules( tmp_path, { + '__init__.py': '', 'a.py': textwrap.dedent( """ value = 1 @@ -23,23 +24,24 @@ def test_chained_from_import_reload(tmp_path): ), 'b.py': textwrap.dedent( """ - from a import value + from .a import value """ ), 'c.py': textwrap.dedent( """ - from b import value + from .b import value """ ), }, + package_name='test_package', ) - import a # type: ignore - import b # type: ignore - import c # type: ignore + import test_package.a # type: ignore + import test_package.b # type: ignore + import test_package.c # type: ignore - assert a.value == 1 - assert b.value == 1 - assert c.value == 1 + assert test_package.a.value == 1 + assert test_package.b.value == 1 + assert test_package.c.value == 1 # a.pyを書き換えて値を変更 update_module(modules_dir, 'a.py', 'value = 777') @@ -47,12 +49,12 @@ def test_chained_from_import_reload(tmp_path): # deep reloadを実行(c からスタート) from deep_reloader import deep_reload - deep_reload(c) + deep_reload(test_package.c) # 更新された値を確認 - new_a = importlib.import_module('a') - new_b = importlib.import_module('b') - new_c = importlib.import_module('c') + new_a = importlib.import_module('test_package.a') + new_b = importlib.import_module('test_package.b') + new_c = importlib.import_module('test_package.c') assert new_a.value == 777 assert new_b.value == 777 diff --git a/tests/test_absolute_import_wildcard.py b/tests/test_absolute_import_wildcard.py index 8b85048..229094e 100644 --- a/tests/test_absolute_import_wildcard.py +++ b/tests/test_absolute_import_wildcard.py @@ -12,10 +12,11 @@ def test_wildcard_from_import_reload(tmp_path): ワイルドカードインポート(from a import *)の更新テスト """ - # テスト用モジュールを作成 + # テスト用パッケージを作成 modules_dir = create_test_modules( tmp_path, { + '__init__.py': '', 'a.py': textwrap.dedent( """ x = 1 @@ -24,15 +25,16 @@ def test_wildcard_from_import_reload(tmp_path): ), 'b.py': textwrap.dedent( """ - from a import * + from .a import * """ ), }, + package_name='test_package', ) - import b # type: ignore + import test_package.b # type: ignore - assert b.x == 1 - assert b.y == 2 + assert test_package.b.x == 1 + assert test_package.b.y == 2 # a.pyを書き換えて値を変更 update_module( @@ -47,10 +49,10 @@ def test_wildcard_from_import_reload(tmp_path): # deep reloadを実行 from deep_reloader import deep_reload - deep_reload(b) + deep_reload(test_package.b) # 更新された値を確認 - new_b = importlib.import_module('b') + new_b = importlib.import_module('test_package.b') assert new_b.x == 100 assert new_b.y == 200 diff --git a/tests/test_architecture_demo.py b/tests/test_architecture_demo.py index 415718a..b0bc989 100644 --- a/tests/test_architecture_demo.py +++ b/tests/test_architecture_demo.py @@ -111,6 +111,7 @@ def test_architecture_demonstration(tmp_path): modules_dir = create_test_modules( tmp_path, { + '__init__.py': '', 'config.py': textwrap.dedent( """ # 設定モジュール(依存される側) @@ -121,7 +122,7 @@ def test_architecture_demonstration(tmp_path): 'utils.py': textwrap.dedent( """ # ユーティリティモジュール(config に依存) - from config import APP_NAME, VERSION + from .config import APP_NAME, VERSION def get_app_info(): return f"{APP_NAME} v{VERSION}" @@ -130,19 +131,20 @@ def get_app_info(): 'main.py': textwrap.dedent( """ # メインモジュール(utils に依存) - from utils import get_app_info + from .utils import get_app_info def show_info(): return f"Running: {get_app_info()}" """ ), }, + package_name='test_package', ) # create_test_modules()により自動的にsys.pathが設定されているため直接インポート可能 - import main # type: ignore + import test_package.main # type: ignore # アサーションによる検証(初期値) - assert main.show_info() == "Running: DemoApp v1.0.0" + assert test_package.main.show_info() == "Running: DemoApp v1.0.0" # 依存元のconfig.pyを更新 # update_module()を使ってモジュールの内容を書き換えます @@ -160,13 +162,13 @@ def show_info(): # deep_reload()を使うことで、依存チェーンをすべてリロード from deep_reloader import deep_reload - deep_reload(main) + deep_reload(test_package.main) # リロード後の値を確認 # config.pyの変更がutils.py、main.pyまで伝播していることを確認 import importlib - new_main = importlib.import_module('main') + new_main = importlib.import_module('test_package.main') assert new_main.show_info() == "Running: UpdatedApp v2.5.0" # 実行方式の検出と表示 From a9de31ddd3bc5e181f0166700c7e21f9e8687426 Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 10:20:26 +0900 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E3=83=87=E3=82=B3=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E3=83=BC=E3=81=AE=E3=82=AF=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=A3=E5=95=8F=E9=A1=8C=E3=82=92=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 1670302..a049f9a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,41 @@ Pythonモジュールの依存関係を解析して、再帰的に再読み込 ## 制限事項・既知の問題 +### Python言語レベルの制約(解決不可能) + +- **デコレーターのクロージャ問題**: デコレーター内で例外クラスをキャッチする場合、リロード後に正しくキャッチできません + - これはPython言語仕様の制約であり、すべてのリロードシステム(`importlib.reload()`, IPythonの`%autoreload`等)が抱える共通の問題です + - **原因**: デコレーターのクロージャは定義時にクラスオブジェクトへの参照を保持し、リロード後も古いクラスオブジェクトを参照し続けます + - **例**: + ```python + # custom_error.py + class CustomError(Exception): + @staticmethod + def catch(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except CustomError as e: # ←デコレーター定義時のCustomErrorを保持 + return f"Caught: {e}" + return wrapper + + # main.py + @CustomError.catch # ←リロード後、このクロージャは古いCustomErrorを参照 + def risky_function(): + raise CustomError("Error") # ←新しいCustomErrorを投げる + ``` + - **回避策**: + - デコレーターを使用せず、直接`try-except`で例外をキャッチする + - 例外クラスをリロード対象から除外する + - アプリケーションを再起動する + +- **モジュールスコープでのクラス参照**: モジュールレベルでクラスをエイリアスする場合も同様の問題が発生します + - **例**: `MyError = CustomError` のようなエイリアスは、リロード後も古いクラスを参照します + - **回避策**: エイリアスを避け、常にオリジナルのクラス名を使用する + +### 実装上の制約(将来対応予定) + - **import文未対応**: 現在は `import module` 形式の依存関係は解析対象外です - 対応: `from module import something` 形式のみ解析・リロード - 今後のバージョンで `import module` にも対応予定 From 2f2123f132c0703a8aa865f4effd03a7c627bab5 Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 10:21:53 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20=E5=88=9D=E6=9C=9F=E5=9E=8B?= =?UTF-8?q?=E3=81=AEmodule=5Freloader=E3=82=92=E9=96=8B=E7=99=BA=E5=8F=82?= =?UTF-8?q?=E8=80=83=E7=94=A8=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module_reloader.py | 208 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 module_reloader.py diff --git a/module_reloader.py b/module_reloader.py new file mode 100644 index 0000000..9ed7311 --- /dev/null +++ b/module_reloader.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- + +"""deep_reloaderの初期型""" + +import _ast +import ast +import importlib +import inspect +import shutil +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, Dict, List, Tuple, cast + +# ref. https://graphics.hatenablog.com/entry/2019/12/22/052819 + +__package_name = '' + + +def deep_reload(module: ModuleType) -> None: + """module_reloaderの実装をベースとしたdeep_reload + + Args: + module: リロード対象のモジュール + """ + global __package_name + + # モジュール名からパッケージ名を自動推定 + module_name = module.__name__ + if '.' in module_name: + # パッケージの一部の場合は、最上位パッケージ名を使用 + __package_name = module_name.split('.')[0] + else: + # トップレベルモジュールの場合はモジュール名をそのまま使用 + __package_name = module_name + + _delete_modules() + + from_import_symbols: List[Tuple[ModuleType, Dict[ModuleType, List[str]]]] = _get_symbols(module) + + parent: ModuleType + children_symbols: Dict[ModuleType, List[str]] + for parent, children_symbols in from_import_symbols: + _reload(children_symbols) + _overwrite_with_reloaded_symbols(parent, children_symbols) + + +def _delete_modules() -> None: + global __package_name + + # パッケージ名に基づいてsys.modulesからモジュールを削除 + for module_name in list(sys.modules.keys()): + if module_name.startswith(__package_name): + del sys.modules[module_name] + + +def _get_symbols(parent: ModuleType) -> List[Tuple[ModuleType, Dict[ModuleType, List[str]]]]: + children_symbols: Dict[ModuleType, List[str]] = get_children_symbols(parent) + result = [] + for child_module in children_symbols.keys(): + result.extend(_get_symbols(child_module)) + result.append((parent, children_symbols)) + return result + + +def get_children_symbols(module: ModuleType): + children_symbols: Dict[ModuleType, List[Any]] = {} + + try: + source = inspect.getsource(module) + except Exception: + # ソースコードが取得できない場合(組み込みモジュールなど)はスキップ + return children_symbols + + tree: _ast.Module = ast.parse(source) + + stmt: _ast.stmt + for stmt in tree.body: + # TODO: import xxx の場合のサポートも必要? + # from xxx import でないならcontinue + if stmt.__class__ != _ast.ImportFrom: + continue + + imp_frm = cast(_ast.ImportFrom, stmt) + + # モジュール名を取得(相対インポートの場合の特別処理を含む) + module_name = imp_frm.module + + # モジュールのフルネームを取得 + if imp_frm.level == 0: + # 絶対インポート: from module import something + if module_name is None: + continue + module_full_name = f'{module_name}' + elif imp_frm.level == 1: + # 同階層相対インポート: from .module import something + if module_name is None: + # from . import something (現在のパッケージから直接インポート) + module_full_name = module.__package__ + else: + # from .module import something + module_full_name = f'{module.__package__}.{module_name}' + elif imp_frm.level >= 2: + # 上位階層相対インポート: from ..module import something + package_names = module.__package__.split('.') + package_names = package_names[: -(imp_frm.level - 1)] + package_names = '.'.join(package_names) + if module_name is None: + # from .. import something (上位パッケージから直接インポート) + module_full_name = package_names + else: + # from ..module import something + module_full_name = f'{package_names}.{module_name}' + else: + raise Exception('module_reloaderにて例外が発生しました。ソースコードを確認してください') + + # リロード対象ではないならcontinue + global __package_name + if not module_full_name.startswith(__package_name): + continue + + try: + new_module: ModuleType = importlib.import_module(module_full_name) + except Exception: + # インポートに失敗した場合はスキップ + continue + + # packageならスキップ(フリーズ防止のため重要) + if _is_package(new_module): + # NOTE: from xxx import yyy のyyyがモジュールのため、シンボルを上書きする必要はない。 + continue + + symbol_names: List[str] = [x.name for x in imp_frm.names] + + # wildcard importの場合 + if symbol_names[0] == '*': + if '__all__' in new_module.__dict__: + symbol_names = new_module.__dict__['__all__'] + else: + symbol_names = [x for x in new_module.__dict__ if not x.startswith('__')] + + children_symbols[new_module] = symbol_names + + return children_symbols + + +def _is_package(module: ModuleType) -> bool: + """モジュールがパッケージ(__init__.py)かどうかを判定""" + file = module.__file__ + return file is None or file.endswith('__init__.py') + + +def _reload(children_symbols: Dict[ModuleType, List[str]]) -> None: + for child_module in children_symbols.keys(): + # 強力なリロード: sys.modulesから削除してから再インポート + module_name = child_module.__name__ + + # .pycファイルを削除(キャッシュクリア) + _clear_single_pycache(child_module) + + # sys.modulesから削除 + if module_name in sys.modules: + del sys.modules[module_name] + + # キャッシュをクリア + importlib.invalidate_caches() + + # 再インポート + try: + reloaded_module = importlib.import_module(module_name) + + # 元のモジュールオブジェクトの辞書を更新 + child_module.__dict__.clear() + child_module.__dict__.update(reloaded_module.__dict__) + + except Exception: + # フォールバック: 通常のリロード + importlib.reload(child_module) + + +def _clear_single_pycache(module: ModuleType) -> None: + """ + 1つのモジュールに対応する __pycache__ を削除 + """ + module_file = getattr(module, '__file__', None) + if module_file is None: + return + + module_dir = Path(module_file).parent + pycache_dir = module_dir / '__pycache__' + + if pycache_dir.exists(): + try: + shutil.rmtree(pycache_dir) + except Exception: + pass # エラーは無視 + + +def _overwrite_with_reloaded_symbols(parent: ModuleType, children_symbols: Dict[ModuleType, List[str]]) -> None: + no_key = 'no key' + + for child_module, child_symbol_names in children_symbols.items(): + for child_symbol_name in child_symbol_names: + val = child_module.__dict__.get(child_symbol_name, no_key) + if val == no_key: + print(f'sys.modulesに{child_symbol_name}が存在しません') + else: + parent.__dict__[child_symbol_name] = val From ba0b541a24540972519d88cd02c73aeb17700ef0 Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 16:48:56 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E5=BE=AA=E7=92=B0=E5=8F=82?= =?UTF-8?q?=E7=85=A7=E5=AF=BE=E5=BF=9C=E3=81=A8=E5=8D=98=E4=B8=80=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8=E3=82=B9=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要変更: - 循環インポート(A→B→A)のサポート追加 - リロード対象を単一パッケージに限定し、組み込み/サードパーティを自動除外 - リロードロジックを簡素化(overwrite_symbols廃止) - テストを12個に拡充(循環参照、モジュール参照、クラスエイリアステスト追加) - コード品質改善(文字列リテラルの統一、ドキュメント更新) --- README.md | 129 ++++++++++++++----------- deep_reloader.py | 112 +++++++++------------ imported_symbols.py | 10 +- module_info.py | 76 +++++++-------- symbol_extractor.py | 51 ++-------- tests/test_absolute_import_basic.py | 2 +- tests/test_absolute_import_wildcard.py | 2 +- tests/test_architecture_demo.py | 22 ++--- tests/test_circular_import.py | 93 ++++++++++++++++++ tests/test_class_alias.py | 105 ++++++++++++++++++++ tests/test_isinstance_check.py | 83 ++++++++++++++++ tests/test_relative_import_package.py | 14 +-- tests/test_relative_import_wildcard.py | 4 +- tests/test_runner.py | 2 +- tests/test_utils.py | 8 +- 15 files changed, 470 insertions(+), 243 deletions(-) create mode 100644 tests/test_circular_import.py create mode 100644 tests/test_class_alias.py create mode 100644 tests/test_isinstance_check.py diff --git a/README.md b/README.md index a049f9a..e103c43 100644 --- a/README.md +++ b/README.md @@ -3,59 +3,15 @@ > [!WARNING] > このソフトウェアは現在プレリリース版(v0.2.0)です。APIが変更される可能性があります。 -Pythonモジュールの依存関係を解析して、再帰的に再読み込みを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。 +Pythonモジュールの依存関係を解析して、再帰的にリロードを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。 ## 機能 -- **深い再読み込み**: from-import の依存関係を自動解析 +- **深いリロード**: 深い階層でもリロードが可能 - **AST解析**: 静的解析により from-import文 を正確に検出 - **ワイルドカード対応**: `from module import *` もサポート - **相対インポート対応**: パッケージ内の相対インポートを正しく処理 -- **`__pycache__`クリア**: 古いキャッシュファイルを自動削除 - -## 制限事項・既知の問題 - -### Python言語レベルの制約(解決不可能) - -- **デコレーターのクロージャ問題**: デコレーター内で例外クラスをキャッチする場合、リロード後に正しくキャッチできません - - これはPython言語仕様の制約であり、すべてのリロードシステム(`importlib.reload()`, IPythonの`%autoreload`等)が抱える共通の問題です - - **原因**: デコレーターのクロージャは定義時にクラスオブジェクトへの参照を保持し、リロード後も古いクラスオブジェクトを参照し続けます - - **例**: - ```python - # custom_error.py - class CustomError(Exception): - @staticmethod - def catch(function): - @functools.wraps(function) - def wrapper(*args, **kwargs): - try: - return function(*args, **kwargs) - except CustomError as e: # ←デコレーター定義時のCustomErrorを保持 - return f"Caught: {e}" - return wrapper - - # main.py - @CustomError.catch # ←リロード後、このクロージャは古いCustomErrorを参照 - def risky_function(): - raise CustomError("Error") # ←新しいCustomErrorを投げる - ``` - - **回避策**: - - デコレーターを使用せず、直接`try-except`で例外をキャッチする - - 例外クラスをリロード対象から除外する - - アプリケーションを再起動する - -- **モジュールスコープでのクラス参照**: モジュールレベルでクラスをエイリアスする場合も同様の問題が発生します - - **例**: `MyError = CustomError` のようなエイリアスは、リロード後も古いクラスを参照します - - **回避策**: エイリアスを避け、常にオリジナルのクラス名を使用する - -### 実装上の制約(将来対応予定) - -- **import文未対応**: 現在は `import module` 形式の依存関係は解析対象外です - - 対応: `from module import something` 形式のみ解析・リロード - - 今後のバージョンで `import module` にも対応予定 -- **循環インポート**: 循環インポート(A → B → A のような相互依存)が存在するモジュール構造では現在エラーが発生します - - 今後のバージョンで対応予定 - - 回避策: 循環依存を避けた設計に変更するか、手動での部分リロードをご検討ください +- **循環参照対応**: Pythonで動作する循環インポート(関数内での遅延インポート)を正しくリロード ## 使用方法 @@ -151,19 +107,77 @@ python -m pytest deep_reloader/tests/test_absolute_import_basic.py -v python -m pytest deep_reloader/tests/ -vv ``` -### テストアーキテクチャの特徴 - -- **二種の実行をサポート**: 各テストファイルはスクリプト実行とpytest実行の両方に対応 -- **条件付きインポート**: 実行環境に応じて相対/絶対インポートを自動切り替え -- **一時ディレクトリ管理**: 手動作成(スクリプト実行)と`tmp_path`(pytest)の両方をサポート - ### 動作確認済み環境 **テスト開発環境(Maya以外):** - Python 3.11.9+(現在の開発環境で検証済み) - pytest 8.4.2+(テスト実行時のみ、現在の開発環境で検証済み) -**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。Mayaのサポートバージョンは確定していません。 +**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。Mayaのサポートバージョンはまだ確定していません。 + +## 制限事項・既知の問題 + +- **isinstance()チェックの失敗**(Python言語仕様の制約 - 解決不可能) + - リロード前に作成したインスタンスは、リロード後のクラスで`isinstance()`チェックが失敗します + - これはPython言語仕様の制約であり、すべてのリロードシステムが抱える共通の問題です + - **原因**: リロード後、クラスオブジェクトのIDが変わるため、リロード前のインスタンスは古いクラスを参照し続けます + - **例**: + ```python + # リロード前 + obj = MyClass() + isinstance(obj, MyClass) # True + + # deep_reload後 + isinstance(obj, MyClass) # False(objは古いMyClassのインスタンス、MyClassは新しいクラス) + ``` + - **回避策**: + - リロード後にインスタンスを再作成する + - クラス名での文字列比較を使用する(`type(obj).__name__ == 'MyClass'`) + - アプリケーションを再起動する + +- **デコレーターのクロージャ問題**(Python言語仕様の制約 - 解決不可能) + - デコレーター内で例外クラスをキャッチする場合、リロード後に正しくキャッチできません + - これはPython言語仕様の制約であり、すべてのリロードシステム(`importlib.reload()`, IPythonの`%autoreload`等)が抱える共通の問題です + - **原因**: デコレーターのクロージャは定義時にクラスオブジェクトへの参照を保持し、リロード後も古いクラスオブジェクトを参照し続けます + - **例**: + ```python + # custom_error.py + class CustomError(Exception): + @staticmethod + def catch(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except CustomError as e: # ←デコレーター定義時のCustomErrorを保持 + return f"Caught: {e}" + return wrapper + + # main.py + @CustomError.catch # ←リロード後、このクロージャは古いCustomErrorを参照 + def risky_function(): + raise CustomError("Error") # ←新しいCustomErrorを投げる + ``` + - **回避策**: + - デコレーターを使用せず、直接`try-except`で例外をキャッチする + - 例外クラスをリロード対象から除外する + - アプリケーションを再起動する + +- **import文未対応**(将来対応予定) + - 現在は `import module` 形式の依存関係は解析対象外です + - 対応: `from module import something` 形式のみ解析・リロード + - 今後のバージョンで対応予定 + +- **単一パッケージのみリロード**(仕様) + - `deep_reload()`は、指定されたモジュールと同じパッケージに属するモジュールのみをリロードします + - **理由**: 組み込みモジュール(`collections`等)やサードパーティライブラリ(`maya.cmds`, `PySide2`等)のリロードを防ぎ、システムの安定性を保つため + - **例**: `deep_reload(routinerecipe.main)` を実行すると、`routinerecipe`パッケージ内のモジュールのみがリロードされます + - **複数の自作パッケージを開発している場合**: + ```python + # routinerecipe と myutils の両方を開発中の場合 + deep_reload(myutils.helper) # myutilsパッケージをリロード + deep_reload(routinerecipe.main) # routinerecipeパッケージをリロード + ``` ## バージョン情報 @@ -171,13 +185,12 @@ python -m pytest deep_reloader/tests/ -vv ### リリース状況 - ✅ コア機能実装完了(from-import対応) -- ✅ テストスイート(9テスト) +- ✅ テストスイート(12テスト) - ✅ ドキュメント整備 +- ✅ Maya環境での動作検証 +- ✅ 循環インポート対応 - 🔄 APIの安定化作業中 -- 📋 Maya環境での動作検証 - 📋 import文対応の追加 -- 📋 循環インポートエラー対応 -- 📋 組み込み・サードパーティモジュールのスキップ処理 - 📋 デバッグログの強化 - 📋 パフォーマンス最適化とキャッシュ機能 diff --git a/deep_reloader.py b/deep_reloader.py index d3f5474..9f277a0 100644 --- a/deep_reloader.py +++ b/deep_reloader.py @@ -16,16 +16,35 @@ def deep_reload(module: ModuleType) -> None: Maya開発でのモジュール変更を即座に反映させるために設計されています。 + リロード後、引数で渡されたモジュールオブジェクトの内容が自動的に更新されるため、 + 戻り値を受け取る必要はありません。 + Args: module: リロード対象のモジュール Note: ログレベルの設定には setup_logging() 関数を使用してください。 例: setup_logging(logging.DEBUG) + + Example: + ```python + from mypackage import main + from deep_reloader import deep_reload + + deep_reload(main) # mainの中身が自動的に更新される + main.restart() # 新しいコードが実行される + ``` """ # キャッシュを無効化して .py の変更を認識させる importlib.invalidate_caches() + # ターゲットパッケージ名を自動推定 + module_name = module.__name__ + if '.' in module_name: + target_package = module_name.split('.')[0] + else: + target_package = module_name + # TODO: パフォーマンス最適化 - ファイル変更検出による差分リロード # - ファイルのタイムスタンプキャッシュで変更検出 # - 変更されたモジュールのみのリロード(現在は全モジュール対象) @@ -37,43 +56,47 @@ def deep_reload(module: ModuleType) -> None: # - 依存関係ツリーの視覚的表示(階層構造、インデント付き) # - 各モジュールの詳細情報(パス、サイズ、最終更新時刻) # - スキップされるモジュールの理由と一覧 - root = _build_tree(module) + visited = set() # 循環インポート検出用 + root = _build_tree(module, visited, target_package) # ツリー全体の __pycache__ を削除 _clear_pycache_recursive(root) - # 親→子へリロード - # TODO: リロード順序とプロセスの詳細ログ追加 - # - 各モジュールのリロード開始/完了タイミング - # - リロード中のエラーとリカバリ状況 - # - パフォーマンス情報(各段階の実行時間) + # リロード root.reload() - # 子→親へシンボルをコピー(from-import で取得したシンボルを親モジュールに反映) - # TODO: シンボルコピー処理の詳細ログ追加 - # - コピーされるシンボルの詳細(名前、型、ソース) - # - シンボルの競合や上書き状況 - # - 失敗したシンボルとその理由 - root.overwrite_symbols() - -def _build_tree(module: ModuleType) -> ModuleInfo: +def _build_tree(module: ModuleType, visited: set, target_package: str) -> ModuleInfo: """ AST 解析して ModuleInfo ツリーを構築 - TODO: 組み込みモジュール(os、pathlib等)やサードパーティライブラリ(maya.cmds、PySide6等)の - スキップ処理を実装する必要がある。現在は全ての依存関係をリロード対象としているため、 - 不要なリロードや潜在的な危険性がある。 + Args: + module: 解析対象のモジュール + visited: 循環インポート検出用の訪問済みモジュールセット + target_package: リロード対象のパッケージ名(例: 'routinerecipe') + このパッケージに属するモジュールのみをリロード対象とする + + Note: + target_packageに一致しないモジュール(組み込みモジュールやサードパーティライブラリ、その他の自作パッケージ)は + スキップされ、リロード対象から除外されます。 """ + node = ModuleInfo(module) - # 念のため sys.modules から最新の正規モジュールを取得する - module = sys.modules[module.__name__] + # 循環インポート検出: すでに訪問済みなら子の展開はスキップ(無限ループ防止) + # ただし、ノード自体は作成してリロード対象には含める + if module.__name__ in visited: + return node - node = ModuleInfo(module) + visited.add(module.__name__) extractor = SymbolExtractor(module) for child_module, symbols in extractor.extract(): - child_node = _build_tree(child_module) + # ターゲットパッケージに属するモジュールのみをツリーに追加 + if not child_module.__name__.startswith(target_package): + logger.debug(f'Skipped module (not in target package): {child_module.__name__}') + continue + + child_node = _build_tree(child_module, visited, target_package) child_node.symbols = symbols node.children.append(child_node) @@ -106,50 +129,3 @@ def _clear_single_pycache(module: ModuleType) -> None: logger.debug(f'Cleared pycache {pycache_dir}') except Exception as e: logger.warning(f'Failed to clear pycache {pycache_dir}: {e!r}') - - -# TODO: 将来の実装用メソッド - デバッグ情報の出力機能 -# def _print_tree_structure(root: ModuleInfo, level: int = 0) -> None: -# """依存関係ツリーを視覚的に表示する -# -# 出力例: -# my_package.main -# ├── my_package.utils (from utils import helper, calculator) -# │ └── my_package.math_utils (from .math_utils import add, subtract) -# ├── my_package.config (from .config import settings) -# └── os [SKIPPED: builtin module] -# """ -# pass -# -# def _log_reload_summary(root: ModuleInfo) -> None: -# """リロード処理の概要を詳細ログ出力 -# -# 出力内容: -# - 処理開始/終了時刻 -# - 総モジュール数、リロード対象数、スキップ数 -# - 各段階の所要時間(ツリー構築、リロード、シンボルコピー) -# - 検出されたエラーや警告の統計 -# """ -# pass -# -# TODO: 将来の実装用 - パフォーマンス最適化キャッシュシステム -# def _init_cache_system() -> None: -# """キャッシュシステムの初期化 -# -# キャッシュ対象: -# - ファイルタイムスタンプ(変更検出用) -# - AST解析結果(重いパース処理の削減) -# - 依存関係ツリー(構造変更時のみ再構築) -# - モジュール判定結果(組み込み/サードパーティ判定キャッシュ) -# """ -# pass -# -# def _should_reload_module(module: ModuleType) -> bool: -# """モジュールがリロード必要かキャッシュベースで判定 -# -# 判定基準: -# - ファイルタイムスタンプの変更 -# - 依存関係の変更 -# - 強制リロードフラグ -# """ -# pass diff --git a/imported_symbols.py b/imported_symbols.py index da0a8e4..af19e86 100644 --- a/imported_symbols.py +++ b/imported_symbols.py @@ -1,17 +1,13 @@ -from typing import List, Iterator, Optional import logging +from typing import Iterator, List, Optional logger = logging.getLogger(__name__) class ImportedSymbols: - """モジュールから親モジュールへコピーされるシンボル集合を保持する軽量コンテナ。 + """親モジュールが子モジュールからインポートしたシンボル名のリストを管理するクラス""" - logging: - シンボルコピーの詳細ログはDEBUGレベルで出力されます。 - ログレベルは DeepReloader の verbose パラメータで制御してください。 - """ - def __init__(self, names: Optional[List[str]]=None) -> None: + def __init__(self, names: Optional[List[str]] = None) -> None: """ImportedSymbolsを初期化する。 Args: diff --git a/module_info.py b/module_info.py index 9f1c68c..093900a 100644 --- a/module_info.py +++ b/module_info.py @@ -13,6 +13,7 @@ class ModuleInfo: """ モジュールとその子モジュール(from-import)情報を保持するクラス """ + def __init__(self, module: ModuleType) -> None: self.module: ModuleType = module self.children: List['ModuleInfo'] = [] @@ -20,21 +21,23 @@ def __init__(self, module: ModuleType) -> None: def reload(self, visited=None) -> None: """ - 再帰的にリロードを実行(親→子の順でリロード)。 + 再帰的にリロードを実行 - 主目的:自分自身のモジュールを最新のファイル内容でリロードすること - (自身が取り込む子モジュールは最新ではないが今の段階ではそれでOK)。 + 処理の流れ: + 1. sys.modulesから一時削除してキャッシュクリア + 2. importlib.import_module()で新しいモジュールオブジェクト(new_module)を作成 + 3. 子モジュールを再帰的にリロード + 4. 子のシンボルをnew_moduleにコピー(関数の__globals__が正しく参照できるように) + 5. self.module.__dict__を更新(削除された属性を除去、新しい属性を追加・上書き) + 6. sys.modules[name]にself.moduleを登録(new_moduleではなく) - なぜ古い子モジュールでリロードするのか: - 1. 通常、親から再帰的にリロードすると(親→子→孫の順で呼び出しが進むため) - 親がリロードする際に古い子を取り込むことになる - 2. このreload()では各モジュールを最新内容に更新することが主目的(子モジュールは最新でなくてもよい) - 3. 子モジュール(from-import)の更新は後でoverwrite_symbols()が解決する設計 - 4. リロードロジックとシンボル伝播ロジックは分離させることでよりシンプルに設計を保っている + 重要な設計思想: + - self.moduleのオブジェクトIDを保持することで、既存の参照を有効に保つ + - new_moduleは一時的な作業用オブジェクトとして使用 + - __dict__を更新することで、オブジェクトを置き換えずに中身だけを更新 """ # 再帰処理で訪問済みモジュールを記録するセット - # 最初の呼び出し時のみ作成し、以降の再帰呼び出しでは同じオブジェクトを共有 if visited is None: visited = set() @@ -43,42 +46,35 @@ def reload(self, visited=None) -> None: if name in visited: return - # キャッシュを消してから再インポート - if name in sys.modules: - sys.modules.pop(name, None) + # 一時的にsys.modulesから削除してキャッシュをクリア + sys.modules.pop(name, None) importlib.invalidate_caches() + # 新しいモジュールをインポート new_module = importlib.import_module(name) - self.module = new_module - visited.add(name) - - # リロード完了をログ出力(ログレベルで制御) - logger.info(f'RELOADED {name}') - - # TODO: より詳細なリロード情報のログ出力 - # - モジュールのファイルパス、サイズ、最終更新時刻 - # - リロード前後での属性・関数の変更差分 - # - 依存関係の深度レベル表示 - # - リロード所要時間の計測 - # 子を再帰的にリロード + # 子を再帰的にリロード(子が先に完了する必要がある) for child in self.children: child.reload(visited) - def overwrite_symbols(self) -> None: - """ - from-import シンボルを親モジュールに上書き(葉から順) - - TODO: シンボル上書き処理の詳細ログ追加 - - 上書きされるシンボルの一覧(名前、型、古い値→新しい値) - - シンボルコピーの失敗ケースとその理由 - - ネストレベルと処理順序の視覚化 - - 競合や循環参照の検出・警告 - """ - # 子を先に処理(葉 → 根) + # 子のリロード後、from-importシンボルを新しいモジュールにコピー + # (new_moduleの関数の__globals__に正しい値を設定するため) for child in self.children: - child.overwrite_symbols() + child.symbols.copy_to(child.module, new_module) - # 子モジュールから自分にシンボルをコピー - for child in self.children: - child.symbols.copy_to(child.module, self.module) + # リロード前のモジュール(self.module)にあって、リロード後のモジュール(new_module)に存在しなくなった属性を削除する + old_keys = set(self.module.__dict__.keys()) + new_keys = set(new_module.__dict__.keys()) + for key in old_keys - new_keys: + if not key.startswith('__'): # __name__, __file__等の特殊属性は保持 + del self.module.__dict__[key] + + # self.module.__dict__をnew_module.__dict__で更新(属性を追加・上書き) + self.module.__dict__.update(new_module.__dict__) + + # sys.modulesをself.moduleで上書き + sys.modules[name] = self.module + + visited.add(name) + + logger.debug(f'RELOADED {name}') diff --git a/symbol_extractor.py b/symbol_extractor.py index c492091..f590440 100644 --- a/symbol_extractor.py +++ b/symbol_extractor.py @@ -14,28 +14,18 @@ class SymbolExtractor: """ モジュールのASTを解析して、from-import文の「子モジュール」と「インポートされるシンボル」を抽出するクラス - from-import文で参照される子モジュールと、インポートされるシンボル名のペアを - (ModuleType, ImportedSymbols) の形式で返す。 + 子モジュールと、インポートされるシンボル名のペアを(ModuleType, ImportedSymbols)で表現する。 例: from math import sin, cos → (math_module, ImportedSymbols(['sin', 'cos'])) この場合、mathが子モジュール、sin・cosがインポートされるシンボル """ + def __init__(self, module: ModuleType) -> None: - self.module = module - # TODO: AST解析のパフォーマンス最適化 - # - 解析結果のキャッシュ(モジュールのタイムスタンプベース) - # - 頻繁にアクセスされるモジュールの優先キャッシュ - # - メモリ効率的なキャッシュ管理(LRU、サイズ制限) + self.module: ModuleType = module self.tree: Optional[ast.AST] = self._parse_ast() def extract(self) -> List[Tuple[ModuleType, ImportedSymbols]]: - """(子モジュール, インポートされたシンボル) のリストを返す - - TODO: 組み込みモジュール(os、sys、pathlib等)やサードパーティライブラリ - (maya.cmds、PySide6、numpy等)の判定とスキップ処理を実装する。 - これらのモジュールはリロード不要かつ危険な場合があるため、 - 安全なモジュールのみを対象とする仕組みが必要。 - """ + """(子モジュール, インポートされたシンボル) のリストを返す""" if self.tree is None: return [] @@ -46,14 +36,7 @@ def extract(self) -> List[Tuple[ModuleType, ImportedSymbols]]: # 無効な依存関係を除外: 存在しないモジュール(None)と自分自身への参照 if child_module is None or child_module is self.module: continue - - # TODO: ここで組み込み・サードパーティモジュールのスキップ判定を追加 - # 例: if self._should_skip_module(child_module): continue - # 判定ロジック案: - # - sys.builtin_module_names による組み込みモジュール判定 - # - __file__ 属性の有無とパスによるサードパーティ判定 - # - 設定可能なブラックリスト/ホワイトリスト - + symbols = self._extract_symbols(child_module, node) results.append((child_module, symbols)) return results @@ -64,10 +47,12 @@ def _parse_ast(self) -> Optional[ast.AST]: source = inspect.getsource(self.module) return ast.parse(source) except (OSError, TypeError, SyntaxError): + # 組み込みモジュール(os, sys等)、バイナリ拡張(.pyd/.so)、 + # Maya内部モジュール(maya.cmds等)はソースコードが取得できないためNoneを返す return None def _resolve_imported_module(self, stmt: ast.ImportFrom) -> Optional[ModuleType]: - """ImportFromノードから実際の子モジュールを解決(相対対応)""" + """ImportFromノードからインポート対象のモジュールを取得""" try: if stmt.level > 0: # 相対インポートを絶対パスに変換 @@ -105,23 +90,3 @@ def _extract_symbols(self, child_module: ModuleType, stmt: ast.ImportFrom) -> Im if attr_name.startswith('__') is False: # __name__, __file__ 等の特殊属性を除外 public_attrs.append(attr_name) return ImportedSymbols(public_attrs) - - # TODO: 将来の実装用メソッド - 組み込み・サードパーティモジュールの判定 - # def _should_skip_module(self, module: ModuleType) -> bool: - # """モジュールをスキップすべきかどうかを判定する - # - # Args: - # module: 判定対象のモジュール - # - # Returns: - # True: スキップすべき(組み込み・サードパーティモジュール) - # False: リロード対象(ユーザー作成モジュール) - # - # 判定ロジック案: - # 1. sys.builtin_module_names による組み込みモジュール判定 - # 2. __file__ が None の場合(C拡張モジュール等) - # 3. site-packages パス内のモジュール判定 - # 4. maya.* パッケージの特別扱い - # 5. 設定可能なブラックリスト/ホワイトリスト - # """ - # pass diff --git a/tests/test_absolute_import_basic.py b/tests/test_absolute_import_basic.py index d6a5ee2..4100703 100644 --- a/tests/test_absolute_import_basic.py +++ b/tests/test_absolute_import_basic.py @@ -47,7 +47,7 @@ def test_simple_from_import_reload(tmp_path): assert new_b.x == 999 -if __name__ == "__main__": +if __name__ == '__main__': from test_utils import run_test_as_script run_test_as_script(test_simple_from_import_reload, __file__) diff --git a/tests/test_absolute_import_wildcard.py b/tests/test_absolute_import_wildcard.py index 229094e..547f258 100644 --- a/tests/test_absolute_import_wildcard.py +++ b/tests/test_absolute_import_wildcard.py @@ -57,7 +57,7 @@ def test_wildcard_from_import_reload(tmp_path): assert new_b.y == 200 -if __name__ == "__main__": +if __name__ == '__main__': from test_utils import run_test_as_script run_test_as_script(test_wildcard_from_import_reload, __file__) diff --git a/tests/test_architecture_demo.py b/tests/test_architecture_demo.py index b0bc989..0f2a3a6 100644 --- a/tests/test_architecture_demo.py +++ b/tests/test_architecture_demo.py @@ -115,8 +115,8 @@ def test_architecture_demonstration(tmp_path): 'config.py': textwrap.dedent( """ # 設定モジュール(依存される側) - APP_NAME = "DemoApp" - VERSION = "1.0.0" + APP_NAME = 'DemoApp' + VERSION = '1.0.0' """ ), 'utils.py': textwrap.dedent( @@ -144,7 +144,7 @@ def show_info(): import test_package.main # type: ignore # アサーションによる検証(初期値) - assert test_package.main.show_info() == "Running: DemoApp v1.0.0" + assert test_package.main.show_info() == 'Running: DemoApp v1.0.0' # 依存元のconfig.pyを更新 # update_module()を使ってモジュールの内容を書き換えます @@ -153,8 +153,8 @@ def show_info(): 'config.py', """ # 設定モジュール(更新版) - APP_NAME = "UpdatedApp" - VERSION = "2.5.0" + APP_NAME = 'UpdatedApp' + VERSION = '2.5.0' """, ) @@ -169,7 +169,7 @@ def show_info(): import importlib new_main = importlib.import_module('test_package.main') - assert new_main.show_info() == "Running: UpdatedApp v2.5.0" + assert new_main.show_info() == 'Running: UpdatedApp v2.5.0' # 実行方式の検出と表示 # この情報により、どちらの方式で実行されているかが分かります @@ -197,24 +197,24 @@ def _detect_execution_method(): # pytestが実行中かどうかを判定 # pytest実行時は'pytest'モジュールがsys.modulesに存在する if 'pytest' in sys.modules: - return "pytest" + return 'pytest' # __main__モジュールの__file__属性から判定 main_module = sys.modules.get('__main__') if main_module and hasattr(main_module, '__file__'): main_file = main_module.__file__ if main_file and 'test_architecture_demo.py' in main_file: - return "script" + return 'script' - return "unknown" + return 'unknown' # === このテスト設計の実装例 === -# このif __name__ == "__main__":ブロックは、テスト設計の +# このif __name__ == '__main__':ブロックは、テスト設計の # 核心部分です。スクリプト実行時のみ実行され、pytestのインフラストラクチャを # 手動で再現します。 -if __name__ == "__main__": +if __name__ == '__main__': """ スクリプト実行時のエントリーポイント diff --git a/tests/test_circular_import.py b/tests/test_circular_import.py new file mode 100644 index 0000000..a1dd777 --- /dev/null +++ b/tests/test_circular_import.py @@ -0,0 +1,93 @@ +"""循環インポートのテスト + +A → B → A のような循環インポート構造が正しくリロードされることを確認 +""" + +import importlib +import textwrap + +try: + from .test_utils import create_test_modules, update_module +except ImportError: + from test_utils import create_test_modules, update_module + + +def test_circular_import(tmp_path): + """循環インポート(A → B → A)が正しく処理されることを確認""" + + # パッケージ構造を作成 + modules_dir = create_test_modules( + tmp_path, + { + 'module_a.py': textwrap.dedent( + """ + def func_a(): + return "A-v1" + + def call_b(): + from .module_b import func_b + return func_b() + """ + ), + 'module_b.py': textwrap.dedent( + """ + def func_b(): + return "B-v1" + + def call_a(): + from .module_a import func_a + return func_a() + """ + ), + }, + package_name='circular_pkg', + ) + + from circular_pkg import module_a # noqa: F401 # type: ignore + + # 初期値確認 + assert module_a.func_a() == 'A-v1' + assert module_a.call_b() == 'B-v1' + + # モジュールを更新 + update_module( + modules_dir, + 'module_a.py', + """ + def func_a(): + return "A-v2" + + def call_b(): + from .module_b import func_b + return func_b() + """, + ) + + update_module( + modules_dir, + 'module_b.py', + """ + def func_b(): + return "B-v2" + + def call_a(): + from .module_a import func_a + return func_a() + """, + ) + + # deep reloadを実行 + from deep_reloader import deep_reload + + deep_reload(module_a) + + # 更新確認 + new_module_a = importlib.import_module('circular_pkg.module_a') + assert new_module_a.func_a() == 'A-v2' + assert new_module_a.call_b() == 'B-v2' + + +if __name__ == "__main__": + from test_utils import run_test_as_script + + run_test_as_script(test_circular_import, __file__) diff --git a/tests/test_class_alias.py b/tests/test_class_alias.py new file mode 100644 index 0000000..8614d53 --- /dev/null +++ b/tests/test_class_alias.py @@ -0,0 +1,105 @@ +""" +モジュールスコープでのクラス参照(エイリアス)の問題をテストする + +このテストは、モジュールレベルでクラスをエイリアスした場合、 +リロード後にエイリアスが新しいクラスを参照するか確認します。 +""" + +import textwrap + +try: + from .test_utils import create_test_modules, update_module +except ImportError: + from test_utils import create_test_modules, update_module + + +def test_class_alias_problem(tmp_path): + """モジュールスコープでのクラス参照(エイリアス)を検証""" + + # テスト用パッケージを作成 + modules_dir = create_test_modules( + tmp_path, + { + '__init__.py': '', + 'custom_class.py': textwrap.dedent( + ''' + class MyClass: + """カスタムクラス v1""" + VERSION = 1 + + def get_version(self): + return self.VERSION + + # モジュールスコープでエイリアスを作成 + MyAlias = MyClass + ''' + ), + }, + ) + + # 初回インポート + import custom_class # type: ignore + + # リロード前の確認 + obj_from_class = custom_class.MyClass() + obj_from_alias = custom_class.MyAlias() + + version_class_before = obj_from_class.get_version() + version_alias_before = obj_from_alias.get_version() + class_id_before = id(custom_class.MyClass) + alias_id_before = id(custom_class.MyAlias) + + assert version_class_before == 1 + assert version_alias_before == 1 + assert custom_class.MyClass is custom_class.MyAlias + + # custom_class.pyを更新 + update_module( + modules_dir, + 'custom_class.py', + textwrap.dedent( + ''' + class MyClass: + """カスタムクラス v2""" + VERSION = 2 + + def get_version(self): + return self.VERSION + + # モジュールスコープでエイリアスを作成 + MyAlias = MyClass + ''' + ), + ) + + # deep_reloadでリロード + from deep_reloader import deep_reload + + deep_reload(custom_class) + + # リロード後の確認 + obj_from_class = custom_class.MyClass() + obj_from_alias = custom_class.MyAlias() + + version_class_after = obj_from_class.get_version() + version_alias_after = obj_from_alias.get_version() + class_id_after = id(custom_class.MyClass) + alias_id_after = id(custom_class.MyAlias) + + # 検証: バージョンが更新されている + assert version_class_after == 2, f'MyClass.VERSIONが更新されていません: {version_class_after}' + assert version_alias_after == 2, f'MyAlias.VERSIONが更新されていません: {version_alias_after}' + + # 検証: クラスIDが変更されている(新しいクラスオブジェクトが作成された) + assert class_id_before != class_id_after, 'MyClassのIDが変更されていません' + assert alias_id_before != alias_id_after, 'MyAliasのIDが変更されていません' + + # 検証: MyClassとMyAliasは同一オブジェクト + assert custom_class.MyClass is custom_class.MyAlias, 'MyClassとMyAliasが同一オブジェクトではありません' + + +if __name__ == '__main__': + # スクリプト実行モード + from test_utils import run_test_as_script + + run_test_as_script(test_class_alias_problem, __file__) diff --git a/tests/test_isinstance_check.py b/tests/test_isinstance_check.py new file mode 100644 index 0000000..0575047 --- /dev/null +++ b/tests/test_isinstance_check.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" +モジュール参照の一貫性テスト + +sys.modules[name] = self.module により、リロード後も既存のモジュール参照が有効であることを確認する。 +これは deep_reloader の重要な特徴:モジュールオブジェクトのIDを保持することで、 +既存の参照から最新の内容にアクセスできる。 +""" +import textwrap + +try: + from .test_utils import create_test_modules, update_module +except ImportError: + from test_utils import create_test_modules, update_module + + +def test_module_reference_consistency(tmp_path): + """リロード前のモジュール参照が、リロード後も同じIDを保持し、最新の内容にアクセスできることを確認""" + + # テスト用のモジュール構造を作成 + modules_dir = create_test_modules( + tmp_path, + { + '__init__.py': '', + 'main.py': textwrap.dedent( + ''' + version = 1 + + def get_version(): + return version + ''' + ), + }, + package_name='test_pkg', + ) + + import test_pkg.main # type: ignore + + # 重要:変数に保存して、同じオブジェクトを参照し続ける + main_ref = test_pkg.main + + # リロード前のIDと値を記録 + module_id_before = id(main_ref) + version_before = main_ref.get_version() + + assert version_before == 1 + + # モジュールを変更 + update_module( + modules_dir, + 'main.py', + textwrap.dedent( + ''' + version = 2 + + def get_version(): + return version + ''' + ), + ) + + # deep_reload を実行 + from deep_reloader import deep_reload + + deep_reload(main_ref) + + # リロード後のIDと値を確認 + module_id_after = id(main_ref) + version_after = main_ref.get_version() + + # 重要:モジュールオブジェクトのIDが変わっていないことを確認 + assert ( + module_id_before == module_id_after + ), f'モジュールIDが変わっている: before={module_id_before}, after={module_id_after}' + + # 重要:既存の参照から最新の内容にアクセスできることを確認 + assert version_after == 2, 'リロード後の値が反映されていない' + + +if __name__ == '__main__': + from test_utils import run_test_as_script + + run_test_as_script(test_module_reference_consistency, __file__) diff --git a/tests/test_relative_import_package.py b/tests/test_relative_import_package.py index 3b49545..f112ac3 100644 --- a/tests/test_relative_import_package.py +++ b/tests/test_relative_import_package.py @@ -18,10 +18,10 @@ def test_package_level_relative_import(tmp_path): { '__init__.py': textwrap.dedent( """ - package_version = "1.0.0" + package_version = '1.0.0' def package_info(): - return f"Package version: {package_version}" + return f'Package version: {package_version}' """ ), 'childpkg/__init__.py': '', @@ -39,17 +39,17 @@ def get_full_info(): from parentpkg.childpkg import info # noqa: F401 # type: ignore - assert info.get_full_info() == "Child module - Package version: 1.0.0" + assert info.get_full_info() == 'Child module - Package version: 1.0.0' # 親パッケージの __init__.pyを書き換えて値を変更 update_module( modules_dir, '__init__.py', """ - package_version = "2.5.0" + package_version = '2.5.0' def package_info(): - return f"Package version: {package_version}" + return f'Package version: {package_version}' """, ) @@ -60,10 +60,10 @@ def package_info(): # 更新された値を確認 new_info = importlib.import_module('parentpkg.childpkg.info') - assert new_info.get_full_info() == "Child module - Package version: 2.5.0" + assert new_info.get_full_info() == 'Child module - Package version: 2.5.0' -if __name__ == "__main__": +if __name__ == '__main__': from test_utils import run_test_as_script run_test_as_script(test_package_level_relative_import, __file__) diff --git a/tests/test_relative_import_wildcard.py b/tests/test_relative_import_wildcard.py index a0cb4c2..a145ed0 100644 --- a/tests/test_relative_import_wildcard.py +++ b/tests/test_relative_import_wildcard.py @@ -72,10 +72,10 @@ def _private_func(): # __all__ にないので除外される # 更新された値を確認 new_main = importlib.import_module('testpkg.main') - assert new_main.get_values() == "555-updated" + assert new_main.get_values() == '555-updated' -if __name__ == "__main__": +if __name__ == '__main__': from test_utils import run_test_as_script run_test_as_script(test_wildcard_relative_import, __file__) diff --git a/tests/test_runner.py b/tests/test_runner.py index 4407910..d4abf5f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -21,7 +21,7 @@ def show_maya_warning(): """Maya環境での実行時の警告メッセージを表示""" print( textwrap.dedent( - """ + """ 警告: Maya環境での test_runner 実行が検出されました セーフティ機能により実行を停止します。 diff --git a/tests/test_utils.py b/tests/test_utils.py index 4292b5b..f65cd74 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -244,16 +244,16 @@ def run_test_as_script(test_function: Callable[[Path], None], test_file_path: st # テスト実行 test_function(tmp_path) - print("OK: テスト成功!") + print('OK: テスト成功!') except (AssertionError, ImportError, ModuleNotFoundError, AttributeError) as e: - print(f"NG: テスト失敗: {e}") + print(f'NG: テスト失敗: {e}') raise except OSError as e: - print(f"NG: ファイルシステムエラー: {e}") + print(f'NG: ファイルシステムエラー: {e}') raise except Exception as e: - print(f"NG: 予期しないエラー: {e}") + print(f'NG: 予期しないエラー: {e}') raise finally: # テスト後のクリーンアップ From 9dfac92ac6246d9be8c186a80a2a2c60e28414cf Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 16:53:50 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=E3=83=AC=E3=82=AC=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92archive=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module_reloader.py => archive/module_reloader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename module_reloader.py => archive/module_reloader.py (98%) diff --git a/module_reloader.py b/archive/module_reloader.py similarity index 98% rename from module_reloader.py rename to archive/module_reloader.py index 9ed7311..8c2680a 100644 --- a/module_reloader.py +++ b/archive/module_reloader.py @@ -17,8 +17,8 @@ __package_name = '' -def deep_reload(module: ModuleType) -> None: - """module_reloaderの実装をベースとしたdeep_reload +def module_reloader(module: ModuleType) -> None: + """deep_reloaderの初期型 Args: module: リロード対象のモジュール From 138dadf73beb0189c1198f06398f38deb41f39cc Mon Sep 17 00:00:00 2001 From: Hum9183 Date: Sat, 17 Jan 2026 16:54:05 +0900 Subject: [PATCH 7/7] release: v0.3.0 --- README.md | 4 ++-- _metadata.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e103c43..209215a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # deep_reloader > [!WARNING] -> このソフトウェアは現在プレリリース版(v0.2.0)です。APIが変更される可能性があります。 +> このソフトウェアは現在プレリリース版(v0.3.0)です。APIが変更される可能性があります。 Pythonモジュールの依存関係を解析して、再帰的にリロードを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。 @@ -181,7 +181,7 @@ python -m pytest deep_reloader/tests/ -vv ## バージョン情報 -**現在のバージョン**: v0.2.0 (Pre-release) +**現在のバージョン**: v0.3.0 (Pre-release) ### リリース状況 - ✅ コア機能実装完了(from-import対応) diff --git a/_metadata.py b/_metadata.py index 7472731..56ef56d 100644 --- a/_metadata.py +++ b/_metadata.py @@ -1,2 +1,2 @@ -__version__ = '0.2.0' +__version__ = '0.3.0' __author__ = 'Miyakawa Takeshi'