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
2 changes: 1 addition & 1 deletion _metadata.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = '0.7.0'
__version__ = '0.8.0'
__author__ = 'Miyakawa Takeshi'
34 changes: 6 additions & 28 deletions deep_reloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import sys
from pathlib import Path
from types import ModuleType
from typing import List

from .dependency_extractor import DependencyExtractor
from .domain import DependencyNode
Expand Down Expand Up @@ -138,16 +137,16 @@ 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ではなく)
1. 子モジュールを再帰的にリロード(子が先に完了する必要がある)
2. importlib.reload()で新しいモジュールオブジェクト(reloaded_module)を作成
3. node.module.__dict__を更新(削除された属性を除去、新しい属性を追加・上書き)
4. sys.modules[name]にnode.moduleを登録(reloaded_moduleではなく)

重要な設計思想:
- node.moduleのオブジェクトIDを保持することで、既存の参照を有効に保つ
- reloaded_moduleは一時的な作業用オブジェクトとして使用
- __dict__を更新することで、オブジェクトを置き換えずに中身だけを更新
- importlib.reload()が自動的にimport文を再実行するため、from-importしたシンボルも最新になる

Args:
node: リロード対象のノード
Expand All @@ -171,15 +170,9 @@ def reload_tree(node: DependencyNode, visited_modules: set = None) -> None:

# importlib.reload()を使用してリロード
# これにより、sys.modulesから削除せずに安全にリロードできる
# また、from .child import xxx などのimport文が再実行され、最新の値が自動的に設定される
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())
Expand All @@ -194,18 +187,3 @@ def reload_tree(node: DependencyNode, visited_modules: set = None) -> None:
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})')
3 changes: 2 additions & 1 deletion dependency_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ def _parse_ast(self) -> Optional[ast.AST]:
try:
source = inspect.getsource(self._module)
return ast.parse(source)
except (OSError, TypeError, SyntaxError):
except (OSError, TypeError, SyntaxError) as e:
# 組み込みモジュール(os, sys等)、バイナリ拡張(.pyd/.so)、
# Maya内部モジュール(maya.cmds等)はソースコードが取得できないためNoneを返す
logger.debug(f'Failed to parse AST for {self._module.__name__}: {type(e).__name__}: {e}')
return None
21 changes: 17 additions & 4 deletions from_clause.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
"""

import importlib
import logging
from types import ModuleType
from typing import Optional, Tuple

logger = logging.getLogger(__name__)


def resolve(base_module: ModuleType, level: int, module_name: Optional[str]) -> Optional[ModuleType]:
"""from句のモジュールを解決する
Expand Down Expand Up @@ -73,7 +76,8 @@ def _try_import_submodule(from_module: ModuleType, name: str) -> Optional[Module
try:
full_name = f'{from_module.__name__}.{name}'
return importlib.import_module(full_name)
except (ModuleNotFoundError, ImportError):
except (ModuleNotFoundError, ImportError) as e:
logger.debug(f'Failed to import submodule {from_module.__name__}.{name}: {type(e).__name__}: {e}')
return None


Expand All @@ -92,7 +96,10 @@ def _import(base_module: ModuleType, level: int, module_name: Optional[str]) ->
else:
# 絶対インポート(from xxx import yyy)の場合
return importlib.import_module(module_name)
except (ModuleNotFoundError, ImportError):
except (ModuleNotFoundError, ImportError) as e:
logger.debug(
f'Failed to import module (base={base_module.__name__}, level={level}, module={module_name}): {type(e).__name__}: {e}'
)
return None


Expand Down Expand Up @@ -121,7 +128,10 @@ def _import_relative(base_module: ModuleType, level: int, module_name: str) -> O

target_name = f'{base_name}.{module_name}'
return importlib.import_module(target_name)
except (ModuleNotFoundError, ImportError):
except (ModuleNotFoundError, ImportError) as e:
logger.debug(
f'Failed to import relative module (base={base_module.__name__}, level={level}, module={module_name}): {type(e).__name__}: {e}'
)
return None


Expand All @@ -148,5 +158,8 @@ def _import_relative_parent_package(base_module: ModuleType, level: int) -> Opti
else:
parent_name = base_module.__name__.rsplit('.', actual_level)[0]
return importlib.import_module(parent_name)
except (ModuleNotFoundError, ImportError, ValueError):
except (ModuleNotFoundError, ImportError, ValueError) as e:
logger.debug(
f'Failed to import parent package (base={base_module.__name__}, level={level}): {type(e).__name__}: {e}'
)
return None
68 changes: 0 additions & 68 deletions tests/unit/test_deep_reloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from ...deep_reloader import (
_build_tree,
_clear_single_pycache,
_copy_symbols_to,
reload_tree,
)
from ...domain import Dependency, DependencyNode
Expand Down Expand Up @@ -306,73 +305,6 @@ def test_reload_updates_module_dict():
# (Mockのため実際の動作は検証できないが、呼び出しは確認できる)


def test_copy_symbols_to_copies_existing_attributes():
"""存在する属性が正しくコピーされることを確認"""
source = _create_mock_module('source', func='function', VALUE=42)
target = _create_mock_module('target')

symbols = ['func', 'VALUE']

_copy_symbols_to(symbols, source, target)

# 属性がコピーされたことを確認
assert target.func == 'function'
assert target.VALUE == 42


def test_copy_symbols_to_skips_missing_attributes():
"""存在しない属性がスキップされることを確認"""
source = _create_mock_module('source', existing='value')
target = _create_mock_module('target')

symbols = ['existing', 'missing']

_copy_symbols_to(symbols, source, target)

# 存在する属性のみコピーされる
assert target.existing == 'value'
# 存在しない属性は無視される(エラーにならない)
assert not hasattr(target, 'missing')


def test_reload_copies_child_symbols():
"""子ノードのsymbolsが親にコピーされることを確認"""
parent_module = _create_mock_module('test.parent')
child_module = _create_mock_module('test.child', func='child_func', VALUE=42)

parent_node = DependencyNode(parent_module)
child_node = DependencyNode(child_module)
child_node.symbols = ['func', 'VALUE']
parent_node.children.append(child_node)

reloaded_parent = _create_mock_module('test.parent')
reloaded_child = _create_mock_module('test.child', func='new_child_func', VALUE=99)

with patch('deep_reloader.deep_reloader.importlib.reload') as mock_reload, patch(
'deep_reloader.deep_reloader.sys.modules', {'test.child': child_module}
):
# 子がリロードされた後、親がリロードされる
def reload_side_effect(module):
if module is child_module:
# 子がリロードされたときにchild_moduleの属性を更新
child_module.func = 'new_child_func'
child_module.VALUE = 99
return reloaded_child
elif module is parent_module:
return reloaded_parent

mock_reload.side_effect = reload_side_effect

reload_tree(parent_node)

# _copy_symbols_toによってreloaded_parentに子のシンボルがコピーされ、
# その後parent_moduleの__dict__がreloaded_parentで更新されるため、
# 最終的にparent_moduleにも反映される
assert hasattr(parent_module, 'func')
assert parent_module.func == 'new_child_func'
assert parent_module.VALUE == 99


def test_reload_preserves_module_identity():
"""リロード後もモジュールのオブジェクトIDが保持されることを確認"""
mock_module = _create_mock_module('test.module')
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_from_clause.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def test_resolve_relative_dot_only():
def test_resolve_returns_none_on_import_error():
"""インポート失敗時にNoneを返すことを確認"""
mock_base = Mock(spec=ModuleType)
mock_base.__name__ = 'test_module'

with patch('importlib.import_module') as mock_import:
mock_import.side_effect = ModuleNotFoundError('nonexistent')
Expand Down