Skip to content

Commit de2b0a7

Browse files
committed
Implement full support for @guard("step_name") decorator syntax
This commit adds complete support for the @guard("step_name") decorator syntax, allowing guards to be defined as separate methods that reference a step by name. Changes: - Enhanced model.py to discover and use @guard("step_name") decorated methods - Fixed decorators.py to properly handle inline lambda guards with @guard(lambda...) - Added support for the 'invert' parameter in both decorator-based guards - Implemented _find_decorator_guard() to search for guards targeting specific steps - Added comprehensive tests for all guard decorator variations The implementation now supports three guard styles: 1. Naming convention: guard_step_name() methods 2. Inline guards: @guard(lambda self: condition) 3. Decorator-based: @guard("step_name") on separate methods All 63 existing tests pass with the new implementation.
1 parent 3a4d6e3 commit de2b0a7

3 files changed

Lines changed: 228 additions & 9 deletions

File tree

pyosmo/decorators.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ def can_process(self) -> bool:
9898
Decorated function
9999
"""
100100
if callable(step_name_or_func):
101-
# Inline guard
102-
func = step_name_or_func
103-
func._osmo_guard_inline = True # type: ignore[attr-defined]
104-
func._osmo_guard_invert = invert # type: ignore[attr-defined]
105-
return func
101+
# Inline guard - return a decorator that attaches the guard to the step
102+
guard_func = step_name_or_func
103+
104+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
105+
func._osmo_guard_inline = guard_func # type: ignore[attr-defined]
106+
func._osmo_guard_invert = invert # type: ignore[attr-defined]
107+
return func
108+
109+
return decorator
106110

107111
# Named guard
108112
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:

pyosmo/model.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,35 @@ def is_available(self) -> bool:
8585

8686
# Check for inline guard (decorator-based)
8787
if hasattr(self.func, '_osmo_guard_inline'):
88-
return bool(self.func._osmo_guard_inline(self.object_instance))
89-
90-
# Check for named guard function
88+
result = bool(self.func._osmo_guard_inline(self.object_instance))
89+
# Apply invert if specified
90+
if hasattr(self.func, '_osmo_guard_invert') and self.func._osmo_guard_invert:
91+
result = not result
92+
return result
93+
94+
# Check for decorator-based guard (@guard("step_name"))
95+
decorator_guard = self._find_decorator_guard()
96+
if decorator_guard is not None:
97+
result = bool(decorator_guard.execute())
98+
# Check if guard should be inverted
99+
guard_func = decorator_guard.func
100+
if hasattr(guard_func, '_osmo_guard_invert') and guard_func._osmo_guard_invert:
101+
result = not result
102+
return result
103+
104+
# Check for named guard function (naming convention)
91105
return True if self.guard_function is None else bool(self.guard_function.execute())
92106

107+
def _find_decorator_guard(self) -> Optional['ModelFunction']:
108+
"""Find a guard method decorated with @guard("step_name") for this step."""
109+
for attr_name in dir(self.object_instance):
110+
method = getattr(self.object_instance, attr_name)
111+
if callable(method) and hasattr(method, '_osmo_guard') and hasattr(method, '_osmo_guard_for'):
112+
# Check if this guard is for our step
113+
if method._osmo_guard_for == self.name:
114+
return ModelFunction(attr_name, self.object_instance)
115+
return None
116+
93117
@property
94118
def guard_function(self) -> Optional['ModelFunction']:
95119
"""Return guard function if it exists, otherwise None."""

pyosmo/tests/test_decorators.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test decorator-based API"""
22

3-
from pyosmo import Osmo, post, pre, requires, step, weight
3+
from pyosmo import Osmo, guard, post, pre, requires, step, weight
44
from pyosmo.end_conditions import Length
55

66

@@ -147,3 +147,194 @@ def weight_action(self):
147147

148148
# Weight function should be called
149149
assert action_step.weight == 20
150+
151+
152+
def test_guard_decorator_with_step_name():
153+
"""Test @guard("step_name") decorator"""
154+
155+
class GuardModel:
156+
def __init__(self):
157+
self.ready = False
158+
self.process_called = False
159+
self.other_called = False
160+
161+
@step
162+
def process(self):
163+
self.process_called = True
164+
165+
@guard("process")
166+
def can_process(self) -> bool:
167+
return self.ready
168+
169+
@step
170+
def other_step(self):
171+
self.other_called = True
172+
173+
model = GuardModel()
174+
osmo = Osmo(model)
175+
176+
# Process should not be available initially
177+
available_steps = osmo.model.available_steps
178+
step_names = [s.name for s in available_steps]
179+
assert 'process' not in step_names
180+
assert 'other_step' in step_names
181+
182+
# Make process available
183+
model.ready = True
184+
available_steps = osmo.model.available_steps
185+
step_names = [s.name for s in available_steps]
186+
assert 'process' in step_names
187+
188+
189+
def test_guard_decorator_inline():
190+
"""Test @guard with inline lambda"""
191+
192+
class InlineGuardModel:
193+
def __init__(self):
194+
self.counter = 0
195+
self.limit_reached = False
196+
197+
@step
198+
@guard(lambda self: not self.limit_reached)
199+
def increment(self):
200+
self.counter += 1
201+
if self.counter >= 5:
202+
self.limit_reached = True
203+
204+
@step
205+
def other_action(self):
206+
pass
207+
208+
model = InlineGuardModel()
209+
osmo = Osmo(model)
210+
osmo.test_end_condition = Length(10)
211+
212+
osmo.run()
213+
214+
# Should reach 5 and then stop incrementing due to guard
215+
assert model.counter == 5
216+
217+
218+
def test_guard_decorator_with_invert():
219+
"""Test @guard with invert parameter"""
220+
221+
class InvertGuardModel:
222+
def __init__(self):
223+
self.blocked = True
224+
self.action_called = False
225+
226+
@step
227+
def action(self):
228+
self.action_called = True
229+
230+
@guard("action", invert=True)
231+
def is_blocked(self) -> bool:
232+
return self.blocked
233+
234+
model = InvertGuardModel()
235+
osmo = Osmo(model)
236+
237+
# Action should not be available when blocked=True (inverted)
238+
available_steps = osmo.model.available_steps
239+
step_names = [s.name for s in available_steps]
240+
assert 'action' not in step_names
241+
242+
# Make action available by setting blocked=False
243+
model.blocked = False
244+
available_steps = osmo.model.available_steps
245+
step_names = [s.name for s in available_steps]
246+
assert 'action' in step_names
247+
248+
249+
def test_guard_decorator_multiple_steps():
250+
"""Test multiple guards for different steps"""
251+
252+
class MultiGuardModel:
253+
def __init__(self):
254+
self.logged_in = False
255+
self.has_items = False
256+
257+
@step
258+
def login(self):
259+
self.logged_in = True
260+
261+
@step
262+
def checkout(self):
263+
pass
264+
265+
@guard("login")
266+
def can_login(self) -> bool:
267+
return not self.logged_in
268+
269+
@guard("checkout")
270+
def can_checkout(self) -> bool:
271+
return self.logged_in and self.has_items
272+
273+
model = MultiGuardModel()
274+
osmo = Osmo(model)
275+
276+
# Initially, only login is available
277+
available_steps = osmo.model.available_steps
278+
step_names = [s.name for s in available_steps]
279+
assert 'login' in step_names
280+
assert 'checkout' not in step_names
281+
282+
# After login, checkout still not available (no items)
283+
model.logged_in = True
284+
available_steps = osmo.model.available_steps
285+
step_names = [s.name for s in available_steps]
286+
assert 'login' not in step_names # Can't login again
287+
assert 'checkout' not in step_names
288+
289+
# After adding items, checkout is available
290+
model.has_items = True
291+
available_steps = osmo.model.available_steps
292+
step_names = [s.name for s in available_steps]
293+
assert 'checkout' in step_names
294+
295+
296+
def test_guard_mixing_decorator_and_naming_convention():
297+
"""Test that decorator guards work alongside naming convention guards"""
298+
299+
class MixedGuardModel:
300+
def __init__(self):
301+
self.flag_a = True
302+
self.flag_b = True
303+
304+
@step
305+
def action_a(self):
306+
pass
307+
308+
@guard("action_a")
309+
def guard_for_a(self) -> bool:
310+
return self.flag_a
311+
312+
def step_action_b(self):
313+
pass
314+
315+
def guard_action_b(self) -> bool:
316+
return self.flag_b
317+
318+
model = MixedGuardModel()
319+
osmo = Osmo(model)
320+
321+
# Both should be available initially
322+
available_steps = osmo.model.available_steps
323+
step_names = [s.name for s in available_steps]
324+
assert 'action_a' in step_names
325+
assert 'action_b' in step_names
326+
327+
# Disable decorator-guarded step
328+
model.flag_a = False
329+
available_steps = osmo.model.available_steps
330+
step_names = [s.name for s in available_steps]
331+
assert 'action_a' not in step_names
332+
assert 'action_b' in step_names
333+
334+
# Disable naming-convention-guarded step
335+
model.flag_a = True
336+
model.flag_b = False
337+
available_steps = osmo.model.available_steps
338+
step_names = [s.name for s in available_steps]
339+
assert 'action_a' in step_names
340+
assert 'action_b' not in step_names

0 commit comments

Comments
 (0)