Skip to content

Commit

Permalink
feat: Guards now supports the evaluation of **truthy**** and **falsy*…
Browse files Browse the repository at this point in the history
…* values and can be assigned as decorators
  • Loading branch information
fgmacedo committed Jan 28, 2023
1 parent b4587dc commit 2efea0f
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 6 deletions.
8 changes: 8 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ The action will be registered for every {ref}`transition` associated with the ev
... @loop.after
... def loop_completed(self):
... pass
...
... @loop.cond
... def should_we_allow_loop(self):
... return True
...
... @loop.unless
... def should_we_block_loop(self):
... return False

```

Expand Down
22 changes: 20 additions & 2 deletions docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,31 @@ cond
all conditions evaluate to ``True``.

unless
: Same as `cond`, but the transition is allowed if all conditions evaluate to ``False``.
: Same as `cond`, but the transition is allowed if all conditions evaluate to `False`.

```{hint}
In Python, a boolean value is either `True` or `False`. However, there are also specific values that
are considered "**falsy**" and will evaluate as `False` when used in a boolean context.
These include:
1. The special value `None`.
1. Numeric values of `0` or `0.0`.
1. **Empty** strings, lists, tuples, sets, and dictionaries.
1. Instances of certain classes that define a `__bool__()` or `__len__()` method that returns
`False` or `0`, respectively.
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.
So, a condition `s1.to(s2, cond=lambda: [])` will evaluate as `False`, as an empty list is a
**falsy** value.
```

## Validators


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


Expand Down
Binary file modified docs/images/order_control_machine_initial.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/order_control_machine_processing.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/test_state_machine_internal.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/releases/2.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ See {ref}`internal transition` for more details.
## Minor features in 2.0

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


## Backward incompatible changes in 2.0
Expand Down
6 changes: 3 additions & 3 deletions statemachine/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def __str__(self):
return name if self.expected_value else f"!{name}"

def __call__(self, *args, **kwargs):
return super().__call__(*args, **kwargs) == self.expected_value
return bool(super().__call__(*args, **kwargs)) == self.expected_value


class Callbacks:
Expand All @@ -90,7 +90,7 @@ def setup(self, resolver):
callback for callback in self.items if callback.setup(self._resolver)
]

def _add_unbounded_callback(self, func, is_event=False, transitions=None):
def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
"""This list was a target for adding a func using decorator
`@<state|event>[.on|before|after|enter|exit]` syntax.
Expand All @@ -113,7 +113,7 @@ def _add_unbounded_callback(self, func, is_event=False, transitions=None):
event.
"""
callback = self._add(func)
callback = self._add(func, **kwargs)
if not getattr(func, "_callbacks_to_update", None):
func._callbacks_to_update = set()
func._callbacks_to_update.add(callback._update_func)
Expand Down
12 changes: 11 additions & 1 deletion statemachine/transition_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ def __getitem__(self, index):
def __len__(self):
return len(self.transitions)

def _add_callback(self, callback, name, is_event=False):
def _add_callback(self, callback, name, is_event=False, **kwargs):
for transition in self.transitions:
list_obj = getattr(transition, name)
list_obj._add_unbounded_callback(
callback,
is_event=is_event,
transitions=self,
**kwargs,
)
return callback

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

def cond(self, f):
return self._add_callback(f, "cond")

def unless(self, f):
return self._add_callback(f, "cond", expected_value=False)

def validators(self, f):
return self._add_callback(f, "validators")

def add_event(self, event):
for transition in self.transitions:
transition.add_event(event)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_transition_list.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import pytest

from statemachine import State
from statemachine.dispatcher import resolver_factory


def test_transition_list_or_operator():
Expand All @@ -21,3 +24,37 @@ def test_transition_list_or_operator():
("s2", "s3"),
("s3", "s4"),
]


class TestDecorators:
@pytest.mark.parametrize(
("callback_name", "list_attr_name", "expected_value"),
[
("before", None, 42),
("after", None, 42),
("on", None, 42),
("validators", None, 42),
("cond", None, True),
("unless", "cond", False),
],
)
def test_should_assign_callback_to_transitions(
self, callback_name, list_attr_name, expected_value
):
if list_attr_name is None:
list_attr_name = callback_name

s1 = State("s1", initial=True)
transition_list = s1.to.itself()
decorator = getattr(transition_list, callback_name)

@decorator
def my_callback():
return 42

transition = s1.transitions[0]
callback_list = getattr(transition, list_attr_name)

callback_list.setup(resolver_factory(object()))

assert callback_list.call() == [expected_value]

0 comments on commit 2efea0f

Please sign in to comment.