Skip to content

Commit f1afe5a

Browse files
authored
feat: Guards now support the evaluation of truthy and falsy values and can be assigned as decorators (#342)
1 parent b4587dc commit f1afe5a

10 files changed

Lines changed: 91 additions & 6 deletions

docs/actions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ The action will be registered for every {ref}`transition` associated with the ev
237237
... @loop.after
238238
... def loop_completed(self):
239239
... pass
240+
...
241+
... @loop.cond
242+
... def should_we_allow_loop(self):
243+
... return True
244+
...
245+
... @loop.unless
246+
... def should_we_block_loop(self):
247+
... return False
240248

241249
```
242250

docs/guards.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,31 @@ cond
3939
all conditions evaluate to ``True``.
4040

4141
unless
42-
: Same as `cond`, but the transition is allowed if all conditions evaluate to ``False``.
42+
: Same as `cond`, but the transition is allowed if all conditions evaluate to `False`.
43+
44+
```{hint}
45+
In Python, a boolean value is either `True` or `False`. However, there are also specific values that
46+
are considered "**falsy**" and will evaluate as `False` when used in a boolean context.
47+
48+
These include:
49+
50+
1. The special value `None`.
51+
1. Numeric values of `0` or `0.0`.
52+
1. **Empty** strings, lists, tuples, sets, and dictionaries.
53+
1. Instances of certain classes that define a `__bool__()` or `__len__()` method that returns
54+
`False` or `0`, respectively.
55+
56+
On the other hand, any value that is not considered "**falsy**" is considered "**truthy**" and will evaluate to `True` when used in a boolean context.
57+
58+
So, a condition `s1.to(s2, cond=lambda: [])` will evaluate as `False`, as an empty list is a
59+
**falsy** value.
60+
```
4361

4462
## Validators
4563

4664

4765
Are like {ref}`guards`, but instead of evaluating to boolean, they are expected to raise an
48-
exception to stop the flow. It may be useful for imperative style programming, when you don't
66+
exception to stop the flow. It may be useful for imperative style programming when you don't
4967
wanna to continue evaluating other possible transitions and exit immediately.
5068

5169

-955 Bytes
Loading
75 Bytes
Loading
-693 Bytes
Loading

docs/releases/2.0.0.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ See {ref}`internal transition` for more details.
4343
## Minor features in 2.0
4444

4545
- Modernization of the development stack to use linters.
46+
- [#342](https://github.com/fgmacedo/python-statemachine/pull/342): Guards now supports the
47+
evaluation of **truthy**** and **falsy** values.
48+
- [#342](https://github.com/fgmacedo/python-statemachine/pull/342): Assignment of `Transition`
49+
guards using decorators is now possible.
4650

4751

4852
## Backward incompatible changes in 2.0

statemachine/callbacks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def __str__(self):
6868
return name if self.expected_value else f"!{name}"
6969

7070
def __call__(self, *args, **kwargs):
71-
return super().__call__(*args, **kwargs) == self.expected_value
71+
return bool(super().__call__(*args, **kwargs)) == self.expected_value
7272

7373

7474
class Callbacks:
@@ -90,7 +90,7 @@ def setup(self, resolver):
9090
callback for callback in self.items if callback.setup(self._resolver)
9191
]
9292

93-
def _add_unbounded_callback(self, func, is_event=False, transitions=None):
93+
def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
9494
"""This list was a target for adding a func using decorator
9595
`@<state|event>[.on|before|after|enter|exit]` syntax.
9696
@@ -113,7 +113,7 @@ def _add_unbounded_callback(self, func, is_event=False, transitions=None):
113113
event.
114114
115115
"""
116-
callback = self._add(func)
116+
callback = self._add(func, **kwargs)
117117
if not getattr(func, "_callbacks_to_update", None):
118118
func._callbacks_to_update = set()
119119
func._callbacks_to_update.add(callback._update_func)

statemachine/contrib/diagram.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class DotGraphMachine:
1515
http://www.graphviz.org/doc/info/attrs.html#d:rankdir
1616
"""
1717

18+
font_name = "Arial"
19+
"""Graph font face name"""
20+
1821
state_font_size = "10pt"
1922
"""State font size"""
2023

@@ -35,6 +38,7 @@ def _get_graph(self):
3538
"list",
3639
graph_type="digraph",
3740
label=machine.name,
41+
fontname=self.font_name,
3842
fontsize=self.state_font_size,
3943
rankdir=self.graph_rankdir,
4044
)
@@ -58,6 +62,7 @@ def _initial_edge(self):
5862
self.machine.initial_state.id,
5963
label="",
6064
color="blue",
65+
fontname=self.font_name,
6166
fontsize=self.transition_font_size,
6267
)
6368

@@ -90,6 +95,7 @@ def _state_as_node(self, state):
9095
label=f"{state.name}{actions}",
9196
shape="rectangle",
9297
style="rounded, filled",
98+
fontname=self.font_name,
9399
fontsize=self.state_font_size,
94100
peripheries=2 if state.final else 1,
95101
)
@@ -109,6 +115,7 @@ def _transition_as_edge(self, transition):
109115
transition.target.id,
110116
label=f"{transition.event}{cond}",
111117
color="blue",
118+
fontname=self.font_name,
112119
fontsize=self.transition_font_size,
113120
)
114121

@@ -142,6 +149,7 @@ def quickchart_write_svg(sm: StateMachine, path: str):
142149
>>> sm = OrderControl()
143150
>>> print(sm._graph().to_string())
144151
digraph list {
152+
fontname=Arial;
145153
fontsize="10pt";
146154
label=OrderControl;
147155
rankdir=LR;

statemachine/transition_list.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ def __getitem__(self, index):
2929
def __len__(self):
3030
return len(self.transitions)
3131

32-
def _add_callback(self, callback, name, is_event=False):
32+
def _add_callback(self, callback, name, is_event=False, **kwargs):
3333
for transition in self.transitions:
3434
list_obj = getattr(transition, name)
3535
list_obj._add_unbounded_callback(
3636
callback,
3737
is_event=is_event,
3838
transitions=self,
39+
**kwargs,
3940
)
4041
return callback
4142

@@ -51,6 +52,15 @@ def after(self, f):
5152
def on(self, f):
5253
return self._add_callback(f, "on")
5354

55+
def cond(self, f):
56+
return self._add_callback(f, "cond")
57+
58+
def unless(self, f):
59+
return self._add_callback(f, "cond", expected_value=False)
60+
61+
def validators(self, f):
62+
return self._add_callback(f, "validators")
63+
5464
def add_event(self, event):
5565
for transition in self.transitions:
5666
transition.add_event(event)

tests/test_transition_list.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import pytest
2+
13
from statemachine import State
4+
from statemachine.dispatcher import resolver_factory
25

36

47
def test_transition_list_or_operator():
@@ -21,3 +24,37 @@ def test_transition_list_or_operator():
2124
("s2", "s3"),
2225
("s3", "s4"),
2326
]
27+
28+
29+
class TestDecorators:
30+
@pytest.mark.parametrize(
31+
("callback_name", "list_attr_name", "expected_value"),
32+
[
33+
("before", None, 42),
34+
("after", None, 42),
35+
("on", None, 42),
36+
("validators", None, 42),
37+
("cond", None, True),
38+
("unless", "cond", False),
39+
],
40+
)
41+
def test_should_assign_callback_to_transitions(
42+
self, callback_name, list_attr_name, expected_value
43+
):
44+
if list_attr_name is None:
45+
list_attr_name = callback_name
46+
47+
s1 = State("s1", initial=True)
48+
transition_list = s1.to.itself()
49+
decorator = getattr(transition_list, callback_name)
50+
51+
@decorator
52+
def my_callback():
53+
return 42
54+
55+
transition = s1.transitions[0]
56+
callback_list = getattr(transition, list_attr_name)
57+
58+
callback_list.setup(resolver_factory(object()))
59+
60+
assert callback_list.call() == [expected_value]

0 commit comments

Comments
 (0)