11import inspect
22import logging
3+ import threading
34from collections .abc import Callable , Iterator
45from typing import Any , Optional
56
67logger = logging .getLogger ('osmo' )
78
89
10+ def _call_with_timeout (func : Callable [[], Any ], timeout : float , description : str ) -> Any :
11+ """Call a function with a timeout using a daemon thread.
12+
13+ Args:
14+ func: Zero-argument callable to execute
15+ timeout: Maximum seconds to wait
16+ description: Human-readable description for error messages
17+
18+ Returns:
19+ The function's return value
20+
21+ Raises:
22+ TimeoutError: If the function exceeds the timeout
23+ Any exception raised by the function
24+ """
25+ result : list [Any ] = []
26+ error : list [BaseException ] = []
27+ completed = threading .Event ()
28+
29+ def wrapper () -> None :
30+ try :
31+ result .append (func ())
32+ except BaseException as e :
33+ error .append (e )
34+ finally :
35+ completed .set ()
36+
37+ thread = threading .Thread (target = wrapper , daemon = True )
38+ thread .start ()
39+ if not completed .wait (timeout = timeout ):
40+ raise TimeoutError (f'{ description } timed out after { timeout } seconds' )
41+ if error :
42+ raise error [0 ]
43+ return result [0 ] if result else None
44+
45+
946class ModelFunction :
1047 """Generic function class containing basic functionality of model functions"""
1148
12- def __init__ (self , function_name : str , object_instance : object ) -> None :
49+ def __init__ (self , function_name : str , object_instance : object , timeout : float | None = 60.0 ) -> None :
1350 self .function_name = function_name
1451 self .object_instance = object_instance # Instance of model class
52+ self .timeout = timeout
1553
1654 @property
1755 def default_weight (self ) -> float :
@@ -24,9 +62,15 @@ def default_weight(self) -> float:
2462 def func (self ) -> Callable [[], Any ]:
2563 return getattr (self .object_instance , self .function_name )
2664
65+ def _with_timeout (self , func : Callable [[], Any ], description : str ) -> Any :
66+ """Call func with timeout if configured, otherwise call directly."""
67+ if self .timeout is not None :
68+ return _call_with_timeout (func , self .timeout , description )
69+ return func ()
70+
2771 def execute (self ) -> Any :
2872 try :
29- return self .func ( )
73+ return self ._with_timeout ( self . func , str ( self ) )
3074 except AttributeError as e :
3175 raise Exception (f'Osmo cannot find function { self .object_instance } .{ self .function_name } from model' ) from e
3276
@@ -44,10 +88,11 @@ def __init__(
4488 object_instance : object ,
4589 step_name : str | None = None ,
4690 is_decorator_based : bool = False ,
91+ timeout : float | None = 60.0 ,
4792 ) -> None :
4893 if not is_decorator_based :
4994 assert function_name .startswith ('step_' ), 'Wrong name function'
50- super ().__init__ (function_name , object_instance )
95+ super ().__init__ (function_name , object_instance , timeout = timeout )
5196 self ._step_name = step_name
5297 self ._is_decorator_based = is_decorator_based
5398 self ._decorator_guard_cache : ModelFunction | None | object = _GUARD_NOT_CACHED
@@ -85,16 +130,25 @@ def is_available(self) -> bool:
85130 """Check if step is available right now"""
86131 # Check model-level guard first (guard_all)
87132 guard_all_func = getattr (self .object_instance , 'guard_all' , None )
88- if guard_all_func is not None and callable (guard_all_func ) and not guard_all_func ():
89- return False
133+ if guard_all_func is not None and callable (guard_all_func ):
134+ guard_all_result = self ._with_timeout (guard_all_func , f'{ type (self .object_instance ).__name__ } .guard_all()' )
135+ if not guard_all_result :
136+ return False
90137
91138 # Check if step is disabled by decorator
92139 if hasattr (self .func , '_osmo_enabled' ) and not self .func ._osmo_enabled :
93140 return False
94141
95142 # Check for inline guard (decorator-based)
96143 if hasattr (self .func , '_osmo_guard_inline' ):
97- result = bool (self .func ._osmo_guard_inline (self .object_instance )) # type: ignore[operator]
144+ inline_guard = self .func ._osmo_guard_inline
145+ instance = self .object_instance
146+ result = bool (
147+ self ._with_timeout (
148+ lambda : inline_guard (instance ), # type: ignore[operator]
149+ f'{ type (self .object_instance ).__name__ } .{ self .function_name } () inline guard' ,
150+ )
151+ )
98152 # Apply invert if specified
99153 if hasattr (self .func , '_osmo_guard_invert' ) and self .func ._osmo_guard_invert :
100154 result = not result
@@ -133,7 +187,7 @@ def _find_decorator_guard(self) -> Optional['ModelFunction']:
133187 and hasattr (method , '_osmo_guard_for' )
134188 and method ._osmo_guard_for == self .name
135189 ):
136- self ._decorator_guard_cache = ModelFunction (attr_name , self .object_instance )
190+ self ._decorator_guard_cache = ModelFunction (attr_name , self .object_instance , timeout = self . timeout )
137191 return self ._decorator_guard_cache
138192
139193 self ._decorator_guard_cache = None
@@ -152,7 +206,7 @@ def return_function_if_exists(self, name: str) -> Optional['ModelFunction']:
152206 if hasattr (self .object_instance , name ):
153207 attr = getattr (self .object_instance , name )
154208 if callable (attr ):
155- return ModelFunction (name , self .object_instance )
209+ return ModelFunction (name , self .object_instance , timeout = self . timeout )
156210 return None
157211
158212
@@ -163,6 +217,7 @@ def __init__(self) -> None:
163217 # Format: functions[function_name] = link_of_instance
164218 self .sub_models : list [object ] = []
165219 self .debug : bool = False
220+ self .timeout : float | None = 60.0
166221 # Performance optimization: cache discovered steps
167222 self ._steps_cache : list [TestStep ] | None = None
168223 self ._cache_valid : bool = False
@@ -185,7 +240,7 @@ def _discover_steps(self, sub_model: object) -> Iterator[TestStep]:
185240 if hasattr (method , '_osmo_step' ):
186241 step_name = method ._osmo_step_name # type: ignore[attr-defined]
187242 discovered_step_names .add (attr_name )
188- yield TestStep (attr_name , sub_model , step_name , is_decorator_based = True )
243+ yield TestStep (attr_name , sub_model , step_name , is_decorator_based = True , timeout = self . timeout )
189244
190245 # Then, discover naming convention steps (skip if already found via decorator)
191246 for attr_name , _method in inspect .getmembers (sub_model , predicate = callable ):
@@ -194,7 +249,7 @@ def _discover_steps(self, sub_model: object) -> Iterator[TestStep]:
194249 continue
195250
196251 if attr_name .startswith ('step_' ):
197- yield TestStep (attr_name , sub_model )
252+ yield TestStep (attr_name , sub_model , timeout = self . timeout )
198253
199254 @property
200255 def all_steps (self ) -> Iterator [TestStep ]:
@@ -231,7 +286,7 @@ def functions_by_name(self, name: str) -> Iterator[ModelFunction]:
231286 for sub_model in self .sub_models :
232287 for attr_name , _method in inspect .getmembers (sub_model , predicate = callable ):
233288 if attr_name == name :
234- yield ModelFunction (attr_name , sub_model )
289+ yield ModelFunction (attr_name , sub_model , timeout = self . timeout )
235290
236291 def add_model (self , model : object ) -> None :
237292 """Add model for osmo.
0 commit comments