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:
x01dc
2026-07-21 15:08:11 +02:00
co-authored by Claude Sonnet 5
parent f25eea9488
commit d963cd6dfd
6 changed files with 150 additions and 22 deletions
@@ -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)