11from __future__ import annotations
22
3+ from fnmatch import fnmatchcase
34from 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
67from bec_lib .scan_args import ScanArgument
78
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
2728VALID_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
3094def 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
71141def 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