Skip to content

Commit

Permalink
docs: Improving docs for events
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo committed Nov 5, 2024
1 parent f22bae0 commit 9cadf84
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 37 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,25 +377,25 @@ There's a lot more to cover, please take a look at our docs:
https://python-statemachine.readthedocs.io.


## Contributing to the project
## Contributing

* <a class="github-button" href="https://github.com/fgmacedo/python-statemachine" data-icon="octicon-star" aria-label="Star fgmacedo/python-statemachine on GitHub">Star this project</a>
* <a class="github-button" href="https://github.com/fgmacedo/python-statemachine/issues" data-icon="octicon-issue-opened" aria-label="Issue fgmacedo/python-statemachine on GitHub">Open an Issue</a>
* <a class="github-button" href="https://github.com/fgmacedo/python-statemachine/fork" data-icon="octicon-repo-forked" aria-label="Fork fgmacedo/python-statemachine on GitHub">Fork</a>

- If you found this project helpful, please consider giving it a star on GitHub.

- **Contribute code**: If you would like to contribute code to this project, please submit a pull
- **Contribute code**: If you would like to contribute code, please submit a pull
request. For more information on how to contribute, please see our [contributing.md](contributing.md) file.

- **Report bugs**: If you find any bugs in this project, please report them by opening an issue
- **Report bugs**: If you find any bugs, please report them by opening an issue
on our GitHub issue tracker.

- **Suggest features**: If you have a great idea for a new feature, please let us know by opening
an issue on our GitHub issue tracker.
- **Suggest features**: If you have an idea for a new feature, of feels something being harder than it should be,
please let us know by opening an issue on our GitHub issue tracker.

- **Documentation**: Help improve this project's documentation by submitting pull requests.
- **Documentation**: Help improve documentation by submitting pull requests.

- **Promote the project**: Help spread the word about this project by sharing it on social media,
- **Promote the project**: Help spread the word by sharing on social media,
writing a blog post, or giving a talk about it. Tag me on Twitter
[@fgmacedo](https://twitter.com/fgmacedo) so I can share it too!
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@
:members:
```

## Event (class)
## Event

```{eval-rst}
.. autoclass:: statemachine.event.Event
:members:
:members: id, name, __call__
```

## EventData
Expand Down
4 changes: 2 additions & 2 deletions docs/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
Support for async code was added!
```

The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`event` triggers, while maintaining the same external API for both synchronous and asynchronous codebases.
The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases.

This is achieved through a new concept called "engine," an internal strategy pattern abstraction that manages transitions and callbacks.
This is achieved through a new concept called **engine**, an internal strategy pattern abstraction that manages transitions and callbacks.

There are two engines, {ref}`SyncEngine` and {ref}`AsyncEngine`.

Expand Down
68 changes: 57 additions & 11 deletions docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,78 @@ A condition is generally a boolean function, property, or attribute, and must no

There are two variations of Guard clauses available:


cond
: A list of conditions, acting like predicates. A transition is only allowed to occur if
: A list of condition expressions, acting like predicates. A transition is only allowed to occur if
all conditions evaluate to ``True``.
* Single condition: `cond="condition"`
* Multiple conditions: `cond=["condition1", "condition2"]`
* Single condition expression: `cond="condition"` / `cond="<condition expression>"`
* Multiple condition expressions: `cond=["condition1", "condition2"]`

unless
: Same as `cond`, but the transition is only allowed if all conditions evaluate to ``False``.
* Single condition: `unless="condition"`
* Single condition: `unless="condition"` / `unless="<condition expression>"`
* Multiple conditions: `unless=["condition1", "condition2"]`

Conditions also support [Boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra) expressions, allowing you to use compound logic within transition guards. You can use both standard Python logical operators (`not`, `and`, `or`) as well as classic Boolean algebra symbols:
### Condition expressions

This library supports a mini-language for boolean expressions in conditions, allowing the definition of guards that control transitions based on specified criteria. It includes basic [boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra) operators, parentheses for controlling precedence, and **names** that refer to attributes on the state machine, its associated model, or registered {ref}`Listeners`.

```{tip}
All condition expressions are evaluated when the State Machine is instantiated. This is by design to help you catch any invalid definitions early, rather than when your state machine is running.
```

The mini-language is based on Python's built-in language and the [`ast`](https://docs.python.org/3/library/ast.html) parser, so there are no surprises if you’re familiar with Python. Below is a formal specification to clarify the structure.

#### Syntax elements

1. **Names**:
- Names refer to attributes on the state machine instance, its model or listeners, used directly in expressions to evaluate conditions.
- Names must consist of alphanumeric characters and underscores (`_`) and cannot begin with a digit (e.g., `is_active`, `count`, `has_permission`).
- Any property name used in the expression must exist as an attribute on the state machine, model instance, or listeners, otherwise, an `InvalidDefinition` error is raised.
- Names can be pointed to `properties`, `attributes` or `methods`. If pointed to `attributes`, the library will create a
wrapper get method so each time the expression is evaluated the current value will be retrieved.

2. **Boolean operators and precedence**:
- The following Boolean operators are supported, listed from highest to lowest precedence:
1. `not` / `!` — Logical negation
2. `and` / `^` — Logical conjunction
3. `or` / `v` — Logical disjunction
- These operators are case-sensitive (e.g., `NOT` and `Not` are not equivalent to `not` and will raise syntax errors).
- Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent.

- `!` for `not`
- `^` for `and`
- `v` for `or`
3. **Parentheses for precedence**:
- When operators with the same precedence appear in the expression, evaluation proceeds from left to right, unless parentheses specify a different order.
- Parentheses `(` and `)` are supported to control the order of evaluation in expressions.
- Expressions within parentheses are evaluated first, allowing explicit precedence control (e.g., `(is_admin or is_moderator) and has_permission`).

For example:
#### Expression Examples

Examples of valid boolean expressions include:
- `is_logged_in and has_permission`
- `not is_active or is_admin`
- `!(is_guest ^ has_access)`
- `(is_admin or is_moderator) and !is_banned`
- `has_account and (verified or trusted)`
- `frodo_has_ring and gandalf_present or !sauron_alive`

Being used on a transition definition:

```python
start.to(end, cond="frodo_has_ring and gandalf_present or !sauron_alive")
```

Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent.
#### Summary of grammar rules

The mini-language is formally specified as follows:

```
Name: [A-Za-z_][A-Za-z0-9_]*
Boolean Expression:
<boolean_expr> ::= <term> | <boolean_expr> 'or' <term> | <boolean_expr> 'v' <term>
<term> ::= <factor> | <term> 'and' <factor> | <term> '^' <factor>
<factor> ::= 'not' <factor> | '!' <factor> | '(' <boolean_expr> ')' | <name>
```

```{seealso}
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
Expand Down
8 changes: 7 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@

## Latest release

To install Python State Machine using [poetry](https://python-poetry.org/):
To install using [uv](https://docs.astral.sh/uv):

```shell
uv add python-statemachine
```

To install using [poetry](https://python-poetry.org/):

```shell
poetry add python-statemachine
Expand Down
18 changes: 9 additions & 9 deletions docs/transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,7 @@ the event name is used to describe the transition.

```


## Event
## Events

An event is an external signal that something has happened.
They are send to a state machine and allow the state machine to react.
Expand All @@ -175,7 +174,7 @@ In `python-statemachine`, an event is specified as an attribute of the state mac
### Declaring events

The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the
State machine class level. The name will be converted to an {ref}`Event (class)`:
State machine class level. The name will be converted to an {ref}`Event`:

```py
>>> from statemachine import Event
Expand All @@ -197,7 +196,7 @@ True
You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings.
```

To declare an explicit event you must also import the {ref}`Event (class)`:
To declare an explicit event you must also import the {ref}`Event`:

```py
>>> from statemachine import Event
Expand All @@ -219,7 +218,7 @@ To declare an explicit event you must also import the {ref}`Event (class)`:

```

An {ref}`Event (class)` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need.
An {ref}`Event` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need.

```py
>>> from statemachine import State, StateMachine, Event
Expand Down Expand Up @@ -293,22 +292,23 @@ An {ref}`Event (class)` instance or an event id string can also be used as the `
```

```{tip}
Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event (class)` instances under the hood.
Avoid mixing these options within the same project; instead, choose the one that best serves your use case. Declaring events as strings has been the standard approach since the library’s inception and can be considered syntactic sugar, as the state machine metaclass will convert all events to {ref}`Event` instances under the hood.

```

```{note}
In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event (class)` inherits from `str`.
In order to allow the seamless upgrade from using strings to `Event` instances, the {ref}`Event` inherits from `str`.

Note that this is just an implementation detail and can change in the future.

>>> isinstance(TrafficLightMachine.cycle, str)
True
>>> isinstance(TrafficLightMachine.cycle, str)
True

```


```{warning}

An {ref}`Event` declared as string will have its `name` set equal to its `id`. This is for backward compatibility when migrating from previous versions.

In the next major release, `Event.name` will default to a capitalized version of `id` (i.e., `Event.id.replace("_", " ").capitalize()`).
Expand Down
16 changes: 15 additions & 1 deletion statemachine/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@


class Event(str):
"""An event is triggers a signal that something has happened.
They are send to a state machine and allow the state machine to react.
An event starts a :ref:`Transition`, which can be thought of as a “cause” that initiates a
change in the state of the system.
See also :ref:`events`.
"""

id: str
"""The event identifier."""

Expand Down Expand Up @@ -84,7 +94,11 @@ def __get__(self, instance, owner):
return BoundEvent(id=self.id, name=self.name, _sm=instance)

def __call__(self, *args, **kwargs):
"""Send this event to the current state machine."""
"""Send this event to the current state machine.
Triggering an event on a state machine means invoking or sending a signal, initiating the
process that may result in executing a transition.
"""
# The `__call__` is declared here to help IDEs knowing that an `Event`
# can be called as a method. But it is not meant to be called without
# an SM instance. Such SM instance is provided by `__get__` method when
Expand Down
3 changes: 2 additions & 1 deletion statemachine/event_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any

if TYPE_CHECKING:
from .event import Event
from .state import State
from .statemachine import StateMachine
from .transition import Transition
Expand All @@ -13,7 +14,7 @@
class TriggerData:
machine: "StateMachine"

event: str
event: "Event"
"""The Event that was triggered."""

model: Any = field(init=False)
Expand Down
7 changes: 7 additions & 0 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class State:
>>> [(t.source.name, t.target.name) for t in transitions]
[('Draft', 'Draft'), ('Draft', 'Producing'), ('Draft', 'Closed')]
Sometimes it's easier to use the :func:`State.from_` method:
>>> transitions = closed.from_(draft, producing, closed)
>>> [(t.source.name, t.target.name) for t in transitions]
[('Draft', 'Closed'), ('Producing', 'Closed'), ('Closed', 'Closed')]
"""

def __init__(
Expand Down
2 changes: 1 addition & 1 deletion statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def __init__(
if self.current_state_value is None:
trigger_data = TriggerData(
machine=self,
event="__initial__",
event=BoundEvent("__initial__", _sm=self),
)
self._put_nonblocking(trigger_data)

Expand Down
4 changes: 2 additions & 2 deletions statemachine/states.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,14 @@ def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance:
.. deprecated:: 2.3.3
On the next major release, the ``use_enum_instance=True`` will be the default.
On the next major release, ``use_enum_instance=True`` will be the default.
Args:
enum_type: An enumeration containing the states of the machine.
initial: The initial state of the machine.
final: A set of final states of the machine.
use_enum_instance: If ``True``, the value of the state will be the enum item instance,
otherwise the enum item value.
otherwise the enum item value. Defaults to ``False``.
Returns:
A new instance of the :ref:`States (class)`.
Expand Down

0 comments on commit 9cadf84

Please sign in to comment.