Skip to content

Commit c4a438a

Browse files
committed
fix: None event label in create_machine_class_from_definition + docs
Fix bug where transition_data.get("event") returning None was interpolated as literal "None" in event IDs (e.g., "next None"). Complete remaining documentation: configuration property in states.md, cross-boundary transitions and transition priority in transitions.md, async-specific limitations in async.md, prepare_event convention and create_machine_class_from_definition docstring in api.md.
1 parent b6b5c06 commit c4a438a

5 files changed

Lines changed: 205 additions & 5 deletions

File tree

docs/api.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,31 @@
101101
:members:
102102
```
103103

104+
## Callback conventions
105+
106+
These are convention-based callbacks that you can define on your state machine
107+
subclass. They are not methods on the base class — define them in your subclass
108+
to enable the behavior.
109+
110+
### `prepare_event`
111+
112+
Called before every event is processed. Returns a `dict` of keyword arguments
113+
that will be merged into `**kwargs` for all subsequent callbacks (guards, actions,
114+
entry/exit handlers) during that event's processing:
115+
116+
```python
117+
class MyMachine(StateMachine):
118+
initial = State(initial=True)
119+
loop = initial.to.itself()
120+
121+
def prepare_event(self):
122+
return {"request_id": generate_id()}
123+
124+
def on_loop(self, request_id):
125+
# request_id is available here
126+
...
127+
```
128+
104129
## create_machine_class_from_definition
105130

106131
```{versionadded} 3.0.0

docs/async.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,14 @@ async def run():
201201
await sm.activate_initial_state()
202202
await sm.send("event")
203203
```
204+
205+
### Async-specific limitations
206+
207+
- **Initial state activation**: In async code, you must `await sm.activate_initial_state()`
208+
before inspecting `sm.configuration` or `sm.current_state`. In sync code this happens
209+
automatically at instantiation time.
210+
- **Delayed events**: Both sync and async engines support `delay=` on `send()`. The async
211+
engine uses `asyncio.sleep()` internally, so it integrates naturally with event loops.
212+
- **Thread safety**: The processing loop uses a non-blocking lock (`_processing.acquire`).
213+
All callbacks run on the same thread they are called from — do not share a state machine
214+
instance across threads without external synchronization.

docs/states.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,43 @@ in nested compounds.
268268
```{seealso}
269269
See {ref}`history-states` for shallow vs deep history and default transitions.
270270
```
271+
272+
## Configuration
273+
274+
```{versionadded} 3.0.0
275+
```
276+
277+
The `configuration` property returns the set of currently active states as an
278+
`OrderedSet[State]`. With compound and parallel states, multiple states can be
279+
active simultaneously:
280+
281+
```py
282+
>>> from statemachine import State, StateChart
283+
284+
>>> class Journey(StateChart):
285+
... class shire(State.Compound):
286+
... bag_end = State(initial=True)
287+
... green_dragon = State()
288+
... visit_pub = bag_end.to(green_dragon)
289+
... road = State(final=True)
290+
... depart = shire.to(road)
291+
292+
>>> sm = Journey()
293+
>>> {s.id for s in sm.configuration} == {"shire", "bag_end"}
294+
True
295+
296+
```
297+
298+
Use `configuration_values` for a set of the active state values (or IDs if no
299+
custom value is defined):
300+
301+
```py
302+
>>> set(sm.configuration_values) == {"shire", "bag_end"}
303+
True
304+
305+
```
306+
307+
```{note}
308+
The older `current_state` property is deprecated. Use `configuration` instead,
309+
which works consistently for both flat and hierarchical state machines.
310+
```

docs/transitions.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,113 @@ the third `bear_ring` event pushes `ring_power` past the threshold.
420420
```{seealso}
421421
See {ref}`eventless-transitions` for chains, compound interactions, and `In()` guards.
422422
```
423+
424+
(cross-boundary-transitions)=
425+
426+
### Cross-boundary transitions
427+
428+
```{versionadded} 3.0.0
429+
```
430+
431+
In statecharts, transitions can cross compound state boundaries — going from a
432+
state inside one compound to a state outside, or into a different compound. The
433+
engine automatically determines which states to exit and enter by computing the
434+
**transition domain**: the smallest compound ancestor that contains both the
435+
source and all target states.
436+
437+
```py
438+
>>> from statemachine import State, StateChart
439+
440+
>>> class MiddleEarthJourney(StateChart):
441+
... validate_disconnected_states = False
442+
... class rivendell(State.Compound):
443+
... council = State(initial=True)
444+
... preparing = State()
445+
... get_ready = council.to(preparing)
446+
... class moria(State.Compound):
447+
... gates = State(initial=True)
448+
... bridge = State(final=True)
449+
... cross = gates.to(bridge)
450+
... march = rivendell.to(moria)
451+
452+
>>> sm = MiddleEarthJourney()
453+
>>> set(sm.configuration_values) == {"rivendell", "council"}
454+
True
455+
456+
>>> sm.send("march")
457+
>>> set(sm.configuration_values) == {"moria", "gates"}
458+
True
459+
460+
```
461+
462+
When `march` fires, the engine:
463+
1. Computes the transition domain (the root, since `rivendell` and `moria` are siblings)
464+
2. Exits `council` and `rivendell` (running their exit actions)
465+
3. Enters `moria` and its initial child `gates` (running their entry actions)
466+
467+
A transition can also go from a deeply nested child to an outer state:
468+
469+
```py
470+
>>> from statemachine import State, StateChart
471+
472+
>>> class MoriaEscape(StateChart):
473+
... class moria(State.Compound):
474+
... class halls(State.Compound):
475+
... entrance = State(initial=True)
476+
... bridge = State(final=True)
477+
... cross = entrance.to(bridge)
478+
... assert isinstance(halls, State)
479+
... depths = State(final=True)
480+
... descend = halls.to(depths)
481+
... daylight = State(final=True)
482+
... escape = moria.to(daylight)
483+
484+
>>> sm = MoriaEscape()
485+
>>> set(sm.configuration_values) == {"moria", "halls", "entrance"}
486+
True
487+
488+
>>> sm.send("escape")
489+
>>> set(sm.configuration_values) == {"daylight"}
490+
True
491+
492+
```
493+
494+
(transition-priority)=
495+
496+
### Transition priority in compound states
497+
498+
```{versionadded} 3.0.0
499+
```
500+
501+
When an event could match transitions at multiple levels of the state hierarchy,
502+
transitions from **descendant states take priority** over transitions from
503+
ancestor states. This follows the SCXML specification: the most specific
504+
(deepest) matching transition wins.
505+
506+
```py
507+
>>> from statemachine import State, StateChart
508+
509+
>>> class PriorityExample(StateChart):
510+
... log = []
511+
... class outer(State.Compound):
512+
... class inner(State.Compound):
513+
... s1 = State(initial=True)
514+
... s2 = State(final=True)
515+
... go = s1.to(s2, on="log_inner")
516+
... assert isinstance(inner, State)
517+
... after_inner = State(final=True)
518+
... done_state_inner = inner.to(after_inner)
519+
... after_outer = State(final=True)
520+
... done_state_outer = outer.to(after_outer)
521+
... def log_inner(self):
522+
... self.log.append("inner won")
523+
524+
>>> sm = PriorityExample()
525+
>>> sm.send("go")
526+
>>> sm.log
527+
['inner won']
528+
529+
```
530+
531+
If two transitions at the same level would exit overlapping states (a conflict),
532+
the one selected first in document order wins.

statemachine/io/__init__.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,22 @@ def _parse_states(
146146
def create_machine_class_from_definition(
147147
name: str, states: Mapping[str, "StateKwargs | StateDefinition"], **definition
148148
) -> StateChart: # noqa: C901
149-
"""
150-
Creates a StateChart class from a dictionary definition, using the StateMachineMetaclass.
149+
"""Create a StateChart class dynamically from a dictionary definition.
150+
151+
Args:
152+
name: The class name for the generated state machine.
153+
states: A mapping of state IDs to state definitions. Each state definition
154+
can include ``initial``, ``final``, ``parallel``, ``name``, ``value``,
155+
``enter``/``exit`` callbacks, ``donedata``, nested ``states``,
156+
``history``, and transitions via ``on`` (event-triggered) or
157+
``transitions`` (eventless).
158+
**definition: Additional keyword arguments passed to the metaclass
159+
(e.g., ``validate_disconnected_states=False``).
151160
152-
Example usage with a traffic light machine:
161+
Returns:
162+
A new StateChart subclass configured with the given states and transitions.
163+
164+
Example:
153165
154166
>>> machine = create_machine_class_from_definition(
155167
... "TrafficLightMachine",
@@ -173,8 +185,10 @@ def create_machine_class_from_definition(
173185

174186
target_state_id = transition_data["target"]
175187
transition_event_name = transition_data.get("event")
176-
if event_name is not None:
177-
transition_event_name = f"{event_name} {transition_event_name}".strip()
188+
if event_name is not None and transition_event_name is not None:
189+
transition_event_name = f"{event_name} {transition_event_name}"
190+
elif event_name is not None:
191+
transition_event_name = event_name
178192

179193
transition_kwargs = {
180194
"event": transition_event_name,

0 commit comments

Comments
 (0)