Skip to content

Commit 2da215a

Browse files
committed
feat(scan_modifier): enhance scan hook functionality with name filtering and original hook access
1 parent cef54b2 commit 2da215a

3 files changed

Lines changed: 387 additions & 31 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
@@ -9,7 +9,7 @@
99
import threading
1010
from abc import ABC, abstractmethod
1111
from collections.abc import Sequence
12-
from typing import TYPE_CHECKING, Annotated, Type
12+
from typing import Annotated, Callable, Type
1313

1414
import numpy as np
1515
import pint
@@ -209,6 +209,7 @@ def __init__(
209209
self._premove_motor_status = None
210210
self.positions = np.array([])
211211
self.start_positions = []
212+
self._scan_original_hooks = self._collect_original_scan_hooks()
212213
self._scan_modifier_hooks = (
213214
get_scan_hooks_impl(scan_modifier) if scan_modifier is not None else {}
214215
)
@@ -321,3 +322,20 @@ def close_scan(self):
321322
@abstractmethod
322323
def on_exception(self, exception: Exception):
323324
"""Handle scan exceptions and perform emergency cleanup."""
325+
326+
def _collect_original_scan_hooks(self) -> dict[str, Callable]:
327+
"""
328+
Bind the undecorated scan hook implementations to this scan instance.
329+
330+
Returns:
331+
dict[str, Callable]: Mapping from hook name to the original bound method.
332+
"""
333+
original_hooks = {}
334+
for attr_name in dir(type(self)):
335+
attr = getattr(type(self), attr_name)
336+
hook_info = getattr(attr, "_scan_hook_info", None)
337+
original_func = getattr(attr, "_scan_hook_original", None)
338+
if hook_info is None or original_func is None:
339+
continue
340+
original_hooks[hook_info["method_name"]] = original_func.__get__(self, type(self))
341+
return original_hooks

bec_server/bec_server/scan_server/scans/scan_modifier.py

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3+
from fnmatch import fnmatchcase
34
from functools import wraps
4-
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, get_args
5+
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, TypedDict, get_args
56

67
from bec_lib.scan_args import ScanArgument
78

@@ -25,6 +26,69 @@
2526
# somehow, pylance doesn't like it when we define scan hooks and create the
2627
# literals out of it, so we do it the other way around
2728
VALID_SCAN_HOOKS = set(get_args(ScanHookName))
29+
HookType: TypeAlias = Literal["before", "after", "replace"]
30+
31+
32+
class FilteredHookConfig(TypedDict):
33+
method_name: str
34+
scan_names: list[str]
35+
36+
37+
HookConfig: TypeAlias = str | FilteredHookConfig
38+
HookLifecycleConfig: TypeAlias = HookConfig | list[HookConfig]
39+
ScanHookConfigMap: TypeAlias = dict[HookType, HookLifecycleConfig]
40+
41+
42+
def _matches_scan_name(scan_name: str | None, patterns: list[str] | None) -> bool:
43+
if not patterns:
44+
return True
45+
if scan_name is None:
46+
return False
47+
return any(fnmatchcase(scan_name, pattern) for pattern in patterns)
48+
49+
50+
def _get_hook_method_name(
51+
hook_name: str, hook_info: ScanHookConfigMap, hook_type: HookType, scan_name: str | None
52+
) -> str | None:
53+
"""
54+
Resolve the scan modifier method name for a hook lifecycle.
55+
56+
Args:
57+
hook_name (str): Name of the scan hook being resolved, such as ``"post_scan"``.
58+
hook_info (ScanHookConfigMap):
59+
Hook implementation metadata produced by :func:`get_scan_hooks_impl`.
60+
hook_type (HookType): Lifecycle stage to resolve within the hook metadata.
61+
scan_name (str | None): Scan name used to evaluate optional ``scan_names`` filters.
62+
63+
Returns:
64+
str | None: The matching modifier method name, or ``None`` if no implementation applies.
65+
66+
Raises:
67+
ValueError: If more than one implementation matches the same hook lifecycle for the given
68+
``scan_name``.
69+
"""
70+
hook_config = hook_info.get(hook_type)
71+
if hook_config is None:
72+
return None
73+
if isinstance(hook_config, list):
74+
matched_method_names = []
75+
for config in hook_config:
76+
if isinstance(config, str):
77+
matched_method_names.append(config)
78+
continue
79+
if _matches_scan_name(scan_name, config.get("scan_names")):
80+
matched_method_names.append(config["method_name"])
81+
if len(matched_method_names) > 1:
82+
raise ValueError(
83+
f"Multiple scan modifier implementations matched hook '{hook_name}' "
84+
f"for lifecycle '{hook_type}' and scan '{scan_name}'"
85+
)
86+
return matched_method_names[0] if matched_method_names else None
87+
if isinstance(hook_config, str):
88+
return hook_config
89+
if not _matches_scan_name(scan_name, hook_config.get("scan_names")):
90+
return None
91+
return hook_config["method_name"]
2892

2993

3094
def scan_hook(func):
@@ -46,64 +110,87 @@ def wrapper(self, *args, **kwargs):
46110
return func(self, *args, **kwargs)
47111

48112
hook_info = self._scan_modifier_hooks[func.__name__]
49-
if "before" in hook_info:
50-
before_method = getattr(self._scan_modifier, hook_info["before"])
113+
scan_name = getattr(self, "scan_name", None)
114+
115+
before_method_name = _get_hook_method_name(func.__name__, hook_info, "before", scan_name)
116+
if before_method_name is not None:
117+
before_method = getattr(self._scan_modifier, before_method_name)
51118
before_method(*args, **kwargs)
52119

53-
if "replace" in hook_info:
54-
replace_method = getattr(self._scan_modifier, hook_info["replace"])
120+
replace_method_name = _get_hook_method_name(func.__name__, hook_info, "replace", scan_name)
121+
if replace_method_name is not None:
122+
replace_method = getattr(self._scan_modifier, replace_method_name)
55123
replace_method(*args, **kwargs)
56124
else:
57125
func(self, *args, **kwargs)
58126

59-
if "after" in hook_info:
60-
after_method = getattr(self._scan_modifier, hook_info["after"])
127+
after_method_name = _get_hook_method_name(func.__name__, hook_info, "after", scan_name)
128+
if after_method_name is not None:
129+
after_method = getattr(self._scan_modifier, after_method_name)
61130
after_method(*args, **kwargs)
62131

63132
return
64133

65134
# pylint: disable=protected-access
66135
wrapper._scan_hook_info = {"method_name": func.__name__} # type: ignore
136+
wrapper._scan_hook_original = func # type: ignore[attr-defined]
67137

68138
return wrapper
69139

70140

71141
def scan_hook_impl(
72-
hook_name: ScanHookName, hook_type: Literal["before", "after", "replace"] = "before"
142+
hook_name: ScanHookName, hook_type: HookType = "before", scan_names: list[str] | None = None
73143
):
74144
"""
75-
Decorator for scan hook implementations. It registers the decorated method as an implementation of the specified scan hook.
76-
The hook_name must refer to an existing scan hook.
77-
The hook_type should be one of the following: "before", "after" or "replace".
78-
This allows the scan modifier to specify whether the decorated method should be executed before, after or instead of the original scan hook method.
145+
Register a scan modifier method as an implementation of a scan hook lifecycle.
146+
147+
Args:
148+
hook_name (ScanHookName): Name of the scan hook to attach to.
149+
hook_type (HookType): Lifecycle stage in which the
150+
modifier method should run. ``"before"`` runs ahead of the original hook,
151+
``"after"`` runs after it, and ``"replace"`` runs instead of it.
152+
scan_names (list[str] | None): Optional list of scan-name patterns that restrict when
153+
this implementation applies. Patterns use shell-style wildcards such as
154+
``"*_line_scan"``. If ``None``, the implementation applies to all scan names.
155+
156+
Returns:
157+
Callable: A decorator that annotates the wrapped method with scan hook metadata.
158+
159+
Raises:
160+
ValueError: If ``hook_name`` is not a supported hook, if ``hook_type`` is invalid, or if
161+
``scan_names`` is not a list.
79162
"""
80163
if hook_name not in VALID_SCAN_HOOKS:
81164
raise ValueError(f"Invalid scan hook: {hook_name}")
82165
if hook_type not in {"before", "after", "replace"}:
83166
raise ValueError(f"Invalid scan hook type: {hook_type}")
167+
if scan_names is not None and not isinstance(scan_names, list):
168+
raise ValueError("scan_names must be a list of scan name patterns")
84169

85170
def decorator(func):
86171
@wraps(func)
87172
def wrapper(self, *args, **kwargs):
88173
return func(self, *args, **kwargs)
89174

90175
# pylint: disable=protected-access
91-
wrapper._scan_hook_impl_info = {"hook_name": hook_name, "hook_type": hook_type} # type: ignore
176+
wrapper._scan_hook_impl_info = {
177+
"hook_name": hook_name,
178+
"hook_type": hook_type,
179+
"scan_names": scan_names,
180+
} # type: ignore
92181

93182
return wrapper
94183

95184
return decorator
96185

97186

98-
def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
187+
def get_scan_hooks_impl(cls) -> dict[str, ScanHookConfigMap]:
99188
"""
100189
Get the scan hooks implemented by the given class. It returns
101190
a dictionary mapping the original scan hook names to the corresponding method names and hook types in the scan modifier.
102191
103-
Raises:
104-
ValueError: If the class implements multiple hooks for the same hook_type (before, after, replace) for the same scan hook.
105192
"""
106-
hooks = {}
193+
hooks: dict[str, ScanHookConfigMap] = {}
107194
for attr_name in dir(cls):
108195
attr = getattr(cls, attr_name)
109196
if callable(attr) and hasattr(attr, "_scan_hook_impl_info"):
@@ -112,11 +199,19 @@ def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
112199
hook_type = info["hook_type"]
113200
if hook_name not in hooks:
114201
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
202+
scan_names = info.get("scan_names")
203+
hook_config: HookConfig
204+
if scan_names is None:
205+
hook_config = attr_name
206+
else:
207+
hook_config = {"method_name": attr_name, "scan_names": scan_names}
208+
existing_hook_config = hooks[hook_name].get(hook_type)
209+
if existing_hook_config is None:
210+
hooks[hook_name][hook_type] = hook_config
211+
elif isinstance(existing_hook_config, list):
212+
existing_hook_config.append(hook_config)
213+
else:
214+
hooks[hook_name][hook_type] = [existing_hook_config, hook_config]
120215
return hooks
121216

122217

@@ -225,3 +320,27 @@ def device_is_available(self, device: list[str] | str, check_enabled: bool = Tru
225320
if check_enabled and not self.dev[dev_name].enabled:
226321
return False
227322
return True
323+
324+
def call_original(self, hook_name: ScanHookName, *args, **kwargs):
325+
"""
326+
Call the scan's original hook implementation directly, bypassing scan modifier dispatch.
327+
328+
Args:
329+
hook_name (ScanHookName): Name of the original scan hook to call.
330+
*args: Positional arguments forwarded to the original hook.
331+
**kwargs: Keyword arguments forwarded to the original hook.
332+
333+
Returns:
334+
Any: The return value of the original hook implementation.
335+
336+
Raises:
337+
AttributeError: If the scan does not expose an original implementation for the hook.
338+
"""
339+
original_hooks = getattr(self.scan, "_scan_original_hooks", {})
340+
try:
341+
original_hook = original_hooks[hook_name]
342+
except KeyError as exc:
343+
raise AttributeError(
344+
f"Scan {type(self.scan).__name__!r} does not expose an original hook for {hook_name!r}"
345+
) from exc
346+
return original_hook(*args, **kwargs)

0 commit comments

Comments
 (0)