feat/auto-refresh stale at_each_angle hooks on redefinition
register_at_each_angle_hook() stores a function object by reference, so editing a hook's def and forgetting to re-register left the old version running silently -- easy to miss mid-beamtime. Detect it via func.__globals__ (a redefined top-level def, or a reloaded module, mutates the same namespace dict in place) and auto-adopt the new definition with a printed note instead of failing or staying stale. Centralizes the shared lookup/error logic (previously duplicated in lamni.py and flomni.py) into TomoQueueMixin._resolve_at_each_angle_hook(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -204,3 +204,75 @@ def test_at_each_angle_raises_for_unregistered_hook_name():
|
||||
except LamNIError as exc:
|
||||
assert "missing_hook" in str(exc)
|
||||
assert "not registered" in str(exc)
|
||||
|
||||
|
||||
def test_at_each_angle_hook_refreshed_when_redefined(capsys):
|
||||
"""Redefining a hook's `def` in the same namespace (as happens re-running
|
||||
a cell in IPython) and forgetting to re-register must not silently keep
|
||||
running the old version -- the live definition should be picked up
|
||||
automatically, with a note printed."""
|
||||
lamni = make_lamni()
|
||||
ns = {}
|
||||
exec("def hook_func(setup, angle):\n setup.marker = 'old'\n", ns)
|
||||
lamni.register_at_each_angle_hook("swap", ns["hook_func"])
|
||||
lamni.at_each_angle_hook = "swap"
|
||||
|
||||
exec("def hook_func(setup, angle):\n setup.marker = 'new'\n", ns)
|
||||
lamni._at_each_angle(5.0)
|
||||
|
||||
assert lamni.marker == "new"
|
||||
assert "redefined" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_register_at_each_angle_hook_with_lambda_never_flagged_stale(capsys):
|
||||
"""Lambdas have no real namespace binding under their `__name__`
|
||||
("<lambda>"), so the redefinition check must never fire a false positive
|
||||
for them -- covered separately from the dispatch test above since that
|
||||
one doesn't assert anything about stdout."""
|
||||
lamni = make_lamni()
|
||||
lamni.register_at_each_angle_hook("record", lambda self, angle: None)
|
||||
lamni.at_each_angle_hook = "record"
|
||||
|
||||
lamni._at_each_angle(1.0)
|
||||
|
||||
assert "redefined" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_at_each_angle_hook_refreshed_after_module_reload(tmp_path, capsys):
|
||||
"""Mirrors the recommended file-based workflow: define the hook in a
|
||||
.py file, load it, edit the file, and reload the module -- the module's
|
||||
__dict__ (== the hook function's __globals__) is mutated in place by
|
||||
importlib.reload(), so this must be picked up the same way an
|
||||
interactive redefinition is."""
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
module_name = "lamni_hook_reload_test_module"
|
||||
module_path = tmp_path / f"{module_name}.py"
|
||||
|
||||
def write(marker_value, mtime):
|
||||
module_path.write_text(f"def hook_func(setup, angle):\n setup.marker = {marker_value!r}\n")
|
||||
os.utime(module_path, (mtime, mtime))
|
||||
|
||||
# Distinct, well-separated mtimes -- otherwise both writes can land in
|
||||
# the same filesystem mtime-resolution window and the loader reuses the
|
||||
# cached .pyc from the first write, silently defeating the reload.
|
||||
write("old", 1_700_000_000)
|
||||
sys.path.insert(0, str(tmp_path))
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
lamni = make_lamni()
|
||||
lamni.register_at_each_angle_hook("swap", module.hook_func)
|
||||
lamni.at_each_angle_hook = "swap"
|
||||
|
||||
write("new", 1_700_000_010)
|
||||
importlib.reload(module)
|
||||
|
||||
lamni._at_each_angle(5.0)
|
||||
|
||||
assert lamni.marker == "new"
|
||||
assert "redefined" in capsys.readouterr().out
|
||||
finally:
|
||||
sys.path.remove(str(tmp_path))
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
Reference in New Issue
Block a user