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
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ A Python library that analyzes module dependencies and performs recursive reload
- **Relative Import Support**: Properly handles relative imports within packages
- **Circular Import Support**: Correctly reloads circular imports that work in Python

## Supported Versions

- Maya 2022
- Maya 2023
- Maya 2024
- Maya 2025
- Maya 2026

## Installation

The package can be placed anywhere in the Python path.
Expand All @@ -26,10 +34,10 @@ This README uses Maya's common scripts folder as an example.
├── __init__.py
├── _metadata.py
├── deep_reloader.py
├── dependency_extractor.py
├── domain.py
├── from_clause.py
├── import_clause.py
├── module_info.py
├── symbol_extractor.py
├── LICENSE
├── README.md
└── tests/
Expand Down Expand Up @@ -97,7 +105,7 @@ pytest tests/ -q
- Python 3.11.9+ (verified in current development environment)
- pytest 8.4.2+ (required for running tests)

**Note**: The above is the environment used for library testing and development. It differs from the Maya execution environment. Supported Maya versions are not yet finalized.
**Note**: The above is the environment used for library testing and development. It differs from the Maya execution environment.

## Limitations and Known Issues

Expand Down Expand Up @@ -134,6 +142,33 @@ isinstance(my_class, MyClass) # False (my_class is an instance of old MyClass,
- `from .xxx import yyy` style
- `from . import yyy` style

### Modules Not Explicitly Imported in `__init__.py` Are Not Detected When Importing the Package (By Design)

Since AST analysis parses the `__init__.py` code, modules under the package cannot be detected if they are not explicitly imported there.

**Example**:

File structure:
- `mypackage/__init__.py` (empty)
- `mypackage/utils.py`
- `main.py`

```python
# main.py
import mypackage

# Reload the package
deep_reload(mypackage)
mypackage.utils.some_function() # utils is not reloaded
```

**Workaround**: Reload the module directly
```python
# main.py
from mypackage import utils
deep_reload(utils)
```

### Single Package Reload Only (By Design)

`deep_reload()` only reloads modules that belong to the same package as the passed module.
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.5.0'
__version__ = '0.7.0'
__author__ = 'Miyakawa Takeshi'
118 changes: 99 additions & 19 deletions deep_reloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import sys
from pathlib import Path
from types import ModuleType
from typing import List

from .module_info import ModuleInfo
from .symbol_extractor import SymbolExtractor
from .dependency_extractor import DependencyExtractor
from .domain import DependencyNode

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,56 +57,56 @@ def deep_reload(module: ModuleType) -> None:
# - 依存関係ツリーの視覚的表示(階層構造、インデント付き)
# - 各モジュールの詳細情報(パス、サイズ、最終更新時刻)
# - スキップされるモジュールの理由と一覧
visited = set() # 循環インポート検出用
root = _build_tree(module, visited, target_package)
visited_modules = set() # 循環インポート検出用
root = _build_tree(module, visited_modules, target_package)

# ツリー全体の __pycache__ を削除
_clear_pycache_recursive(root)

# リロード
root.reload()
reload_tree(root)


def _build_tree(module: ModuleType, visited: set, target_package: str) -> ModuleInfo:
def _build_tree(module: ModuleType, visited_modules: set, target_package: str) -> DependencyNode:
"""
AST 解析して ModuleInfo ツリーを構築
AST 解析して DependencyNode ツリーを構築

Args:
module: 解析対象のモジュール
visited: 循環インポート検出用の訪問済みモジュールセット
visited_modules: 循環インポート検出用の訪問済みモジュールセット
target_package: リロード対象のパッケージ名(例: 'routinerecipe')
このパッケージに属するモジュールのみをリロード対象とする

Note:
target_packageに一致しないモジュール(組み込みモジュールやサードパーティライブラリ、その他の自作パッケージ)は
スキップされ、リロード対象から除外されます。
"""
node = ModuleInfo(module)
node = DependencyNode(module)

# 循環インポート検出: すでに訪問済みなら子の展開はスキップ(無限ループ防止)
# ノード自体は作成して返す(将来のデバッグ出力で循環参照を可視化するため)
if module.__name__ in visited:
if module.__name__ in visited_modules:
return node

visited.add(module.__name__)
visited_modules.add(module.__name__)

extractor = SymbolExtractor(module)
for child_module, symbols in extractor.extract():
extractor = DependencyExtractor(module)
for dependency in extractor.extract():
# ターゲットパッケージに属するモジュールのみをツリーに追加
if not child_module.__name__.startswith(target_package):
logger.debug(f'Skipped module (not in target package): {child_module.__name__}')
if not dependency.module.__name__.startswith(target_package):
logger.debug(f'Skipped module (not in target package): {dependency.module.__name__}')
continue

child_node = _build_tree(child_module, visited, target_package)
child_node.symbols = symbols
child_node = _build_tree(dependency.module, visited_modules, target_package)
child_node.symbols = dependency.symbols
node.children.append(child_node)

return node


def _clear_pycache_recursive(node: ModuleInfo) -> None:
def _clear_pycache_recursive(node: DependencyNode) -> None:
"""
ModuleInfo ツリー全体を再帰的にたどって __pycache__ を削除
DependencyNode ツリー全体を再帰的にたどって __pycache__ を削除
"""
_clear_single_pycache(node.module)
for child in node.children:
Expand All @@ -129,3 +130,82 @@ 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}')


def reload_tree(node: DependencyNode, visited_modules: set = None) -> None:
"""依存関係ツリーを再帰的にリロード

DependencyNodeで構成された依存ツリーを深さ優先探索でリロードします。

処理の流れ:
1. importlib.reload()で新しいモジュールオブジェクト(reloaded_module)を作成
2. 子モジュールを再帰的にリロード
3. 子のインポートされた名前をreloaded_moduleにコピー(関数の__globals__が正しく参照できるように)
4. node.module.__dict__を更新(削除された属性を除去、新しい属性を追加・上書き)
5. sys.modules[name]にnode.moduleを登録(reloaded_moduleではなく)

重要な設計思想:
- node.moduleのオブジェクトIDを保持することで、既存の参照を有効に保つ
- reloaded_moduleは一時的な作業用オブジェクトとして使用
- __dict__を更新することで、オブジェクトを置き換えずに中身だけを更新

Args:
node: リロード対象のノード
visited_modules: 訪問済みモジュールのセット(循環参照防止)
"""
# 再帰処理で訪問済みモジュールを記録するセット
if visited_modules is None:
visited_modules = set()

name = node.module.__name__
# 既に訪問済みのモジュールはスキップ(重複処理防止・処理時間短縮)
if name in visited_modules:
return

# このモジュールの処理が完了したことをマーク
visited_modules.add(name)

# 子を再帰的にリロード(子が先に完了する必要がある)
for child in node.children:
reload_tree(child, visited_modules)

# importlib.reload()を使用してリロード
# これにより、sys.modulesから削除せずに安全にリロードできる
reloaded_module = importlib.reload(node.module)

# 子のリロード後、from-importで取得した名前を新しいモジュールにコピー
# (reloaded_moduleの関数の__globals__に正しい値を設定するため)
for child in node.children:
if child.symbols is not None:
source_module = sys.modules.get(child.module.__name__, child.module)
_copy_symbols_to(child.symbols, source_module, reloaded_module)

# リロード前のモジュール(node.module)にあって、リロード後のモジュール(reloaded_module)に存在しなくなった属性を削除する
old_attrs = set(node.module.__dict__.keys())
new_attrs = set(reloaded_module.__dict__.keys())
for key in old_attrs - new_attrs:
if not key.startswith('__'): # __name__, __file__等の特殊属性は保持
del node.module.__dict__[key]

# node.module.__dict__をreloaded_module.__dict__で更新(属性を追加・上書き)
node.module.__dict__.update(reloaded_module.__dict__)

# sys.modulesをnode.moduleで上書き
sys.modules[name] = node.module

logger.debug(f'RELOADED {name}')


def _copy_symbols_to(symbols: List[str], source_module: ModuleType, target_module: ModuleType) -> None:
"""source_moduleからtarget_moduleへsymbolsで指定された名前をコピーする

Args:
symbols: コピーする名前のリスト
source_module: コピー元のモジュール
target_module: コピー先のモジュール
"""
for name in symbols:
if hasattr(source_module, name):
value = getattr(source_module, name)
setattr(target_module, name, value)
logger.debug(f'{target_module.__name__}.{name} ← {source_module.__name__}.{name} ({value!r})')
82 changes: 82 additions & 0 deletions dependency_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import ast
import inspect
import logging
from types import ModuleType
from typing import List, Optional

from . import from_clause, import_clause
from .domain import Dependency

logger = logging.getLogger(__name__)


class DependencyExtractor:
"""
モジュールのASTを解析して、依存関係を抽出するクラス

from-import文を解析し、Dependency オブジェクトを抽出します。

例: from math import sin, cos → Dependency(math, ['sin', 'cos'])
from .utils import helper → Dependency(package.utils.helper, None)
"""

def __init__(self, module: ModuleType) -> None:
self._module: ModuleType = module
self._ast_tree: Optional[ast.AST] = self._parse_ast()

def extract(self) -> List[Dependency]:
"""依存関係のリストを返す

Returns:
Dependency オブジェクトのリスト
symbols=None ならモジュール依存、symbols=[...] ならアトリビュート依存
"""
if self._ast_tree is None:
return []

dependencies: List[Dependency] = []
for node in ast.walk(self._ast_tree):
if isinstance(node, ast.ImportFrom):
# 1つのimport文から複数の依存関係が生まれる可能性があるためextendを使用
# 例: from . import module1, module2, func → 最大3つの依存関係が返る
dependencies.extend(self._extract_from_node(node))
return dependencies

def _extract_from_node(self, node: ast.ImportFrom) -> List[Dependency]:
"""ImportFromノードから依存関係を抽出する

戻り値がリストである理由:
1つのimport文から複数の依存関係が生まれる場合がある
例: from . import module1, module2, func
→ [Dependency(module1, None),
Dependency(parent_package, ['module1']),
Dependency(module2, None),
Dependency(parent_package, ['module2']),
Dependency(parent_package, ['func'])]
"""
# from句を解決
from_module = from_clause.resolve(self._module, node.level, node.module)
if from_module is None:
return []

# import句のシンボルを解決(ワイルドカード展開含む)
names = [alias.name for alias in node.names]
symbols = import_clause.resolve(from_module, names)

# 依存関係を生成
dependencies = import_clause.create_dependencies(from_module, self._module, symbols)

# 自分自身への依存のみをフィルタリング
# 例: from . import helper の場合、Dependency(testpkg.helper, None) は残し、
# Dependency(testpkg, ['helper']) は除外
return [dep for dep in dependencies if dep.module is not self._module]

def _parse_ast(self) -> Optional[ast.AST]:
"""モジュールをASTにパース"""
try:
source = inspect.getsource(self._module)
return ast.parse(source)
except (OSError, TypeError, SyntaxError):
# 組み込みモジュール(os, sys等)、バイナリ拡張(.pyd/.so)、
# Maya内部モジュール(maya.cmds等)はソースコードが取得できないためNoneを返す
return None
42 changes: 39 additions & 3 deletions docs/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ Pythonモジュールの依存関係を解析して、再帰的にリロード
- **相対インポート対応**: パッケージ内の相対インポートを正しく処理
- **循環参照対応**: Pythonで動作する循環インポートを正しくリロード

## 動作環境

- Maya 2022
- Maya 2023
- Maya 2024
- Maya 2025
- Maya 2026

## インストール

Pythonパスが通っている場所であればどこでも配置可能です。
Expand All @@ -26,10 +34,10 @@ Pythonパスが通っている場所であればどこでも配置可能です
├── __init__.py
├── _metadata.py
├── deep_reloader.py
├── dependency_extractor.py
├── domain.py
├── from_clause.py
├── import_clause.py
├── module_info.py
├── symbol_extractor.py
├── LICENSE
├── README.md
└── tests/
Expand Down Expand Up @@ -97,7 +105,7 @@ pytest tests/ -q
- Python 3.11.9+(現在の開発環境で検証済み)
- pytest 8.4.2+(テスト実行に必須)

**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。Mayaのサポートバージョンはまだ確定していません。
**注意**: 上記はライブラリのテスト・開発で使用している環境です。Maya内での実行環境とは異なります。

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

Expand Down Expand Up @@ -134,6 +142,34 @@ isinstance(my_class, MyClass) # False(my_classは古いMyClassのインスタ
- `from .xxx import yyy` 形式
- `from . import yyy` 形式

### そのパッケージの`__init__.py`で明示的にインポートされていないモジュールはパッケージをインポートしても検出されない(仕様)

AST解析は`__init__.py`のコードを解析するため、そこで明示的にインポートされていない場合そのパッケージ配下のモジュールは検出できません。

**例**:

ファイル構造:
- `mypackage/__init__.py` (中身は空)
- `mypackage/utils.py`
- `main.py`

```python
# main.py
import mypackage

# パッケージをリロード
deep_reload(mypackage)
mypackage.utils.some_function() # utilsはリロードされない
```

**回避策**: モジュールを直接リロードする
```python
# main.py
from mypackage import utils
deep_reload(utils)
```


### 単一パッケージのみリロード(仕様)

`deep_reload()`は、渡されたモジュールと同じパッケージに属するモジュールのみをリロードします。
Expand Down
Loading