diff --git a/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py index cdfbd530..ce19b5a3 100644 --- a/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py +++ b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py @@ -69,11 +69,14 @@ def run_cont_grid_scan_for_table_row( try: # Move motors to the starting position of the scan move_command = [] - roty_motor = dev.devices[row["roty_axis"]] + # NOTE `dev` IS the device container (bec_lib client binds + # dev = device_manager.devices), so `dev.devices[...]` asks the container for a + # device named "devices" and raises DeviceConfigError before any motor moves. + roty_motor = dev[row["roty_axis"]] setpoint_roty = roty_motor.setpoint.get(cached=True) if not np.isclose(setpoint_roty, row["roty"]): move_command.extend([row["roty_axis"], row["roty"]]) - setpoint_rotx = dev.devices[row["rotx_axis"]].setpoint.get(cached=True) + setpoint_rotx = dev[row["rotx_axis"]].setpoint.get(cached=True) if not np.isclose(setpoint_rotx, row["rotx"]): move_command.extend([row["rotx_axis"], row["rotx"]]) # Wait for all motor movements to complete diff --git a/tests/tests_macros/test_macro_device_access.py b/tests/tests_macros/test_macro_device_access.py new file mode 100644 index 00000000..f46dcc60 --- /dev/null +++ b/tests/tests_macros/test_macro_device_access.py @@ -0,0 +1,44 @@ +"""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 + +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[]." + )