Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 73 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
# deep_reloader

> [!WARNING]
> このソフトウェアは現在プレリリース版(v0.2.0)です。APIが変更される可能性があります。
> このソフトウェアは現在プレリリース版(v0.3.0)です。APIが変更される可能性があります。

Pythonモジュールの依存関係を解析して、再帰的に再読み込みを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。
Pythonモジュールの依存関係を解析して、再帰的にリロードを行うライブラリです。特にMayaでのスクリプト開発時に、モジュール変更を即座に反映させるために設計されています。

## 機能

- **深い再読み込み**: from-import の依存関係を自動解析
- **深いリロード**: 深い階層でもリロードが可能
- **AST解析**: 静的解析により from-import文 を正確に検出
- **ワイルドカード対応**: `from module import *` もサポート
- **相対インポート対応**: パッケージ内の相対インポートを正しく処理
- **`__pycache__`クリア**: 古いキャッシュファイルを自動削除

## 制限事項・既知の問題

- **import文未対応**: 現在は `import module` 形式の依存関係は解析対象外です
- 対応: `from module import something` 形式のみ解析・リロード
- 今後のバージョンで `import module` にも対応予定
- **循環インポート**: 循環インポート(A → B → A のような相互依存)が存在するモジュール構造では現在エラーが発生します
- 今後のバージョンで対応予定
- 回避策: 循環依存を避けた設計に変更するか、手動での部分リロードをご検討ください
- **循環参照対応**: Pythonで動作する循環インポート(関数内での遅延インポート)を正しくリロード

## 使用方法

Expand Down Expand Up @@ -116,33 +107,90 @@ 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パッケージをリロード
```

## バージョン情報

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

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

Expand Down
2 changes: 1 addition & 1 deletion _metadata.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = '0.2.0'
__version__ = '0.3.0'
__author__ = 'Miyakawa Takeshi'
208 changes: 208 additions & 0 deletions archive/module_reloader.py
Original file line number Diff line number Diff line change
@@ -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 module_reloader(module: ModuleType) -> None:
"""deep_reloaderの初期型

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
Loading