The previous commit added `logger = bec_logger.logger` at module level. The
macro loader rejects any module-level ast.Assign
(bec_lib.macro_update_handler.has_executable_code) and then refuses the whole
file, so run_cont_grid_scan_for_table_row stopped being loaded at all:
Macro file .../run_cont_grid_scan_for_table_row.py contains executable code
at module level (line 16) and will not be loaded for security reasons.
Imports, defs, classes, annotated assignments and docstrings are permitted;
plain assignments are not. The logger is now fetched inside the functions.
Adds a test that runs the real has_executable_code over every macro, so this is
caught by the suite instead of by a WARNING in the log stream. Verified to fail
when a module-level assignment is reintroduced.
Noticed only because bec-log-monitor happened to be running at the time -- a
refused macro is otherwise indistinguishable from one that was never installed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148tn6uK6oiTH25mzLfJcyc
63 lines
2.7 KiB
Python
63 lines
2.7 KiB
Python
"""Static checks on the user macros.
|
|
|
|
Macros run in the BEC client namespace and are not covered by unit tests, so mistakes in
|
|
them surface only at the beamline -- and, because several macros wrap their body in a
|
|
try/except that reports to SciLog, often only as a generic message with the traceback
|
|
discarded. These checks catch the failure modes that are decidable statically.
|
|
"""
|
|
|
|
import ast
|
|
import pathlib
|
|
|
|
import pytest
|
|
from bec_lib.macro_update_handler import has_executable_code
|
|
|
|
MACRO_DIR = pathlib.Path(__file__).resolve().parents[2] / "csaxs_bec" / "macros"
|
|
MACROS = sorted(p for p in MACRO_DIR.glob("*.py") if p.name != "__init__.py")
|
|
|
|
|
|
def test_macro_directory_is_found():
|
|
"""Guard against the glob silently matching nothing if the layout changes."""
|
|
assert MACROS, f"no macros found under {MACRO_DIR}"
|
|
|
|
|
|
@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name)
|
|
def test_macros_do_not_go_through_dev_devices(macro: pathlib.Path):
|
|
"""`dev` is already the device container, so `dev.devices` cannot resolve.
|
|
|
|
bec_lib binds `dev = device_manager.devices` in the client namespace, and
|
|
DeviceContainer.__getattr__ raises DeviceConfigError for an unknown attribute. So
|
|
`dev.devices[name]` asks for a device literally called "devices" and fails before
|
|
doing anything useful. Use `dev[name]`.
|
|
"""
|
|
tree = ast.parse(macro.read_text(), filename=str(macro))
|
|
offenders = [
|
|
node.lineno
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Attribute)
|
|
and node.attr == "devices"
|
|
and isinstance(node.value, ast.Name)
|
|
and node.value.id == "dev"
|
|
]
|
|
assert not offenders, (
|
|
f"{macro.name}: line(s) {offenders} use dev.devices -- `dev` is the device "
|
|
"container itself, so this raises DeviceConfigError at runtime. Use dev[<name>]."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name)
|
|
def test_macros_are_loadable_by_the_macro_loader(macro: pathlib.Path):
|
|
"""A module-level statement makes the loader refuse the whole file, silently.
|
|
|
|
bec_lib.macro_update_handler.has_executable_code rejects any module-level node that
|
|
is not an import, a def, a class, an annotated assignment or a docstring -- a plain
|
|
`logger = bec_logger.logger` is enough. The macro is then simply not loaded, and the
|
|
only trace is a WARNING in the log stream, so a broken macro looks like a macro that
|
|
was never there. The real checker is used here rather than a re-implementation.
|
|
"""
|
|
blocked, lineno = has_executable_code(macro.read_text())
|
|
assert not blocked, (
|
|
f"{macro.name}: module-level executable code at line {lineno}; the macro loader "
|
|
"will refuse the file. Move it inside a function, or use an annotated assignment."
|
|
)
|