Skip to content

Commit 94d84bd

Browse files
committed
feat(scan_modifier): enhance scan hook functionality with name filtering and original hook access
1 parent ea86e78 commit 94d84bd

3 files changed

Lines changed: 394 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
@@ -213,6 +213,7 @@ def __init__(
213213
self._premove_motor_status = None
214214
self.positions = np.array([])
215215
self.start_positions = []
216+
self._scan_original_hooks = self._collect_original_scan_hooks()
216217
self._scan_modifier_hooks = (
217218
get_scan_hooks_impl(scan_modifier) if scan_modifier is not None else {}
218219
)
@@ -337,3 +338,20 @@ def close_scan(self):
337338
@abstractmethod
338339
def on_exception(self, exception: Exception):
339340
"""Handle scan exceptions and perform emergency cleanup."""
341+
342+
def _collect_original_scan_hooks(self) -> dict[str, Callable]:
343+
"""
344+
Bind the undecorated scan hook implementations to this scan instance.
345+
346+
Returns:
347+
dict[str, Callable]: Mapping from hook name to the original bound method.
348+
"""
349+
original_hooks = {}
350+
for attr_name in dir(type(self)):
351+
attr = getattr(type(self), attr_name)
352+
hook_info = getattr(attr, "_scan_hook_info", None)
353+
original_func = getattr(attr, "_scan_hook_original", None)
354+
if hook_info is None or original_func is None:
355+
continue
356+
original_hooks[hook_info["method_name"]] = original_func.__get__(self, type(self))
357+
return original_hooks

bec_server/bec_server/scan_server/scans/scan_modifier.py

Lines changed: 143 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,89 @@ 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 of strings.
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")
169+
if scan_names is not None and any(not isinstance(pattern, str) for pattern in scan_names):
170+
raise ValueError("scan_names must contain only string scan name patterns")
84171

85172
def decorator(func):
86173
@wraps(func)
87174
def wrapper(self, *args, **kwargs):
88175
return func(self, *args, **kwargs)
89176

90177
# pylint: disable=protected-access
91-
wrapper._scan_hook_impl_info = {"hook_name": hook_name, "hook_type": hook_type} # type: ignore
178+
wrapper._scan_hook_impl_info = {
179+
"hook_name": hook_name,
180+
"hook_type": hook_type,
181+
"scan_names": scan_names,
182+
} # type: ignore
92183

93184
return wrapper
94185

95186
return decorator
96187

97188

98-
def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
189+
def get_scan_hooks_impl(cls) -> dict[str, ScanHookConfigMap]:
99190
"""
100191
Get the scan hooks implemented by the given class. It returns
101192
a dictionary mapping the original scan hook names to the corresponding method names and hook types in the scan modifier.
102193
103-
Raises:
104-
ValueError: If the class implements multiple hooks for the same hook_type (before, after, replace) for the same scan hook.
105194
"""
106-
hooks = {}
195+
hooks: dict[str, ScanHookConfigMap] = {}
107196
for attr_name in dir(cls):
108197
attr = getattr(cls, attr_name)
109198
if callable(attr) and hasattr(attr, "_scan_hook_impl_info"):
@@ -112,11 +201,19 @@ def get_scan_hooks_impl(cls) -> dict[str, dict[str, str]]:
112201
hook_type = info["hook_type"]
113202
if hook_name not in hooks:
114203
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
204+
scan_names = info.get("scan_names")
205+
hook_config: HookConfig
206+
if scan_names is None:
207+
hook_config = attr_name
208+
else:
209+
hook_config = {"method_name": attr_name, "scan_names": scan_names}
210+
existing_hook_config = hooks[hook_name].get(hook_type)
211+
if existing_hook_config is None:
212+
hooks[hook_name][hook_type] = hook_config
213+
elif isinstance(existing_hook_config, list):
214+
existing_hook_config.append(hook_config)
215+
else:
216+
hooks[hook_name][hook_type] = [existing_hook_config, hook_config]
120217
return hooks
121218

122219

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

0 commit comments

Comments
 (0)