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
17 changes: 9 additions & 8 deletions docs/behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ GHSA-v4jc-pm6r-3vj8.
| Attribute | `StateChart` | `StateMachine` | Description |
|---|---|---|---|
| `allow_event_without_transition` | `True` | `False` | Tolerate events that don't match any transition |
| `enable_self_transition_entries` | `True` | `False` | Execute entry/exit actions on self-transitions |
| `atomic_configuration_update` | `False` | `True` | When to update {ref}`configuration <querying-configuration>` during a microstep |
| `catch_errors_as_events` | `True` | `False` | Catch action errors as `error.execution` events |

Expand All @@ -65,14 +64,16 @@ See {ref}`conditions` for how the engine selects transitions, and

## `enable_self_transition_entries`

When `True` (SCXML default), a {ref}`self-transition <self-transition>`
executes the state's exit and entry actions, just like any other transition.
When `False` (legacy default), self-transitions skip entry/exit actions.
```{deprecated} 3.3.0
This attribute has no effect, and setting it on a subclass emits a
`DeprecationWarning`.
```

The SCXML spec treats self-transitions as regular transitions that happen to
return to the same state, so entry/exit actions must fire. Use an
{ref}`internal transition <internal-transition>` if you need a transition that
stays in the same state **without** running exit/entry actions.
A {ref}`self-transition <self-transition>` always executes the state's exit
and entry actions, as the SCXML spec requires. Use an
{ref}`internal transition <internal-transition>` on an atomic state if you need
a transition that stays in the same state **without** running exit/entry
actions.

```{seealso}
See {ref}`transitions` for the full reference on self-transitions and
Expand Down
2 changes: 1 addition & 1 deletion docs/diagram.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ A transition from a state back to itself.

### Internal transitions

Internal transitions execute actions without exiting/entering the state.
An internal transition declared with `internal=True`. See {ref}`internal transition` for the states it exits and enters.

```{literalinclude} ../tests/machines/showcase_internal.py
:pyobject: InternalSC
Expand Down
48 changes: 47 additions & 1 deletion docs/releases/3.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,26 @@ 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`.
- On a `StateChart`, an `internal=True` self-transition on an atomic state no longer runs
the state's entry actions. It used to enter the state again without exiting it. See
{ref}`release-3.3.0-internal`.
- On a `StateMachine`, an `internal=True` transition from a compound state to one of its
descendants now enters the target, and an `internal=True` self-transition on a compound
state exits and re-enters it. The first used to leave the compound state without an active
child, and the second raised `TransitionNotAllowed`. See {ref}`release-3.3.0-internal`.
- In an SCXML document, `type="internal"` on a transition from an atomic state is now
external, as the SCXML specification defines: a self-transition there runs `onexit` and
`onentry`. It used to run only `onentry`. See {ref}`release-3.3.0-internal`.

## 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.

- `enable_self_transition_entries` has no effect, and setting it on a subclass emits a
`DeprecationWarning`. An external self-transition always runs exit and entry actions, on
`StateChart` and `StateMachine` alike; use `internal=True` on an atomic state to skip them.
Its removal is tracked in [#724](https://github.com/fgmacedo/python-statemachine/issues/724).
See {ref}`release-3.3.0-internal`.

## Bug fixes in 3.3.0

Expand Down Expand Up @@ -134,3 +150,33 @@ 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-internal)=

### Internal transitions enter and exit the right states

An `internal=True` transition depended on `enable_self_transition_entries`, and both values
produced a wrong trace or configuration:

- On a `StateChart`, a self-transition on an atomic state ran its entry actions without its
exit actions.
- On a `StateMachine`, a transition from a compound state to a descendant exited the active
child and never entered the target, and a self-transition on a compound state raised
`TransitionNotAllowed`.

An internal transition now behaves the same on both classes, at any nesting depth:

- An atomic state that targets itself is neither exited nor entered: only the transition's
actions run, and its ancestors and sibling regions are left untouched.
- A compound state that targets a descendant stays active: its active child is exited and the
target is entered.
- A compound or parallel state that targets itself, and a parallel state that targets a
descendant, are exited and re-entered, as SCXML specifies. A targetless transition,
`to(None)`, runs an action on a compound state without leaving it.

An external self-transition was already running exit and entry actions on `StateMachine`
too, despite the flag; the upgrade guide said otherwise and is corrected. The SCXML reader
now treats `type="internal"` on an atomic source as external, as SCXML 3.13 specifies. See
{ref}`internal-transition`.

[#696](https://github.com/fgmacedo/python-statemachine/pull/696).
12 changes: 4 additions & 8 deletions docs/releases/upgrade_2x_to_3.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ now a subclass of `StateChart` with defaults that preserve 2.x behavior:
| Attribute | `StateChart` | `StateMachine` |
|-----------------------------------|:------------:|:--------------:|
| `allow_event_without_transition` | `True` | `False` |
| `enable_self_transition_entries` | `True` | `False` |
| `atomic_configuration_update` | `False` | `True` |
| `catch_errors_as_events` | `True` | `False` |

Expand Down Expand Up @@ -303,9 +302,8 @@ Or simply use `StateMachine`, which has `atomic_configuration_update=True` by de

## Self-transition entry/exit behavior

In `StateChart`, self-transitions (a state transitioning to itself) now execute entry and exit
actions, following the SCXML spec. In `StateMachine`, the 2.x behavior is preserved (no
entry/exit on self-transitions). See {ref}`self-transition`.
Self-transitions (a state transitioning to itself) now execute entry and exit actions,
following the SCXML spec. See {ref}`self-transition`.

**Before (2.x):**

Expand All @@ -320,10 +318,8 @@ loop = s1.to.itself()
# Self-transitions DO trigger on_enter_*/on_exit_* callbacks
loop = s1.to.itself()


# To disable (preserve 2.x behavior):
class MyChart(StateChart):
enable_self_transition_entries = False
# To skip them (preserve 2.x behavior), declare an internal transition:
loop = s1.to.itself(internal=True)
```


Expand Down
97 changes: 90 additions & 7 deletions docs/transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ True
| `unless` | Negative guard — transition fires when this returns `False`. |
| `validators` | Validation callback(s) that raise on failure. |
| `event` | Override the event for this transition. See {ref}`event-parameter`. |
| `internal` | If `True`, no exit/enter actions fire. See {ref}`internal transition`. |
| `internal` | If `True`, an atomic self-transition skips exit/entry, and a compound source targeting a descendant stays active. See {ref}`internal transition`. |


### Combining transitions with `|`
Expand Down Expand Up @@ -216,8 +216,12 @@ re-enters the state, running all exit and entry actions:
(internal transition)=
(internal-transition)=

An **internal transition** stays in the same state **without** running exit
or entry actions — only the `on` callback executes. Use `internal=True`:
An **internal transition** is declared with `internal=True` on a transition
whose target is the source itself or one of its descendants. What it skips
depends on the kind of source state.

On an atomic state that targets itself, nothing is exited or entered: only the
`on` callback executes:

```py
>>> class OrderCart(StateChart):
Expand All @@ -231,8 +235,13 @@ or entry actions — only the `on` callback executes. Use `internal=True`:
...
... def do_add_item(self, price: float = 0):
... self.total += price
...
... def on_enter_shopping(self):
... print("entered shopping")

>>> sm = OrderCart()
entered shopping

>>> sm.send("add_item", price=9.99)
>>> sm.send("add_item", price=4.50)
>>> sm.total
Expand All @@ -245,10 +254,84 @@ entering a state has side-effects like resetting a timer), while internal
transitions skip them (useful for pure data updates that shouldn't re-trigger
entry logic).

```{seealso}
The `enable_self_transition_entries` flag in {ref}`behaviour` controls whether
self-transitions run exit/enter actions. `StateChart` defaults to `True` (SCXML
semantics); `StateMachine` defaults to `False` (legacy behavior).
When the target is a descendant of a compound source, the source stays active
while its active child is exited and the target is entered:

```py
>>> class Ticket(StateChart):
... class open(State.Compound):
... new = State(initial=True)
... assigned = State()
... escalated = State()
...
... assign = new.to(assigned)
...
... closed = State(final=True)
...
... escalate = open.to(open.escalated, internal=True)
... close = open.to(closed)
...
... def on_exit_state(self, state):
... print(f"exit {state.id}")
...
... def on_enter_state(self, state):
... print(f"enter {state.id}")

>>> sm = Ticket()
enter open
enter new

>>> sm.send("escalate")
exit new
enter escalated

>>> sorted(sm.configuration_values)
['escalated', 'open']

```

A compound or parallel state that targets itself with `internal=True` is
still exited and re-entered, as the SCXML specification requires: its children
restart from their initial states. A parallel source is also exited and
re-entered when it targets one of its descendants. To run an action on a compound state without
leaving it, declare a **targetless** transition, `to(None)`:

```py
>>> class Ticket(StateChart):
... class open(State.Compound):
... new = State(initial=True)
... assigned = State()
...
... assign = new.to(assigned)
...
... closed = State(final=True)
...
... restart = open.to.itself(internal=True)
... comment = open.to(None, on="add_comment")
... close = open.to(closed)
...
... comments: int = 0
...
... def add_comment(self):
... self.comments += 1

>>> sm = Ticket()
>>> sm.send("assign")
>>> sm.send("comment")
>>> sm.comments, sorted(sm.configuration_values)
(1, ['assigned', 'open'])

>>> sm.send("restart")
>>> sorted(sm.configuration_values)
['new', 'open']

```


```{note}
In an SCXML document, `type="internal"` keeps its SCXML meaning: on an atomic
source the transition is external, so a self-transition there runs `onexit` and
`onentry`.
```


Expand Down
30 changes: 8 additions & 22 deletions statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,8 @@ def get_transition_domain(self, transition: Transition) -> "State | None":
elif (
transition.internal
and transition.is_self
and transition.target
and transition.target.is_atomic
and len(transition.targets) == 1
and transition.source.is_atomic
):
return transition.source
else:
Expand Down Expand Up @@ -690,16 +690,18 @@ def compute_entry_set(
default_history_content: A dictionary to hold temporary content for history states.
"""
for transition in transitions:
# Determine the ancestor state (transition domain)
ancestor = self.get_transition_domain(transition)
if ancestor is not None and ancestor in transition.targets:
continue

# Process each target state of the transition
for target_state in transition.targets:
info = StateTransition(transition=transition, state=target_state)
self.add_descendant_states_to_enter(
info, states_to_enter, states_for_default_entry, default_history_content
)

# Determine the ancestor state (transition domain)
ancestor = self.get_transition_domain(transition)

# Add ancestor states to enter for each effective target state
for effective_target in self.get_effective_target_states(transition):
info = StateTransition(transition=transition, state=effective_target)
Expand Down Expand Up @@ -741,8 +743,7 @@ def add_descendant_states_to_enter(
return

# Add the state to the entry set
if self._enters_target(info.transition):
states_to_enter.add(info)
states_to_enter.add(info)
state = info.state

if state.parallel:
Expand All @@ -754,21 +755,6 @@ def add_descendant_states_to_enter(
info, states_to_enter, states_for_default_entry, default_history_content
)

def _enters_target(self, transition: Transition) -> bool:
"""Whether taking ``transition`` enters its target.

An internal transition to its own source, or to a descendant of it, does not, unless
the machine enables self-transition entries.
"""
return (
self.sm.enable_self_transition_entries
or not transition.internal
or not (
transition.is_self
or (transition.target and transition.target.is_descendant(transition.source))
)
)

def _add_history_states_to_enter(
self,
info: StateTransition,
Expand Down
13 changes: 13 additions & 0 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
import warnings
from typing import Any

from . import registry
Expand Down Expand Up @@ -71,6 +72,7 @@ def __init__(
cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
"prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
)
cls._warn_deprecated_attributes(bases, attrs)
cls.add_inherited(bases)
cls.add_from_attributes(attrs)
cls._collect_class_listeners(attrs, bases)
Expand Down Expand Up @@ -105,6 +107,17 @@ def __init__(
cls._unpack_builders_callbacks(attrs)
cls._expand_docstring()

@staticmethod
def _warn_deprecated_attributes(bases: tuple[type], attrs: dict[str, Any]) -> None:
is_subclass = any(isinstance(base, StateMachineMetaclass) for base in bases)
if is_subclass and "enable_self_transition_entries" in attrs:
warnings.warn(
"`enable_self_transition_entries` is deprecated and has no effect since 3.3.0. "
"Use `internal=True` for a self-transition that skips exit and entry actions.",
DeprecationWarning,
stacklevel=3,
)

_STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")

def _expand_docstring(cls) -> None:
Expand Down
11 changes: 8 additions & 3 deletions statemachine/io/scxml/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,9 @@ def parse_state( # noqa: C901
state.onexit.append(content)

# Parse transitions
is_atomic = not any(child.tag in ("state", "parallel", "final") for child in state_elem)
for trans_elem in state_elem.findall("transition"):
transition = parse_transition(trans_elem)
transition = parse_transition(trans_elem, atomic_source=is_atomic)
state.transitions.append(transition)

# Parse child states — handle initial attribute and <initial> element
Expand Down Expand Up @@ -265,12 +266,16 @@ def parse_donedata(element: ET.Element) -> DoneData:
return DoneData(params=params, content_expr=content_expr)


def parse_transition(trans_elem: ET.Element, initial: bool = False) -> Transition:
def parse_transition(
trans_elem: ET.Element, initial: bool = False, atomic_source: bool = False
) -> Transition:
"""Parse a ``<transition>``; ``type="internal"`` is ignored on an atomic source, which
SCXML treats as external."""
target = trans_elem.get("target")

event = trans_elem.get("event")
cond = trans_elem.get("cond")
internal = trans_elem.get("type") == "internal"
internal = trans_elem.get("type") == "internal" and not atomic_source

executable_content = parse_executable_content(trans_elem)

Expand Down
Loading
Loading