Files
csaxs_bec/tests/tests_macros/test_macro_device_access.py
menzelandClaude Opus 5 c9cd7ac972
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 2s
CI for csaxs_bec / test (push) Successful in 1m45s
fix(macros): no module-level assignment, or the loader refuses the file
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
2026-09-09 12:29:09 +02:00

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."
)