Skip to content

Commit ca24a05

Browse files
committed
Add timeout support for model function calls and enhance validation
- Introduce per-function timeout with `_call_with_timeout` utility. - Update `ModelFunction`, `TestStep`, and `Osmo` to allow configurable timeouts. - Add timeout propagation and revalidation in model discovery. - Implement timeout validation in `ConfigValidator`.
1 parent bd6c5bd commit ca24a05

4 files changed

Lines changed: 221 additions & 11 deletions

File tree

pyosmo/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ def validate_error_strategy(strategy: Any, name: str = 'Error strategy') -> None
6565
'AlwaysRaise, AlwaysIgnore, IgnoreAsserts, AllowCount',
6666
)
6767

68+
@staticmethod
69+
def validate_timeout(timeout: Any) -> None:
70+
"""Validate timeout value.
71+
72+
Args:
73+
timeout: Timeout value to validate (positive number or None)
74+
75+
Raises:
76+
ConfigurationError: If timeout is invalid
77+
"""
78+
if timeout is None:
79+
return
80+
if not isinstance(timeout, (int, float)):
81+
raise ConfigurationError(f'Timeout must be a positive number or None, got {type(timeout).__name__}.')
82+
if timeout <= 0:
83+
raise ConfigurationError(f'Timeout must be a positive number, got {timeout}.')
84+
6885
@staticmethod
6986
def validate_seed(seed: Any) -> None:
7087
"""Validate random seed value.
@@ -98,6 +115,7 @@ def __init__(self) -> None:
98115
self._test_suite_end_condition: OsmoEndCondition = Length(1) # pragma: no mutate
99116
self._test_error_strategy: OsmoErrorStrategy = AlwaysRaise()
100117
self._test_suite_error_strategy: OsmoErrorStrategy = AlwaysRaise()
118+
self._timeout: float | None = 60.0
101119

102120
@property
103121
def random(self) -> Random:
@@ -187,3 +205,20 @@ def test_suite_error_strategy(self, value: OsmoErrorStrategy) -> None:
187205
"""
188206
ConfigValidator.validate_error_strategy(value, 'Test suite error strategy')
189207
self._test_suite_error_strategy = value
208+
209+
@property
210+
def timeout(self) -> float | None:
211+
return self._timeout
212+
213+
@timeout.setter
214+
def timeout(self, value: float | None) -> None:
215+
"""Set timeout for model function calls with validation.
216+
217+
Args:
218+
value: Timeout in seconds (positive number) or None to disable
219+
220+
Raises:
221+
ConfigurationError: If timeout is invalid
222+
"""
223+
ConfigValidator.validate_timeout(value)
224+
self._timeout = value

pyosmo/model.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,55 @@
11
import inspect
22
import logging
3+
import threading
34
from collections.abc import Callable, Iterator
45
from typing import Any, Optional
56

67
logger = 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+
946
class 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.

pyosmo/osmo.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ def generate(self) -> None:
135135
# Re-inject osmo_history into all models
136136
for sub_model in self.model.sub_models:
137137
sub_model.osmo_history = self.history # type: ignore[attr-defined]
138+
# Propagate timeout to model collector and cached steps
139+
self.model.timeout = self._timeout
140+
self.model._cache_valid = False # Force step re-discovery with new timeout
138141
logger.debug('Start generation..')
139142
logger.info(f'Using seed: {self.seed}')
140143
# Initialize algorithm
@@ -522,3 +525,16 @@ def ignore_asserts(self) -> 'Osmo':
522525
from pyosmo.error_strategy import IgnoreAsserts
523526

524527
return self.on_error(IgnoreAsserts())
528+
529+
def with_timeout(self, seconds: float | None) -> 'Osmo':
530+
"""
531+
Set timeout for model function calls (fluent API).
532+
533+
Args:
534+
seconds: Timeout in seconds (positive number) or None to disable
535+
536+
Returns:
537+
Self for method chaining
538+
"""
539+
self.timeout = seconds
540+
return self

pyosmo/tests/test_timeout.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import time
2+
3+
import pytest
4+
5+
from pyosmo.config import ConfigurationError
6+
from pyosmo.osmo import Osmo
7+
8+
9+
class HangingStepModel:
10+
def step_hang(self):
11+
time.sleep(10)
12+
13+
def step_fast(self):
14+
pass
15+
16+
17+
class HangingGuardModel:
18+
def step_action(self):
19+
pass
20+
21+
def guard_action(self):
22+
time.sleep(10)
23+
return True
24+
25+
26+
class FastModel:
27+
def step_one(self):
28+
pass
29+
30+
def step_two(self):
31+
pass
32+
33+
34+
class ErrorStepModel:
35+
def step_fail(self):
36+
raise ValueError('step error')
37+
38+
39+
def test_hanging_step_raises_timeout():
40+
osmo = Osmo(HangingStepModel()).with_timeout(0.1).stop_after_steps(1).run_tests(1)
41+
with pytest.raises(TimeoutError, match='timed out after 0.1 seconds'):
42+
osmo.generate()
43+
44+
45+
def test_hanging_guard_raises_timeout():
46+
osmo = Osmo(HangingGuardModel()).with_timeout(0.1).stop_after_steps(1).run_tests(1)
47+
with pytest.raises(TimeoutError, match='timed out after 0.1 seconds'):
48+
osmo.generate()
49+
50+
51+
def test_timeout_none_disables_timeout():
52+
"""With timeout=None, no timeout is applied (function runs normally)."""
53+
osmo = Osmo(FastModel()).with_timeout(None).stop_after_steps(5).run_tests(1)
54+
osmo.generate()
55+
assert len(osmo.history.test_cases) == 1
56+
57+
58+
def test_with_timeout_returns_self():
59+
osmo = Osmo(FastModel())
60+
result = osmo.with_timeout(30)
61+
assert result is osmo
62+
63+
64+
def test_fast_functions_work_with_timeout():
65+
osmo = Osmo(FastModel()).with_timeout(5.0).stop_after_steps(10).run_tests(1)
66+
osmo.generate()
67+
assert len(osmo.history.test_cases) == 1
68+
assert len(osmo.history.test_cases[0].steps_log) == 10
69+
70+
71+
def test_timeout_propagates_to_model():
72+
osmo = Osmo(FastModel()).with_timeout(42.0)
73+
osmo.generate()
74+
assert osmo.model.timeout == 42.0
75+
76+
77+
def test_default_timeout_is_60():
78+
osmo = Osmo(FastModel())
79+
assert osmo.timeout == 60.0
80+
81+
82+
def test_timeout_validation_rejects_negative():
83+
osmo = Osmo(FastModel())
84+
with pytest.raises(ConfigurationError, match='positive number'):
85+
osmo.with_timeout(-1)
86+
87+
88+
def test_timeout_validation_rejects_zero():
89+
osmo = Osmo(FastModel())
90+
with pytest.raises(ConfigurationError, match='positive number'):
91+
osmo.with_timeout(0)
92+
93+
94+
def test_timeout_validation_rejects_string():
95+
osmo = Osmo(FastModel())
96+
with pytest.raises(ConfigurationError, match='positive number or None'):
97+
osmo.with_timeout('fast') # type: ignore[arg-type]
98+
99+
100+
def test_step_error_preserved_through_timeout():
101+
"""Original exceptions should propagate through the timeout wrapper."""
102+
osmo = Osmo(ErrorStepModel()).with_timeout(5.0).stop_after_steps(1).run_tests(1)
103+
with pytest.raises(ValueError, match='step error'):
104+
osmo.generate()

0 commit comments

Comments
 (0)