Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/error_handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ recovering.
### The `error_` naming convention

Since Python identifiers cannot contain dots, any event attribute starting
with `error_` automatically matches both the underscore and dot-notation
forms. For example, `error_execution` matches both `"error_execution"` and
`"error.execution"`:
with `error_` automatically registers both the underscore and dot-notation
forms as events. For example, `error_execution` declares both `"error_execution"` and
`"error.execution"`, and either id triggers the transition:

```py
>>> from statemachine import State, StateChart
Expand Down
46 changes: 41 additions & 5 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,29 +429,65 @@ patterns, `after` as a finalize hook, and nested error scenarios.

SCXML uses dot-separated event names (`done.state.quest`, `error.execution`),
but Python identifiers cannot contain dots. The library provides prefix-based
naming conventions that automatically register both forms:
naming conventions that automatically register both forms, each as an event of
its own, whichever declaration form you use (assignment, `Event(...)` or the
`@source.to(target)` decorator): both are listed by `events`, `allowed_events`
and `enabled_events()`, both can be sent by id, and the attribute name is also
callable as a method:

```py
>>> class ErrorLogger(StateChart):
... running = State(initial=True)
... failed = State(final=True)
...
... process = running.to(running, on="do_process")
... error_execution = running.to(failed, on="log_error")
...
... def do_process(self):
... raise ValueError("bad data")
...
... def log_error(self, error):
... self.last_error = error

>>> [event.id for event in ErrorLogger.events]
['process', 'error_execution', 'error.execution']

>>> sm = ErrorLogger()
>>> [event.id for event in sm.enabled_events()]
['process', 'error_execution', 'error.execution']

>>> sm.send("process")
>>> sm.last_error
ValueError('bad data')

>>> sm = ErrorLogger()
>>> sm.error_execution(error=RuntimeError("sent by hand"))
>>> sm.last_error
RuntimeError('sent by hand')

```

(done-state-convention)=

### `done_state_` prefix

Any event attribute starting with `done_state_` matches both the underscore
Any event attribute starting with `done_state_` registers both the underscore
form and the dot-notation form. Only the prefix is replaced — the suffix is
kept as-is, preserving multi-word state names:

| Attribute name | Matches event names |
| Attribute name | Registers event ids |
|-------------------------------|---------------------|
| `done_state_quest` | `"done_state_quest"` and `"done.state.quest"` |
| `done_state_lonely_mountain` | `"done_state_lonely_mountain"` and `"done.state.lonely_mountain"` |


### `error_` prefix

Any event attribute starting with `error_` matches both the underscore form
Any event attribute starting with `error_` registers both the underscore form
and the dot-notation form. Unlike `done_state_`, **all** underscores after
the prefix are replaced with dots:

| Attribute name | Matches event names |
| Attribute name | Registers event ids |
|----------------------|---------------------|
| `error_execution` | `"error_execution"` and `"error.execution"` |

Expand Down
41 changes: 41 additions & 0 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,47 @@ A pint for Frodo

[#646](https://github.com/fgmacedo/python-statemachine/issues/646).

### Prefixed events were registered under a glued id

The `error_` / `done_state_` / `done_invoke_` prefixes declare two event ids, the attribute
name and its SCXML dotted form. Both reached the transition, so `send("error.execution")`
worked, but the chart registered a single event whose id was the two ids joined by a space.
`sm.error_execution()` raised `TypeError` on a bare transition and silently did nothing on an
`Event(...)`, and `allowed_events` / `enabled_events()` raised `AttributeError` looking up an
attribute named `error.execution`. The expansion also dropped the `name`, `delay` and
`internal` declared on an `Event` inside a nested state body.

Each id is now an event of its own, so both are listed, both are sendable, the attribute name
is callable as a method, and the declared arguments are kept on both. The
`@source.to(target)` decorator form, which registered only the attribute name, expands too:

```py
>>> from statemachine import Event, State, StateChart

>>> class ErrorLogger(StateChart):
... running = State(initial=True)
... failed = State(final=True)
...
... error_execution = Event(running.to(failed), name="Boom")

>>> [(event.id, event.name) for event in ErrorLogger.events]
[('error_execution', 'Boom'), ('error.execution', 'Boom')]

>>> sm = ErrorLogger()
>>> [event.id for event in sm.enabled_events()]
['error_execution', 'error.execution']

>>> sm.error_execution()
>>> sm.failed.is_active
True

```

Reported by [@Dolecor](https://github.com/Dolecor).

[#644](https://github.com/fgmacedo/python-statemachine/issues/644),
[#654](https://github.com/fgmacedo/python-statemachine/issues/654).

### Events due at the same moment kept their order only by chance

Both event queues are priority queues ordered by each trigger's due time, and triggers due at
Expand Down
18 changes: 9 additions & 9 deletions statemachine/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,22 @@
from .transition_list import TransitionList


def _expand_event_id(key: str) -> str:
"""Apply naming conventions for special event prefixes.
def _expand_event_id(key: str) -> list[str]:
"""Return the event ids an attribute name declares.

Converts underscore-based Python attribute names to their dot-separated
event equivalents. Returns a space-separated string so ``Events.add()``
registers both forms.
``done_invoke_X``, ``done_state_X`` and ``error_X`` also declare their SCXML dotted form
(``done.invoke.X``, ``done.state.X``, ``error.X``), each registered as its own event. Any
other name declares only itself.
"""
if key.startswith("done_invoke_"):
suffix = key[len("done_invoke_") :]
return f"{key} done.invoke.{suffix}"
return [key, f"done.invoke.{suffix}"]
if key.startswith("done_state_"):
suffix = key[len("done_state_") :]
return f"{key} done.state.{suffix}"
return [key, f"done.state.{suffix}"]
if key.startswith("error_"):
return f"{key} {key.replace('_', '.')}"
return key
return [key, key.replace("_", ".")]
return [key]


_event_data_kwargs = {
Expand Down
7 changes: 5 additions & 2 deletions statemachine/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def __repr__(self):
def __iter__(self):
return iter(self._items)

def __contains__(self, event):
return event in self._items

def add(self, events):
if events is None:
return self
Expand All @@ -39,9 +42,9 @@ def match(self, event: "str | None"):
return self.is_empty
return any(e.match(event) for e in self)

def _replace(self, old, new):
def _replace(self, old, new_events):
self._items.remove(old)
self._items.append(new)
self.add(new_events)

@property
def is_empty(self):
Expand Down
46 changes: 22 additions & 24 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
cls._abstract = True
cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
cls._protected_attrs: set = set()
cls._events_to_update: dict[Event, Event | None] = {}
cls._events_to_update: dict[Event, list[Event]] = {}
cls._specs = CallbackSpecList()
cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
"prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
Expand Down Expand Up @@ -298,30 +298,28 @@
for event in events:
cls.add_event(event=Event(id=event.id, name=event.name))

def add_from_attributes(cls, attrs): # noqa: C901

Check failure on line 301 in statemachine/factory.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaExHCYRxo7uJw-Nvgi6&open=AaExHCYRxo7uJw-Nvgi6&pullRequest=675
for key, value in attrs.items():
if isinstance(value, States):
cls._add_states_from_dict(value)
if isinstance(value, State):
cls.add_state(key, value)
elif isinstance(value, (Transition, TransitionList)):
event_id = _expand_event_id(key)
cls.add_event(event=Event(transitions=value, id=event_id))
for event_id in _expand_event_id(key):
cls.add_event(event=Event(transitions=value, id=event_id))
elif isinstance(value, (Event,)):
if value._has_real_id:
event_id = value.id
else:
event_id = _expand_event_id(key)
new_event = Event(
transitions=value._transitions,
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
)
cls.add_event(event=new_event, old_event=value)
event_ids = [value.id] if value._has_real_id else _expand_event_id(key)
for event_id in event_ids:
new_event = Event(
transitions=value._transitions,
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
)
cls.add_event(event=new_event, old_event=value)
# Ensure the event is accessible by the Python attribute name
if event_id != key:
if key not in event_ids:
setattr(cls, key, new_event)
elif getattr(value, "attr_name", None):
cls._add_unbounded_callback(key, value)
Expand All @@ -336,7 +334,8 @@
# machinery that is stored at ``func.attr_name``
setattr(cls, func.attr_name, func)
if func.is_event:
cls.add_event(event=Event(func._transitions, id=attr_name))
for event_id in _expand_event_id(attr_name):
cls.add_event(event=Event(func._transitions, id=event_id))

def add_state(cls, id, state: State):
state._set_id(id)
Expand All @@ -359,8 +358,7 @@
old_event: "Event | None" = None,
):
if not event._has_real_id:
if event not in cls._events_to_update:
cls._events_to_update[event] = None
cls._events_to_update.setdefault(event, [])
return

transitions = event._transitions
Expand All @@ -372,20 +370,20 @@
setattr(cls, event.id, event)

if old_event is not None:
cls._events_to_update[old_event] = event
cls._events_to_update.setdefault(old_event, []).append(event)

return cls._events[event]

def _update_event_references(cls):
for old_event, new_event in cls._events_to_update.items():
for old_event, new_events in cls._events_to_update.items():
for state in cls.states:
for transition in state.transitions:
if transition._events.match(old_event):
if new_event is None:
if old_event in transition._events:
if not new_events:
raise InvalidDefinition(
_("An event in the '{}' has no id.").format(transition)
)
transition.events._replace(old_event, new_event)
transition.events._replace(old_event, new_events)

cls._events_to_update = {}

Expand Down
40 changes: 27 additions & 13 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,24 @@ def __new__( # type: ignore [misc] # noqa: C901
value._set_id(key)
states.append(value)
elif isinstance(value, TransitionList):
value.add_event(_expand_event_id(key))
for event_id in _expand_event_id(key):
value.add_event(event_id)
elif isinstance(value, Event):
if value._transitions is not None:
event_id = value.id if value._has_real_id else _expand_event_id(key)
value._transitions.add_event(
Event(
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
event_ids = [value.id] if value._has_real_id else _expand_event_id(key)
for event_id in event_ids:
value._transitions.add_event(
Event(
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
)
)
)
elif getattr(value, "attr_name", None):
if value.is_event:
value._transitions.add_event(key)
for event_id in _expand_event_id(key):
value._transitions.add_event(event_id)
callbacks[value.attr_name] = value
elif callable(value):
callbacks[key] = value
Expand Down Expand Up @@ -439,13 +442,24 @@ class AnyState(State):
until the state machine class is evaluated.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._copies: dict[tuple[int, int], Transition] = {}
"""Transition copied onto each state, keyed by ``(id(transition), id(state))``, so an
attribute that declares several ids adds them all to one copy."""

def _on_event_defined(self, event: str, transition: Transition, states: list[State]):
for state in states:
if state.final:
continue
new_transition = transition._copy_with_args(source=state, event=event)

state.transitions.add_transitions(new_transition)
key = (id(transition), id(state))
copy = self._copies.get(key)
if copy is None:
copy = transition._copy_with_args(source=state, event=event)
self._copies[key] = copy
state.transitions.add_transitions(copy)
else:
copy.add_event(event)


class HistoryType(str, Enum):
Expand Down
24 changes: 24 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,30 @@ def on_cycle(self, event_data, event: str):
assert sm.send("cycle") == "Running cycle from yellow to red"
assert sm.send("cycle") == "Running cycle from red to green"

def test_placeholder_bound_to_several_names_declares_each_as_an_id(self):
class TrafficLightMachine(StateChart):
green = State(initial=True)
yellow = State(final=True)

cycle = Event()
slowdown = cycle
green.to(yellow, event=cycle)

assert [event.id for event in TrafficLightMachine.events] == ["cycle", "slowdown"]
assert [str(t.events) for t in TrafficLightMachine.green.transitions] == ["cycle slowdown"]

def test_wildcard_transition_coexists_with_an_event_attribute(self):
class TrafficLightMachine(StateChart):
green = State(initial=True)
yellow = State()
red = State(final=True)

cycle = Event(green.to(yellow), name="Loop")
yellow.to(red, event="*")

assert [str(t.events) for t in TrafficLightMachine.yellow.transitions] == ["*"]
assert {event.id for event in TrafficLightMachine.events} == {"cycle", "*"}

def test_allow_registering_callbacks_using_decorator(self):
class TrafficLightMachine(StateChart):
"A traffic light machine"
Expand Down
Loading
Loading