Skip to content
Open
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
5 changes: 4 additions & 1 deletion docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ sequence of **groups**. Each group runs to completion before the next one starts
* - Enter
- `on_enter_state()`, `on_enter_<state>()`
- entering state
- Runs once per state being entered, from ancestor to child.
- Runs once per state being entered, from ancestor to child. A compound entered
by default then runs its initial transition: its `on` action, then
`on_init_<state>()`, before any child is entered. Errors there follow the
transition `on` rules. See {ref}`explicit-initial-transition`.
* - Invoke
- `on_invoke_state()`, `on_invoke_<state>()`
- `target`
Expand Down
38 changes: 37 additions & 1 deletion docs/releases/3.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ its output. See [](../contributing.md#uml-cases-based-on-pssm).

[#697](https://github.com/fgmacedo/python-statemachine/pull/697).

### Initial transition actions

A compound state can now run an action on its initial transition, the "init" of UML
statecharts. Declare the transition explicitly with `on=`, or define a method named
`on_init_<state>()`. Either one runs after the state's `enter` callbacks and before any
child is entered, and only when the compound is entered by default. See
[](../states.md#explicit-initial-transition). A class that also has an event
`init_<state>` is rejected, since the method would be bound to both; see
{ref}`callback-name-conflicts`.

[#694](https://github.com/fgmacedo/python-statemachine/pull/694).

## Behavior changes to check when upgrading

Each item below fixes behavior that did not match the documented contract. Code that relied
Expand All @@ -52,10 +64,21 @@ on the old behavior can still behave differently or fail after upgrading:
loop, as before, or, when the invocation itself processes `done.invoke`, through the
channels above, where it used to become `error.execution`. See
{ref}`release-3.3.0-done-invoke`.
- A compound state with an explicit initial transition (`parent.to(child, initial=True)`)
no longer also gets a document-order one. A chart whose first child was reachable only
through that second transition now fails the reachability check with `InvalidDefinition`.
See {ref}`release-3.3.0-explicit-initial`.

## Deprecations in 3.3.0

Deprecated APIs are removed in 4.0. Nothing is deprecated in 3.3.0 yet.
Deprecated APIs are removed in 4.0.

- A method whose name is bound both to an event and to a state callback, such as
`on_enter_x` with an event `enter_x` and a state `x`, runs for both. Defining one on the
class, the model or a listener now emits a `DeprecationWarning`, and will raise
`InvalidDefinition` in 4.0. The same overlap with the new `on_init_<state>` raises
`InvalidDefinition` already. Set `validate_callback_name_conflicts = False` to keep the
old behavior. See {ref}`callback-name-conflicts`.

## Bug fixes in 3.3.0

Expand Down Expand Up @@ -134,3 +157,16 @@ handler. An error raised by a handler's `on_finalize` goes through the same erro
See [](../invoke.md#error-handling).

[#698](https://github.com/fgmacedo/python-statemachine/pull/698).

(release-3.3.0-explicit-initial)=

### Explicit initial transition of a compound state

An explicit initial transition of a compound state, such as
`d.to(d.d2.d21, initial=True, on="action")`, entered the right descendant but dropped its
`on` action, and the compound also got a second, document-order initial transition. The
action now runs after the compound's `enter` callbacks and before any child is entered, and
the explicit transition is the compound's only initial one. See
[](../states.md#explicit-initial-transition).

[#694](https://github.com/fgmacedo/python-statemachine/pull/694).
91 changes: 91 additions & 0 deletions docs/states.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,97 @@ True
Entering a compound activates both the parent and its `initial` child. You can query
whether a state is compound using the `is_compound` property.

(explicit-initial-transition)=

### Explicit initial transition

To enter a compound at a nested descendant, or to run an action each time the compound
is entered by default, declare its initial transition explicitly with
`parent.to(target, initial=True)`. The target can be any descendant, and the `on`
action runs after the parent's `enter` callbacks and before any child is entered:

```py
>>> class Trip(StateChart):
... class travel(State.Compound):
... class planning(State.Compound):
... dates = State(initial=True)
... route = State()
... next_step = dates.to(route)
... booked = State()
... book = planning.to(booked)
... replan = booked.to(planning)
... travel.to(travel.planning.route, initial=True, on="load_saved_route")
... home = State()
... cancel = travel.to(home)
... restart = home.to(travel)
...
... def __init__(self):
... self.log = []
... super().__init__()
...
... def load_saved_route(self):
... self.log.append("load saved route")
...
... def on_enter_state(self, state):
... self.log.append(f"enter {state.id}")

>>> sm = Trip()
>>> sm.log
['enter travel', 'load saved route', 'enter planning', 'enter route']

>>> sm.send("cancel")
>>> sm.log.clear()
>>> sm.send("restart")
>>> sm.log
['enter travel', 'load saved route', 'enter planning', 'enter route']

```

An explicit initial transition replaces the default one: the first child is no longer
picked by document order. A compound state accepts a single initial transition, so
declaring a second one raises `InvalidDefinition`.

A method named `on_init_<state>()` runs as part of the initial transition of that
compound state, whether the transition is explicit or picked by document order. It
runs after the state's `enter` callbacks and after the transition's `on` action, and
only when the compound is entered by default: a transition that targets one of its
descendants directly skips it. A parallel state never runs its own `on_init_<state>()`;
each of its regions runs theirs. An event named `init_<state>` would bind the same method
name, so it raises `InvalidDefinition` (see {ref}`callback-name-conflicts`).

To run an initial action for the whole chart, before any substate is entered, wrap the
states in a top-level compound and give it an explicit initial transition:

```py
>>> class Device(StateChart):
... class top(State.Compound):
... class idle(State.Compound):
... waiting = State()
... class active(State.Compound):
... class job(State.Compound):
... running = State()
... stop = active.to(idle)
... top.to(top.active, initial=True, on="reset_counters")
...
... def __init__(self):
... self.log = []
... super().__init__()
...
... def reset_counters(self):
... self.log.append("top init")
...
... def on_init_active(self):
... self.log.append("active init")
...
... def on_enter_state(self, state):
... self.log.append(f"enter {state.id}")

>>> sm = Device()
>>> sm.log
['enter top', 'top init', 'enter active', 'active init', 'enter job', 'enter running']

```

```{seealso}
See {ref}`done-state-events` for completion events when a compound state's
final child is reached.
Expand Down
36 changes: 36 additions & 0 deletions docs/validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ All non-final states should have at least one path to a final state. These state
Disable with `validate_final_reachability = False`.


(callback-name-conflicts)=

### Callback names bound to an event and to a state

A method name can match two naming conventions at once: `on_init_payment` is the action
of an event `init_payment` and also the {ref}`initial action <explicit-initial-transition>`
of a compound state `payment`. Such a method would run for both. When the class, its
model or a listener defines one, an `on_init_<state>` name raises `InvalidDefinition`:

```py
>>> try:
... class Checkout(StateChart):
... cart = State(initial=True)
... class payment(State.Compound):
... choosing = State()
... paid = State(final=True)
... pay = choosing.to(paid)
... init_payment = cart.to(payment)
...
... def on_init_payment(self):
... pass
... except InvalidDefinition as e:
... print(e)
Checkout defines on_init_payment, bound both to an event and to the initial transition of a compound state. Rename the event or the method.

```

The same overlap with `on_enter_<state>`, `on_exit_<state>` or `on_invoke_<state>` (an
event `enter_x` and a state `x`, for instance) emits a `DeprecationWarning` and will raise
in 4.0. Listeners are checked when they are attached, by the constructor or by
`add_listener()`.

Disable with `validate_callback_name_conflicts = False`; the method then runs for both.


### Internal transition targets

Internal transitions must target the same state (self) or a descendant —
Expand Down Expand Up @@ -317,6 +352,7 @@ Expressions support `and`, `or`, `not`, and parentheses. See
| Unreachable states | Class definition| `validate_disconnected_states` |
| Trap states | Class definition| `validate_trap_states` |
| Final state reachability | Class definition| `validate_final_reachability` |
| Callback names bound twice | Class definition, listeners | `validate_callback_name_conflicts` |
| Internal transition targets | Class definition| No |
| Initial transitions have no cond | Class definition| No |
| `donedata` on final states only | Class definition| No |
Expand Down
58 changes: 58 additions & 0 deletions statemachine/callback_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import warnings
from collections.abc import Iterable
from typing import TYPE_CHECKING

from .callbacks import CallbackPriority
from .exceptions import InvalidDefinition
from .graph import iterate_states_and_transitions
from .i18n import _

if TYPE_CHECKING:
from .state import State

_INIT_PREFIX = "on_init_"


def ambiguous_callback_names(states: "Iterable[State]") -> frozenset[str]:
"""Names that both an event convention (``on_<event>``) and a state convention
(``on_enter_<state>``, ``on_init_<state>``, ...) bind, such as ``on_init_x`` for an
event ``init_x`` and a compound state ``x``."""
event_names: set[str] = set()
state_names: set[str] = set()
for visited in iterate_states_and_transitions(states):
for spec in visited._specs:
if not spec.is_convention or spec.priority != CallbackPriority.NAMING:
continue
if spec.cond is None:
state_names.add(spec.func)
else:
event_names.add(spec.func)
return frozenset(event_names & state_names)


def check_callback_names(owner: str, names: Iterable[str], stacklevel: int):
"""Reject or deprecate the ambiguous callback names that ``owner`` defines.

``stacklevel`` counts from the caller, as in :func:`warnings.warn`."""
names = sorted(names)
if not names:
return

rejected = [name for name in names if name.startswith(_INIT_PREFIX)]
if rejected:
raise InvalidDefinition(
_(
"{owner} defines {names}, bound both to an event and to the initial "
"transition of a compound state. Rename the event or the method."
).format(owner=owner, names=", ".join(rejected))
)

warnings.warn(
_(
"{owner} defines {names}, bound both to an event and to a state callback, so "
"it runs for both. This will raise InvalidDefinition in 4.0. "
"Rename the event or the method."
).format(owner=owner, names=", ".join(names)),
DeprecationWarning,
stacklevel=stacklevel + 1,
)
2 changes: 1 addition & 1 deletion statemachine/engines/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ async def _enter_states(
initial_transition = initial_transitions.get(target.id)
if initial_transition is not None:
result += await self.sm._callbacks.async_call(
initial_transition.on.key, *args, **kwargs
initial_transition.on.key, *args, on_error=on_error_transition, **kwargs
)

# Handle default history states
Expand Down
4 changes: 3 additions & 1 deletion statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,9 @@ def _enter_states(

initial_transition = initial_transitions.get(target.id)
if initial_transition is not None:
result += self.sm._callbacks.call(initial_transition.on.key, *args, **kwargs)
result += self.sm._callbacks.call(
initial_transition.on.key, *args, on_error=on_error_transition, **kwargs
)

# Handle default history states
default_history_transitions = [
Expand Down
37 changes: 32 additions & 5 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from typing import Any

from . import registry
from .callback_names import ambiguous_callback_names
from .callback_names import check_callback_names
from .callbacks import CallbackGroup
from .callbacks import CallbackPriority
from .callbacks import CallbackSpecList
Expand Down Expand Up @@ -47,6 +49,13 @@ class StateMachineMetaclass(type):
"""If ``True`` and final states exist, non-final states without a path to any final
state raise ``InvalidDefinition``."""

validate_callback_name_conflicts: bool = True
"""If ``True``, a method that the machine, its model or a listener defines under a name
bound both to an event (``on_<event>``) and to a state (``on_enter_<state>``,
``on_exit_<state>``, ``on_invoke_<state>``, ``on_init_<state>``) is reported:
``on_init_<state>`` raises ``InvalidDefinition``, the others emit a
``DeprecationWarning``."""

def __init__(
cls,
name: str,
Expand All @@ -64,6 +73,7 @@ def __init__(
"""Map of ``state.value`` to the corresponding :ref:`state`."""

cls._abstract = True
cls._ambiguous_callback_names: frozenset[str] = frozenset()
cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
cls._protected_attrs: set = set()
cls._events_to_update: dict[Event, list[Event]] = {}
Expand Down Expand Up @@ -103,6 +113,7 @@ def __init__(
cls._check()
cls._setup()
cls._unpack_builders_callbacks(attrs)
cls._check_callback_names()
cls._expand_docstring()

_STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")
Expand Down Expand Up @@ -153,6 +164,15 @@ def _initials_by_document_order( # noqa: C901
if s.initial:
initials.append(s)

if parent and not parent.parallel:
explicit_initials = [t for t in parent.transitions if t.initial]
if len(explicit_initials) > 1:
raise InvalidDefinition(
_("State '{0}' has more than one initial transition.").format(parent.id)
)
if explicit_initials:
return

if not initials and states:
initial = states[0]
initial._initial = True
Expand All @@ -161,11 +181,6 @@ def _initials_by_document_order( # noqa: C901
if not parent:
return

# If parent already has a multi-target initial transition (e.g., from SCXML initial
# attribute targeting multiple parallel regions), don't create default initial transitions.
if any(t for t in parent.transitions if t.initial and len(t.targets) > 1):
return

for initial in initials:
if not any(t for t in parent.transitions if t.initial and t.target == initial):
parent.to(initial, initial=True)
Expand Down Expand Up @@ -348,6 +363,18 @@ def _check_disconnected_state(cls):
).format([s.id for s in states])
)

def _check_callback_names(cls):
cls._ambiguous_callback_names = (
ambiguous_callback_names(cls.states)
if cls.validate_callback_name_conflicts
else frozenset()
)
check_callback_names(
cls.__name__,
(name for name in cls._ambiguous_callback_names if hasattr(cls, name)),
stacklevel=3,
)

def _setup(cls):
for visited in iterate_states_and_transitions(cls.states):
visited._setup()
Expand Down
Loading
Loading