Skip to content

Commit f301feb

Browse files
authored
Release/v0.3.0 (#4)
* refactor(tests): テストユーティリティAPIの改善と全テストファイルの統一 - create_test_modules()とupdate_module()の導入 - make_temp_module()から辞書ベースAPIへ移行 - test_architecture_demo.pyを依存チェーンテストに変更 * fix: テストのインポートを相対インポートに統一 * docs: デコレーターのクロージャ問題を文書化 * chore: 初期型のmodule_reloaderを開発参考用に追加 * feat: 循環参照対応と単一パッケージスコープの実装 主要変更: - 循環インポート(A→B→A)のサポート追加 - リロード対象を単一パッケージに限定し、組み込み/サードパーティを自動除外 - リロードロジックを簡素化(overwrite_symbols廃止) - テストを12個に拡充(循環参照、モジュール参照、クラスエイリアステスト追加) - コード品質改善(文字列リテラルの統一、ドキュメント更新) * chore: レガシーコードをarchiveに移動 * release: v0.3.0
1 parent e9f1c7d commit f301feb

21 files changed

Lines changed: 1094 additions & 608 deletions

README.md

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
11
# deep_reloader
22

33
> [!WARNING]
4-
> このソフトウェアは現在プレリリース版(v0.2.0)です。APIが変更される可能性があります。
4+
> このソフトウェアは現在プレリリース版(v0.3.0)です。APIが変更される可能性があります。
55
6-
Pythonモジュールの依存関係を解析して、再帰的に再読み込みを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。
6+
Pythonモジュールの依存関係を解析して、再帰的にリロードを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。
77

88
## 機能
99

10-
- **深い再読み込み**: from-import の依存関係を自動解析
10+
- **深いリロード**: 深い階層でもリロードが可能
1111
- **AST解析**: 静的解析により from-import文 を正確に検出
1212
- **ワイルドカード対応**: `from module import *` もサポート
1313
- **相対インポート対応**: パッケージ内の相対インポートを正しく処理
14-
- **`__pycache__`クリア**: 古いキャッシュファイルを自動削除
15-
16-
## 制限事項・既知の問題
17-
18-
- **import文未対応**: 現在は `import module` 形式の依存関係は解析対象外です
19-
- 対応: `from module import something` 形式のみ解析・リロード
20-
- 今後のバージョンで `import module` にも対応予定
21-
- **循環インポート**: 循環インポート(A → B → A のような相互依存)が存在するモジュール構造では現在エラーが発生します
22-
- 今後のバージョンで対応予定
23-
- 回避策: 循環依存を避けた設計に変更するか、手動での部分リロードをご検討ください
14+
- **循環参照対応**: Pythonで動作する循環インポート(関数内での遅延インポート)を正しくリロード
2415

2516
## 使用方法
2617

@@ -116,33 +107,90 @@ python -m pytest deep_reloader/tests/test_absolute_import_basic.py -v
116107
python -m pytest deep_reloader/tests/ -vv
117108
```
118109

119-
### テストアーキテクチャの特徴
120-
121-
- **二種の実行をサポート**: 各テストファイルはスクリプト実行とpytest実行の両方に対応
122-
- **条件付きインポート**: 実行環境に応じて相対/絶対インポートを自動切り替え
123-
- **一時ディレクトリ管理**: 手動作成(スクリプト実行)と`tmp_path`(pytest)の両方をサポート
124-
125110
### 動作確認済み環境
126111

127112
**テスト開発環境(Maya以外):**
128113
- Python 3.11.9+(現在の開発環境で検証済み)
129114
- pytest 8.4.2+(テスト実行時のみ、現在の開発環境で検証済み)
130115

131-
**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。Mayaのサポートバージョンは確定していません。
116+
**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。Mayaのサポートバージョンはまだ確定していません。
117+
118+
## 制限事項・既知の問題
119+
120+
- **isinstance()チェックの失敗**(Python言語仕様の制約 - 解決不可能)
121+
- リロード前に作成したインスタンスは、リロード後のクラスで`isinstance()`チェックが失敗します
122+
- これはPython言語仕様の制約であり、すべてのリロードシステムが抱える共通の問題です
123+
- **原因**: リロード後、クラスオブジェクトのIDが変わるため、リロード前のインスタンスは古いクラスを参照し続けます
124+
- ****:
125+
```python
126+
# リロード前
127+
obj = MyClass()
128+
isinstance(obj, MyClass) # True
129+
130+
# deep_reload後
131+
isinstance(obj, MyClass) # False(objは古いMyClassのインスタンス、MyClassは新しいクラス)
132+
```
133+
- **回避策**:
134+
- リロード後にインスタンスを再作成する
135+
- クラス名での文字列比較を使用する(`type(obj).__name__ == 'MyClass'`
136+
- アプリケーションを再起動する
137+
138+
- **デコレーターのクロージャ問題**(Python言語仕様の制約 - 解決不可能)
139+
- デコレーター内で例外クラスをキャッチする場合、リロード後に正しくキャッチできません
140+
- これはPython言語仕様の制約であり、すべてのリロードシステム(`importlib.reload()`, IPythonの`%autoreload`等)が抱える共通の問題です
141+
- **原因**: デコレーターのクロージャは定義時にクラスオブジェクトへの参照を保持し、リロード後も古いクラスオブジェクトを参照し続けます
142+
- ****:
143+
```python
144+
# custom_error.py
145+
class CustomError(Exception):
146+
@staticmethod
147+
def catch(function):
148+
@functools.wraps(function)
149+
def wrapper(*args, **kwargs):
150+
try:
151+
return function(*args, **kwargs)
152+
except CustomError as e: # ←デコレーター定義時のCustomErrorを保持
153+
return f"Caught: {e}"
154+
return wrapper
155+
156+
# main.py
157+
@CustomError.catch # ←リロード後、このクロージャは古いCustomErrorを参照
158+
def risky_function():
159+
raise CustomError("Error") # ←新しいCustomErrorを投げる
160+
```
161+
- **回避策**:
162+
- デコレーターを使用せず、直接`try-except`で例外をキャッチする
163+
- 例外クラスをリロード対象から除外する
164+
- アプリケーションを再起動する
165+
166+
- **import文未対応**(将来対応予定)
167+
- 現在は `import module` 形式の依存関係は解析対象外です
168+
- 対応: `from module import something` 形式のみ解析・リロード
169+
- 今後のバージョンで対応予定
170+
171+
- **単一パッケージのみリロード**(仕様)
172+
- `deep_reload()`は、指定されたモジュールと同じパッケージに属するモジュールのみをリロードします
173+
- **理由**: 組み込みモジュール(`collections`等)やサードパーティライブラリ(`maya.cmds`, `PySide2`等)のリロードを防ぎ、システムの安定性を保つため
174+
- ****: `deep_reload(routinerecipe.main)` を実行すると、`routinerecipe`パッケージ内のモジュールのみがリロードされます
175+
- **複数の自作パッケージを開発している場合**:
176+
```python
177+
# routinerecipe と myutils の両方を開発中の場合
178+
deep_reload(myutils.helper) # myutilsパッケージをリロード
179+
deep_reload(routinerecipe.main) # routinerecipeパッケージをリロード
180+
```
132181

133182
## バージョン情報
134183

135-
**現在のバージョン**: v0.2.0 (Pre-release)
184+
**現在のバージョン**: v0.3.0 (Pre-release)
136185

137186
### リリース状況
138187
- ✅ コア機能実装完了(from-import対応)
139-
- ✅ テストスイート(9テスト
188+
- ✅ テストスイート(12テスト
140189
- ✅ ドキュメント整備
190+
- ✅ Maya環境での動作検証
191+
- ✅ 循環インポート対応
141192
- 🔄 APIの安定化作業中
142-
- 📋 Maya環境での動作検証
143193
- 📋 import文対応の追加
144-
- 📋 循環インポートエラー対応
145-
- 📋 組み込み・サードパーティモジュールのスキップ処理
146194
- 📋 デバッグログの強化
147195
- 📋 パフォーマンス最適化とキャッシュ機能
148196

_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = '0.2.0'
1+
__version__ = '0.3.0'
22
__author__ = 'Miyakawa Takeshi'

archive/module_reloader.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""deep_reloaderの初期型"""
4+
5+
import _ast
6+
import ast
7+
import importlib
8+
import inspect
9+
import shutil
10+
import sys
11+
from pathlib import Path
12+
from types import ModuleType
13+
from typing import Any, Dict, List, Tuple, cast
14+
15+
# ref. https://graphics.hatenablog.com/entry/2019/12/22/052819
16+
17+
__package_name = ''
18+
19+
20+
def module_reloader(module: ModuleType) -> None:
21+
"""deep_reloaderの初期型
22+
23+
Args:
24+
module: リロード対象のモジュール
25+
"""
26+
global __package_name
27+
28+
# モジュール名からパッケージ名を自動推定
29+
module_name = module.__name__
30+
if '.' in module_name:
31+
# パッケージの一部の場合は、最上位パッケージ名を使用
32+
__package_name = module_name.split('.')[0]
33+
else:
34+
# トップレベルモジュールの場合はモジュール名をそのまま使用
35+
__package_name = module_name
36+
37+
_delete_modules()
38+
39+
from_import_symbols: List[Tuple[ModuleType, Dict[ModuleType, List[str]]]] = _get_symbols(module)
40+
41+
parent: ModuleType
42+
children_symbols: Dict[ModuleType, List[str]]
43+
for parent, children_symbols in from_import_symbols:
44+
_reload(children_symbols)
45+
_overwrite_with_reloaded_symbols(parent, children_symbols)
46+
47+
48+
def _delete_modules() -> None:
49+
global __package_name
50+
51+
# パッケージ名に基づいてsys.modulesからモジュールを削除
52+
for module_name in list(sys.modules.keys()):
53+
if module_name.startswith(__package_name):
54+
del sys.modules[module_name]
55+
56+
57+
def _get_symbols(parent: ModuleType) -> List[Tuple[ModuleType, Dict[ModuleType, List[str]]]]:
58+
children_symbols: Dict[ModuleType, List[str]] = get_children_symbols(parent)
59+
result = []
60+
for child_module in children_symbols.keys():
61+
result.extend(_get_symbols(child_module))
62+
result.append((parent, children_symbols))
63+
return result
64+
65+
66+
def get_children_symbols(module: ModuleType):
67+
children_symbols: Dict[ModuleType, List[Any]] = {}
68+
69+
try:
70+
source = inspect.getsource(module)
71+
except Exception:
72+
# ソースコードが取得できない場合(組み込みモジュールなど)はスキップ
73+
return children_symbols
74+
75+
tree: _ast.Module = ast.parse(source)
76+
77+
stmt: _ast.stmt
78+
for stmt in tree.body:
79+
# TODO: import xxx の場合のサポートも必要?
80+
# from xxx import でないならcontinue
81+
if stmt.__class__ != _ast.ImportFrom:
82+
continue
83+
84+
imp_frm = cast(_ast.ImportFrom, stmt)
85+
86+
# モジュール名を取得(相対インポートの場合の特別処理を含む)
87+
module_name = imp_frm.module
88+
89+
# モジュールのフルネームを取得
90+
if imp_frm.level == 0:
91+
# 絶対インポート: from module import something
92+
if module_name is None:
93+
continue
94+
module_full_name = f'{module_name}'
95+
elif imp_frm.level == 1:
96+
# 同階層相対インポート: from .module import something
97+
if module_name is None:
98+
# from . import something (現在のパッケージから直接インポート)
99+
module_full_name = module.__package__
100+
else:
101+
# from .module import something
102+
module_full_name = f'{module.__package__}.{module_name}'
103+
elif imp_frm.level >= 2:
104+
# 上位階層相対インポート: from ..module import something
105+
package_names = module.__package__.split('.')
106+
package_names = package_names[: -(imp_frm.level - 1)]
107+
package_names = '.'.join(package_names)
108+
if module_name is None:
109+
# from .. import something (上位パッケージから直接インポート)
110+
module_full_name = package_names
111+
else:
112+
# from ..module import something
113+
module_full_name = f'{package_names}.{module_name}'
114+
else:
115+
raise Exception('module_reloaderにて例外が発生しました。ソースコードを確認してください')
116+
117+
# リロード対象ではないならcontinue
118+
global __package_name
119+
if not module_full_name.startswith(__package_name):
120+
continue
121+
122+
try:
123+
new_module: ModuleType = importlib.import_module(module_full_name)
124+
except Exception:
125+
# インポートに失敗した場合はスキップ
126+
continue
127+
128+
# packageならスキップ(フリーズ防止のため重要)
129+
if _is_package(new_module):
130+
# NOTE: from xxx import yyy のyyyがモジュールのため、シンボルを上書きする必要はない。
131+
continue
132+
133+
symbol_names: List[str] = [x.name for x in imp_frm.names]
134+
135+
# wildcard importの場合
136+
if symbol_names[0] == '*':
137+
if '__all__' in new_module.__dict__:
138+
symbol_names = new_module.__dict__['__all__']
139+
else:
140+
symbol_names = [x for x in new_module.__dict__ if not x.startswith('__')]
141+
142+
children_symbols[new_module] = symbol_names
143+
144+
return children_symbols
145+
146+
147+
def _is_package(module: ModuleType) -> bool:
148+
"""モジュールがパッケージ(__init__.py)かどうかを判定"""
149+
file = module.__file__
150+
return file is None or file.endswith('__init__.py')
151+
152+
153+
def _reload(children_symbols: Dict[ModuleType, List[str]]) -> None:
154+
for child_module in children_symbols.keys():
155+
# 強力なリロード: sys.modulesから削除してから再インポート
156+
module_name = child_module.__name__
157+
158+
# .pycファイルを削除(キャッシュクリア)
159+
_clear_single_pycache(child_module)
160+
161+
# sys.modulesから削除
162+
if module_name in sys.modules:
163+
del sys.modules[module_name]
164+
165+
# キャッシュをクリア
166+
importlib.invalidate_caches()
167+
168+
# 再インポート
169+
try:
170+
reloaded_module = importlib.import_module(module_name)
171+
172+
# 元のモジュールオブジェクトの辞書を更新
173+
child_module.__dict__.clear()
174+
child_module.__dict__.update(reloaded_module.__dict__)
175+
176+
except Exception:
177+
# フォールバック: 通常のリロード
178+
importlib.reload(child_module)
179+
180+
181+
def _clear_single_pycache(module: ModuleType) -> None:
182+
"""
183+
1つのモジュールに対応する __pycache__ を削除
184+
"""
185+
module_file = getattr(module, '__file__', None)
186+
if module_file is None:
187+
return
188+
189+
module_dir = Path(module_file).parent
190+
pycache_dir = module_dir / '__pycache__'
191+
192+
if pycache_dir.exists():
193+
try:
194+
shutil.rmtree(pycache_dir)
195+
except Exception:
196+
pass # エラーは無視
197+
198+
199+
def _overwrite_with_reloaded_symbols(parent: ModuleType, children_symbols: Dict[ModuleType, List[str]]) -> None:
200+
no_key = 'no key'
201+
202+
for child_module, child_symbol_names in children_symbols.items():
203+
for child_symbol_name in child_symbol_names:
204+
val = child_module.__dict__.get(child_symbol_name, no_key)
205+
if val == no_key:
206+
print(f'sys.modulesに{child_symbol_name}が存在しません')
207+
else:
208+
parent.__dict__[child_symbol_name] = val

0 commit comments

Comments
 (0)