Skip to content

Commit 5e09d48

Browse files
committed
feat(scan_modifier): enhance scan hook functionality with name filtering and original hook access
1 parent 67b553a commit 5e09d48

3 files changed

Lines changed: 329 additions & 25 deletions

File tree

bec_server/bec_server/scan_server/scans/scan_base.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import enum
99
import threading
1010
from collections.abc import Sequence
11-
from typing import TYPE_CHECKING, Annotated, Type
11+
from typing import Annotated, Callable, Type
1212

1313
import numpy as np
1414
import pint
@@ -201,6 +201,7 @@ def __init__(
201201
self._premove_motor_status = None
202202
self.positions = np.array([])
203203
self.start_positions = []
204+
self._scan_original_hooks = self._collect_original_scan_hooks()
204205
self._scan_modifier_hooks = (
205206
get_scan_hooks_impl(scan_modifier) if scan_modifier is not None else {}
206207
)
@@ -263,3 +264,20 @@ def update_scan_info(
263264
setattr(self.scan_info, key, value)
264265
else:
265266
self.scan_info.additional_scan_parameters[key] = value
267+
268+
def _collect_original_scan_hooks(self) -> dict[str, Callable]:
269+
"""
270+
Bind the undecorated scan hook implementations to this scan instance.
271+
272+
Returns:
273+
dict[str, Callable]: Mapping from hook name to the original bound method.
274+
"""
275+
original_hooks = {}
276+
for attr_name in dir(type(self)):
277+
attr = getattr(type(self), attr_name)
278+
hook_info = getattr(attr, "_scan_hook_info", None)
279+
original_func = getattr(attr, "_scan_hook_original", None)
280+
if hook_info is None or original_func is None:
281+
continue
282+
original_hooks[hook_info["method_name"]] = original_func.__get__(self, type(self))
283+
return original_hooks

bec_server/bec_server/scan_server/scans/scan_modifier.py

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from fnmatch import fnmatchcase
34
from functools import wraps
45
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, get_args
56

@@ -27,6 +28,44 @@
2728
VALID_SCAN_HOOKS = set(get_args(ScanHookName))
2829

2930

31+
def _matches_scan_name(scan_name: str | None, patterns: list[str] | None) -> bool:
32+
if not patterns:
33+
return True
34+
if scan_name is None:
35+
return False
36+
return any(fnmatchcase(scan_name, pattern) for pattern in patterns)
37+
38+
39+
def _get_hook_method_name(
40+
hook_name: str,
41+
hook_info: dict[str, str | dict[str, str | list[str]] | list[str | dict[str, str | list[str]]]],
42+
hook_type: str,
43+
scan_name: str | None,
44+
) -> str | None:
45+
hook_config = hook_info.get(hook_type)
46+
if hook_config is None:
47+
return None
48+
if isinstance(hook_config, list):
49+
matched_method_names = []
50+
for config in hook_config:
51+
if isinstance(config, str):
52+
matched_method_names.append(config)
53+
continue
54+
if _matches_scan_name(scan_name, config.get("scan_names")):
55+
matched_method_names.append(config["method_name"])
56+
if len(matched_method_names) > 1:
57+
raise ValueError(
58+
f"Multiple scan modifier implementations matched hook '{hook_name}' "
59+
f"for lifecycle '{hook_type}' and scan '{scan_name}'"
60+
)
61+
return matched_method_names[0] if matched_method_names else None
62+
if isinstance(hook_config, str):
63+
return hook_config
64+
if not _matches_scan_name(scan_name, hook_config.get("scan_names")):
65+
return None
66+
return hook_config["method_name"]
67+
68+
3069
def scan_hook(func):
3170
"""
3271
Decorator for scan hooks. It registers the decorated method as a scan hook and thus allows
@@ -46,62 +85,82 @@ def wrapper(self, *args, **kwargs):
4685
return func(self, *args, **kwargs)
4786

4887
hook_info = self._scan_modifier_hooks[func.__name__]
49-
if "before" in hook_info:
50-
before_method = getattr(self._scan_modifier, hook_info["before"])
88+
scan_name = getattr(
89+
getattr(self, "scan_info", None), "scan_name", getattr(self, "scan_name", None)
90+
)
91+
92+
before_method_name = _get_hook_method_name(func.__name__, hook_info, "before", scan_name)
93+
if before_method_name is not None:
94+
before_method = getattr(self._scan_modifier, before_method_name)
5195
before_method(*args, **kwargs)
5296

53-
if "replace" in hook_info:
54-
replace_method = getattr(self._scan_modifier, hook_info["replace"])
97+
replace_method_name = _get_hook_method_name(func.__name__, hook_info, "replace", scan_name)
98+
if replace_method_name is not None:
99+
replace_method = getattr(self._scan_modifier, replace_method_name)
55100
replace_method(*args, **kwargs)
56101
else:
57102
func(self, *args, **kwargs)
58103

59-
if "after" in hook_info:
60-
after_method = getattr(self._scan_modifier, hook_info["after"])
104+
after_method_name = _get_hook_method_name(func.__name__, hook_info, "after", scan_name)
105+
if after_method_name is not None:
106+
after_method = getattr(self._scan_modifier, after_method_name)
61107
after_method(*args, **kwargs)
62108

63109
return
64110

65111
# pylint: disable=protected-access
66112
wrapper._scan_hook_info = {"method_name": func.__name__} # type: ignore
113+
wrapper._scan_hook_original = func # type: ignore[attr-defined]
67114

68115
return wrapper
69116

70117

71118
def scan_hook_impl(
72-
hook_name: ScanHookName, hook_type: Literal["before", "after", "replace"] = "before"
119+
hook_name: ScanHookName,
120+
hook_type: Literal["before", "after", "replace"] = "before",
121+
scan_names: list[str] | None = None,
73122
):
74123
"""
75124
Decorator for scan hook implementations. It registers the decorated method as an implementation of the specified scan hook.
76125
The hook_name must refer to an existing scan hook.
77126
The hook_type should be one of the following: "before", "after" or "replace".
127+
The optional scan_names list can be used to restrict the implementation to matching scan names.
128+
Wildcards are supported using shell-style patterns such as ``*_line_scan``.
78129
This allows the scan modifier to specify whether the decorated method should be executed before, after or instead of the original scan hook method.
79130
"""
80131
if hook_name not in VALID_SCAN_HOOKS:
81132
raise ValueError(f"Invalid scan hook: {hook_name}")
82133
if hook_type not in {"before", "after", "replace"}:
83134
raise ValueError(f"Invalid scan hook type: {hook_type}")
135+
if scan_names is not None and not isinstance(scan_names, list):
136+
raise ValueError("scan_names must be a list of scan name patterns")
84137

85138
def decorator(func):
86139
@wraps(func)
87140
def wrapper(self, *args, **kwargs):
88141
return func(self, *args, **kwargs)
89142

90143
# pylint: disable=protected-access
91-
wrapper._scan_hook_impl_info = {"hook_name": hook_name, "hook_type": hook_type} # type: ignore
144+
wrapper._scan_hook_impl_info = {
145+
"hook_name": hook_name,
146+
"hook_type": hook_type,
147+
"scan_names": scan_names,
148+
} # type: ignore
92149

93150
return wrapper
94151

95152
return decorator
96153

97154

98-
def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
155+
def get_scan_hooks_impl(
156+
cls,
157+
) -> dict[
158+
str, dict[str, str | dict[str, str | list[str]] | list[str | dict[str, str | list[str]]]]
159+
]:
99160
"""
100161
Get the scan hooks implemented by the given class. It returns
101162
a dictionary mapping the original scan hook names to the corresponding method names and hook types in the scan modifier.
102163
103-
Raises:
104-
ValueError: If the class implements multiple hooks for the same hook_type (before, after, replace) for the same scan hook.
105164
"""
106165
hooks = {}
107166
for attr_name in dir(cls):
@@ -112,11 +171,19 @@ def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
112171
hook_type = info["hook_type"]
113172
if hook_name not in hooks:
114173
hooks[hook_name] = {}
115-
if hook_type in hooks[hook_name]:
116-
raise ValueError(
117-
f"Multiple implementations for the same hook type '{hook_type}' for the scan hook '{hook_name}' in class '{cls.__name__}'"
118-
)
119-
hooks[hook_name][hook_type] = attr_name
174+
scan_names = info.get("scan_names")
175+
hook_config: str | dict[str, str | list[str]]
176+
if scan_names is None:
177+
hook_config = attr_name
178+
else:
179+
hook_config = {"method_name": attr_name, "scan_names": scan_names}
180+
existing_hook_config = hooks[hook_name].get(hook_type)
181+
if existing_hook_config is None:
182+
hooks[hook_name][hook_type] = hook_config
183+
elif isinstance(existing_hook_config, list):
184+
existing_hook_config.append(hook_config)
185+
else:
186+
hooks[hook_name][hook_type] = [existing_hook_config, hook_config]
120187
return hooks
121188

122189

@@ -225,3 +292,27 @@ def device_is_available(self, device: list[str] | str, check_enabled: bool = Tru
225292
if check_enabled and not self.dev[dev_name].enabled:
226293
return False
227294
return True
295+
296+
def call_original(self, hook_name: ScanHookName, *args, **kwargs):
297+
"""
298+
Call the scan's original hook implementation directly, bypassing scan modifier dispatch.
299+
300+
Args:
301+
hook_name (ScanHookName): Name of the original scan hook to call.
302+
*args: Positional arguments forwarded to the original hook.
303+
**kwargs: Keyword arguments forwarded to the original hook.
304+
305+
Returns:
306+
Any: The return value of the original hook implementation.
307+
308+
Raises:
309+
AttributeError: If the scan does not expose an original implementation for the hook.
310+
"""
311+
original_hooks = getattr(self.scan, "_scan_original_hooks", {})
312+
try:
313+
original_hook = original_hooks[hook_name]
314+
except KeyError as exc:
315+
raise AttributeError(
316+
f"Scan {type(self.scan).__name__!r} does not expose an original hook for {hook_name!r}"
317+
) from exc
318+
return original_hook(*args, **kwargs)

0 commit comments

Comments
 (0)