Skip to content

Commit 9e2d69b

Browse files
authored
Merge pull request #8 from Hum9183/release/0.7.0
Release/0.7.0
2 parents 9b66f97 + dd4191e commit 9e2d69b

22 files changed

Lines changed: 1074 additions & 1137 deletions

README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ A Python library that analyzes module dependencies and performs recursive reload
1515
- **Relative Import Support**: Properly handles relative imports within packages
1616
- **Circular Import Support**: Correctly reloads circular imports that work in Python
1717

18+
## Supported Versions
19+
20+
- Maya 2022
21+
- Maya 2023
22+
- Maya 2024
23+
- Maya 2025
24+
- Maya 2026
25+
1826
## Installation
1927

2028
The package can be placed anywhere in the Python path.
@@ -26,10 +34,10 @@ This README uses Maya's common scripts folder as an example.
2634
├── __init__.py
2735
├── _metadata.py
2836
├── deep_reloader.py
37+
├── dependency_extractor.py
38+
├── domain.py
2939
├── from_clause.py
3040
├── import_clause.py
31-
├── module_info.py
32-
├── symbol_extractor.py
3341
├── LICENSE
3442
├── README.md
3543
└── tests/
@@ -97,7 +105,7 @@ pytest tests/ -q
97105
- Python 3.11.9+ (verified in current development environment)
98106
- pytest 8.4.2+ (required for running tests)
99107

100-
**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.
108+
**Note**: The above is the environment used for library testing and development. It differs from the Maya execution environment.
101109

102110
## Limitations and Known Issues
103111

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

145+
### Modules Not Explicitly Imported in `__init__.py` Are Not Detected When Importing the Package (By Design)
146+
147+
Since AST analysis parses the `__init__.py` code, modules under the package cannot be detected if they are not explicitly imported there.
148+
149+
**Example**:
150+
151+
File structure:
152+
- `mypackage/__init__.py` (empty)
153+
- `mypackage/utils.py`
154+
- `main.py`
155+
156+
```python
157+
# main.py
158+
import mypackage
159+
160+
# Reload the package
161+
deep_reload(mypackage)
162+
mypackage.utils.some_function() # utils is not reloaded
163+
```
164+
165+
**Workaround**: Reload the module directly
166+
```python
167+
# main.py
168+
from mypackage import utils
169+
deep_reload(utils)
170+
```
171+
137172
### Single Package Reload Only (By Design)
138173

139174
`deep_reload()` only reloads modules that belong to the same package as the passed module.

_metadata.py

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

deep_reloader.py

Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import sys
55
from pathlib import Path
66
from types import ModuleType
7+
from typing import List
78

8-
from .module_info import ModuleInfo
9-
from .symbol_extractor import SymbolExtractor
9+
from .dependency_extractor import DependencyExtractor
10+
from .domain import DependencyNode
1011

1112
logger = logging.getLogger(__name__)
1213

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

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

6566
# リロード
66-
root.reload()
67+
reload_tree(root)
6768

6869

69-
def _build_tree(module: ModuleType, visited: set, target_package: str) -> ModuleInfo:
70+
def _build_tree(module: ModuleType, visited_modules: set, target_package: str) -> DependencyNode:
7071
"""
71-
AST 解析して ModuleInfo ツリーを構築
72+
AST 解析して DependencyNode ツリーを構築
7273
7374
Args:
7475
module: 解析対象のモジュール
75-
visited: 循環インポート検出用の訪問済みモジュールセット
76+
visited_modules: 循環インポート検出用の訪問済みモジュールセット
7677
target_package: リロード対象のパッケージ名(例: 'routinerecipe')
7778
このパッケージに属するモジュールのみをリロード対象とする
7879
7980
Note:
8081
target_packageに一致しないモジュール(組み込みモジュールやサードパーティライブラリ、その他の自作パッケージ)は
8182
スキップされ、リロード対象から除外されます。
8283
"""
83-
node = ModuleInfo(module)
84+
node = DependencyNode(module)
8485

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

90-
visited.add(module.__name__)
91+
visited_modules.add(module.__name__)
9192

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

99-
child_node = _build_tree(child_module, visited, target_package)
100-
child_node.symbols = symbols
100+
child_node = _build_tree(dependency.module, visited_modules, target_package)
101+
child_node.symbols = dependency.symbols
101102
node.children.append(child_node)
102103

103104
return node
104105

105106

106-
def _clear_pycache_recursive(node: ModuleInfo) -> None:
107+
def _clear_pycache_recursive(node: DependencyNode) -> None:
107108
"""
108-
ModuleInfo ツリー全体を再帰的にたどって __pycache__ を削除
109+
DependencyNode ツリー全体を再帰的にたどって __pycache__ を削除
109110
"""
110111
_clear_single_pycache(node.module)
111112
for child in node.children:
@@ -129,3 +130,82 @@ def _clear_single_pycache(module: ModuleType) -> None:
129130
logger.debug(f'Cleared pycache {pycache_dir}')
130131
except Exception as e:
131132
logger.warning(f'Failed to clear pycache {pycache_dir}: {e!r}')
133+
134+
135+
def reload_tree(node: DependencyNode, visited_modules: set = None) -> None:
136+
"""依存関係ツリーを再帰的にリロード
137+
138+
DependencyNodeで構成された依存ツリーを深さ優先探索でリロードします。
139+
140+
処理の流れ:
141+
1. importlib.reload()で新しいモジュールオブジェクト(reloaded_module)を作成
142+
2. 子モジュールを再帰的にリロード
143+
3. 子のインポートされた名前をreloaded_moduleにコピー(関数の__globals__が正しく参照できるように)
144+
4. node.module.__dict__を更新(削除された属性を除去、新しい属性を追加・上書き)
145+
5. sys.modules[name]にnode.moduleを登録(reloaded_moduleではなく)
146+
147+
重要な設計思想:
148+
- node.moduleのオブジェクトIDを保持することで、既存の参照を有効に保つ
149+
- reloaded_moduleは一時的な作業用オブジェクトとして使用
150+
- __dict__を更新することで、オブジェクトを置き換えずに中身だけを更新
151+
152+
Args:
153+
node: リロード対象のノード
154+
visited_modules: 訪問済みモジュールのセット(循環参照防止)
155+
"""
156+
# 再帰処理で訪問済みモジュールを記録するセット
157+
if visited_modules is None:
158+
visited_modules = set()
159+
160+
name = node.module.__name__
161+
# 既に訪問済みのモジュールはスキップ(重複処理防止・処理時間短縮)
162+
if name in visited_modules:
163+
return
164+
165+
# このモジュールの処理が完了したことをマーク
166+
visited_modules.add(name)
167+
168+
# 子を再帰的にリロード(子が先に完了する必要がある)
169+
for child in node.children:
170+
reload_tree(child, visited_modules)
171+
172+
# importlib.reload()を使用してリロード
173+
# これにより、sys.modulesから削除せずに安全にリロードできる
174+
reloaded_module = importlib.reload(node.module)
175+
176+
# 子のリロード後、from-importで取得した名前を新しいモジュールにコピー
177+
# (reloaded_moduleの関数の__globals__に正しい値を設定するため)
178+
for child in node.children:
179+
if child.symbols is not None:
180+
source_module = sys.modules.get(child.module.__name__, child.module)
181+
_copy_symbols_to(child.symbols, source_module, reloaded_module)
182+
183+
# リロード前のモジュール(node.module)にあって、リロード後のモジュール(reloaded_module)に存在しなくなった属性を削除する
184+
old_attrs = set(node.module.__dict__.keys())
185+
new_attrs = set(reloaded_module.__dict__.keys())
186+
for key in old_attrs - new_attrs:
187+
if not key.startswith('__'): # __name__, __file__等の特殊属性は保持
188+
del node.module.__dict__[key]
189+
190+
# node.module.__dict__をreloaded_module.__dict__で更新(属性を追加・上書き)
191+
node.module.__dict__.update(reloaded_module.__dict__)
192+
193+
# sys.modulesをnode.moduleで上書き
194+
sys.modules[name] = node.module
195+
196+
logger.debug(f'RELOADED {name}')
197+
198+
199+
def _copy_symbols_to(symbols: List[str], source_module: ModuleType, target_module: ModuleType) -> None:
200+
"""source_moduleからtarget_moduleへsymbolsで指定された名前をコピーする
201+
202+
Args:
203+
symbols: コピーする名前のリスト
204+
source_module: コピー元のモジュール
205+
target_module: コピー先のモジュール
206+
"""
207+
for name in symbols:
208+
if hasattr(source_module, name):
209+
value = getattr(source_module, name)
210+
setattr(target_module, name, value)
211+
logger.debug(f'{target_module.__name__}.{name}{source_module.__name__}.{name} ({value!r})')

dependency_extractor.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import ast
2+
import inspect
3+
import logging
4+
from types import ModuleType
5+
from typing import List, Optional
6+
7+
from . import from_clause, import_clause
8+
from .domain import Dependency
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class DependencyExtractor:
14+
"""
15+
モジュールのASTを解析して、依存関係を抽出するクラス
16+
17+
from-import文を解析し、Dependency オブジェクトを抽出します。
18+
19+
例: from math import sin, cos → Dependency(math, ['sin', 'cos'])
20+
from .utils import helper → Dependency(package.utils.helper, None)
21+
"""
22+
23+
def __init__(self, module: ModuleType) -> None:
24+
self._module: ModuleType = module
25+
self._ast_tree: Optional[ast.AST] = self._parse_ast()
26+
27+
def extract(self) -> List[Dependency]:
28+
"""依存関係のリストを返す
29+
30+
Returns:
31+
Dependency オブジェクトのリスト
32+
symbols=None ならモジュール依存、symbols=[...] ならアトリビュート依存
33+
"""
34+
if self._ast_tree is None:
35+
return []
36+
37+
dependencies: List[Dependency] = []
38+
for node in ast.walk(self._ast_tree):
39+
if isinstance(node, ast.ImportFrom):
40+
# 1つのimport文から複数の依存関係が生まれる可能性があるためextendを使用
41+
# 例: from . import module1, module2, func → 最大3つの依存関係が返る
42+
dependencies.extend(self._extract_from_node(node))
43+
return dependencies
44+
45+
def _extract_from_node(self, node: ast.ImportFrom) -> List[Dependency]:
46+
"""ImportFromノードから依存関係を抽出する
47+
48+
戻り値がリストである理由:
49+
1つのimport文から複数の依存関係が生まれる場合がある
50+
例: from . import module1, module2, func
51+
→ [Dependency(module1, None),
52+
Dependency(parent_package, ['module1']),
53+
Dependency(module2, None),
54+
Dependency(parent_package, ['module2']),
55+
Dependency(parent_package, ['func'])]
56+
"""
57+
# from句を解決
58+
from_module = from_clause.resolve(self._module, node.level, node.module)
59+
if from_module is None:
60+
return []
61+
62+
# import句のシンボルを解決(ワイルドカード展開含む)
63+
names = [alias.name for alias in node.names]
64+
symbols = import_clause.resolve(from_module, names)
65+
66+
# 依存関係を生成
67+
dependencies = import_clause.create_dependencies(from_module, self._module, symbols)
68+
69+
# 自分自身への依存のみをフィルタリング
70+
# 例: from . import helper の場合、Dependency(testpkg.helper, None) は残し、
71+
# Dependency(testpkg, ['helper']) は除外
72+
return [dep for dep in dependencies if dep.module is not self._module]
73+
74+
def _parse_ast(self) -> Optional[ast.AST]:
75+
"""モジュールをASTにパース"""
76+
try:
77+
source = inspect.getsource(self._module)
78+
return ast.parse(source)
79+
except (OSError, TypeError, SyntaxError):
80+
# 組み込みモジュール(os, sys等)、バイナリ拡張(.pyd/.so)、
81+
# Maya内部モジュール(maya.cmds等)はソースコードが取得できないためNoneを返す
82+
return None

docs/README.ja.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ Pythonモジュールの依存関係を解析して、再帰的にリロード
1515
- **相対インポート対応**: パッケージ内の相対インポートを正しく処理
1616
- **循環参照対応**: Pythonで動作する循環インポートを正しくリロード
1717

18+
## 動作環境
19+
20+
- Maya 2022
21+
- Maya 2023
22+
- Maya 2024
23+
- Maya 2025
24+
- Maya 2026
25+
1826
## インストール
1927

2028
Pythonパスが通っている場所であればどこでも配置可能です。
@@ -26,10 +34,10 @@ Pythonパスが通っている場所であればどこでも配置可能です
2634
├── __init__.py
2735
├── _metadata.py
2836
├── deep_reloader.py
37+
├── dependency_extractor.py
38+
├── domain.py
2939
├── from_clause.py
3040
├── import_clause.py
31-
├── module_info.py
32-
├── symbol_extractor.py
3341
├── LICENSE
3442
├── README.md
3543
└── tests/
@@ -97,7 +105,7 @@ pytest tests/ -q
97105
- Python 3.11.9+(現在の開発環境で検証済み)
98106
- pytest 8.4.2+(テスト実行に必須)
99107

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

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

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

145+
### そのパッケージの`__init__.py`で明示的にインポートされていないモジュールはパッケージをインポートしても検出されない(仕様)
146+
147+
AST解析は`__init__.py`のコードを解析するため、そこで明示的にインポートされていない場合そのパッケージ配下のモジュールは検出できません。
148+
149+
****:
150+
151+
ファイル構造:
152+
- `mypackage/__init__.py` (中身は空)
153+
- `mypackage/utils.py`
154+
- `main.py`
155+
156+
```python
157+
# main.py
158+
import mypackage
159+
160+
# パッケージをリロード
161+
deep_reload(mypackage)
162+
mypackage.utils.some_function() # utilsはリロードされない
163+
```
164+
165+
**回避策**: モジュールを直接リロードする
166+
```python
167+
# main.py
168+
from mypackage import utils
169+
deep_reload(utils)
170+
```
171+
172+
137173
### 単一パッケージのみリロード(仕様)
138174

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

0 commit comments

Comments
 (0)