From e18c03b4992756f9ab22d077c2273535e9823b7d Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 13 Oct 2026 23:57:26 -0300 Subject: [PATCH 1/5] fix: internal transitions enter their target unless it stays active An atomic state targeting itself with internal=True ran its entry actions without its exit actions on StateChart. With enable_self_transition_entries=False (the StateMachine default), an internal transition to a descendant never entered the target, and an internal self-transition on a compound state exited it without re-entering it, leaving the configuration without the parent. The entry set now skips a target only when it is the transition domain, the one state the transition never exits. Document internal transitions on compound states and targetless transitions. Refs #692 Signed-off-by: Fernando Macedo --- docs/transitions.md | 88 ++++++++++++-- statemachine/engines/base.py | 19 +-- statemachine/transition.py | 6 +- tests/test_internal_transitions.py | 180 +++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 tests/test_internal_transitions.py diff --git a/docs/transitions.md b/docs/transitions.md index aa1809b5..067db1ae 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -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`, the source state is not exited. See {ref}`internal transition`. | ### Combining transitions with `|` @@ -216,8 +216,11 @@ 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** does not exit its source state. Use `internal=True` +on a transition whose target is the source itself or one of its descendants. + +On an atomic state that targets itself, nothing is exited or entered: only the +`on` callback executes: ```py >>> class OrderCart(StateChart): @@ -231,8 +234,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 @@ -245,10 +253,76 @@ 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. 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'] + ``` diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index c457957f..6d46e660 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -741,7 +741,7 @@ def add_descendant_states_to_enter( return # Add the state to the entry set - if self._enters_target(info.transition): + if not self._remains_active(info): states_to_enter.add(info) state = info.state @@ -754,20 +754,13 @@ 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. + def _remains_active(self, info: StateTransition) -> bool: + """Whether ``info.state`` is the domain of its internal transition. - An internal transition to its own source, or to a descendant of it, does not, unless - the machine enables self-transition entries. + The domain is never exited, so taking the transition must not enter it again. """ - 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)) - ) - ) + transition = info.transition + return transition.internal and info.state is self.get_transition_domain(transition) def _add_history_states_to_enter( self, diff --git a/statemachine/transition.py b/statemachine/transition.py index c068fae3..5fed38ca 100644 --- a/statemachine/transition.py +++ b/statemachine/transition.py @@ -23,8 +23,10 @@ class Transition: event (Optional[Union[str, List[str]]]): List of designators of events that trigger this transition. Can be either a list of strings, or a space-separated string list of event descriptors. - internal (bool): Is the transition internal or external? Internal transitions - don't execute the state entry/exit actions. Default ``False``. + internal (bool): Is the transition internal or external? An internal transition does + not exit its source; an atomic source targeting itself is neither exited nor + entered, while a compound or parallel source targeting itself is exited and + re-entered, as in SCXML. Default ``False``. validators (Optional[Union[str, Callable, List[Callable]]]): The validation callbacks to be invoked before the transition is executed. cond (Optional[Union[str, Callable, List[Callable]]]): The condition callbacks to be diff --git a/tests/test_internal_transitions.py b/tests/test_internal_transitions.py new file mode 100644 index 00000000..4737825d --- /dev/null +++ b/tests/test_internal_transitions.py @@ -0,0 +1,180 @@ +"""Internal transitions (``internal=True``): which states are exited and entered. + +An internal transition never exits its source when the target is a descendant of it, and an +atomic source that targets itself is neither exited nor entered. A compound or parallel source +that targets itself is exited and re-entered, as SCXML mandates. The outcome is the same on +``StateChart`` and ``StateMachine``. +""" + +import pytest + +from statemachine import State +from statemachine import StateChart +from statemachine import StateMachine + + +class Recorder: + def __init__(self): + self.log: list[str] = [] + + def on_exit_state(self, state): + self.log.append(f"exit {state.id}") + + def on_enter_state(self, state): + self.log.append(f"enter {state.id}") + + def effect(self, event): + self.log.append(f"effect {event}") + + +@pytest.fixture(params=[StateChart, StateMachine], ids=["StateChart", "StateMachine"]) +def base(request): + return request.param + + +async def start(sm_runner, cls, *prepare): + recorder = Recorder() + sm = await sm_runner.start(cls, listeners=[recorder]) + for event in prepare: + await sm_runner.send(sm, event) + recorder.log.clear() + return sm, recorder + + +def atomic_machine(base): + class Cart(base): + shopping = State(initial=True) + checkout = State(final=True) + + add_item = shopping.to.itself(internal=True, on="effect") + pay = shopping.to(checkout) + + return Cart + + +def compound_machine(base): + class Order(base): + class open(State.Compound): + draft = State(initial=True) + review = State() + + submit = draft.to(review) + + closed = State(final=True) + + reset = open.to.itself(internal=True, on="effect") + jump = open.to(open.review, internal=True, on="effect") + close = open.to(closed) + + return Order + + +def parallel_machine(base): + class Trip(base): + class booking(State.Compound): + idle = State(initial=True) + + class planning(State.Parallel): + class flight(State.Compound): + searching = State(initial=True) + booked = State() + + book_flight = searching.to(booked) + + class hotel(State.Compound): + browsing = State(initial=True) + + done = State(final=True) + + plan = booking.to(booking.planning, internal=True, on="effect") + replan = booking.planning.to.itself(internal=True, on="effect") + finish = booking.to(done) + + return Trip + + +@pytest.mark.timeout(5) +class TestInternalTransitions: + async def test_atomic_self_runs_only_the_effect(self, sm_runner, base): + sm, recorder = await start(sm_runner, atomic_machine(base)) + + await sm_runner.send(sm, "add_item") + + assert recorder.log == ["effect add_item"] + assert sm.configuration_values == {"shopping"} + + async def test_local_transition_to_descendant_keeps_source_active(self, sm_runner, base): + sm, recorder = await start(sm_runner, compound_machine(base)) + + await sm_runner.send(sm, "jump") + + assert recorder.log == ["exit draft", "effect jump", "enter review"] + assert sm.configuration_values == {"open", "review"} + await sm_runner.send(sm, "close") + assert sm.configuration_values == {"closed"} + + async def test_compound_self_exits_and_reenters_source(self, sm_runner, base): + sm, recorder = await start(sm_runner, compound_machine(base), "submit") + + await sm_runner.send(sm, "reset") + + assert recorder.log == [ + "exit review", + "exit open", + "effect reset", + "enter open", + "enter draft", + ] + assert sm.configuration_values == {"open", "draft"} + + async def test_local_transition_to_parallel_descendant_enters_every_region( + self, sm_runner, base + ): + sm, recorder = await start(sm_runner, parallel_machine(base)) + + await sm_runner.send(sm, "plan") + + assert recorder.log == [ + "exit idle", + "effect plan", + "enter planning", + "enter flight", + "enter searching", + "enter hotel", + "enter browsing", + ] + assert sm.configuration_values == { + "booking", + "planning", + "flight", + "searching", + "hotel", + "browsing", + } + + async def test_parallel_self_exits_and_reenters_source(self, sm_runner, base): + sm, recorder = await start(sm_runner, parallel_machine(base), "plan", "book_flight") + + await sm_runner.send(sm, "replan") + + assert recorder.log == [ + "exit browsing", + "exit booked", + "exit hotel", + "exit flight", + "exit planning", + "effect replan", + "enter planning", + "enter flight", + "enter searching", + "enter hotel", + "enter browsing", + ] + assert sm.configuration_values == { + "booking", + "planning", + "flight", + "searching", + "hotel", + "browsing", + } From 312b695393c17220bba4a6f6a248ceb6561bcfa0 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 13 Oct 2026 23:58:50 -0300 Subject: [PATCH 2/5] refactor: deprecate enable_self_transition_entries With internal transitions fixed, the attribute no longer changes any outcome: an external self-transition always runs exit and entry actions, and an internal one is decided by the source kind. Keep the attribute for compatibility, warn when a subclass sets it, and drop it from StateMachine and from the docs comparison table. Refs #692 Signed-off-by: Fernando Macedo --- docs/behaviour.md | 17 +++++----- docs/releases/upgrade_2x_to_3.md | 12 +++---- statemachine/factory.py | 13 ++++++++ statemachine/statemachine.py | 7 ++-- tests/examples/order_control_machine.py | 1 - .../order_control_rich_model_machine.py | 1 - tests/test_statemachine_compat.py | 33 +++++++++++++------ tests/test_transitions.py | 2 -- 8 files changed, 52 insertions(+), 34 deletions(-) diff --git a/docs/behaviour.md b/docs/behaviour.md index 72896dfe..232c2952 100644 --- a/docs/behaviour.md +++ b/docs/behaviour.md @@ -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 ` during a microstep | | `catch_errors_as_events` | `True` | `False` | Catch action errors as `error.execution` events | @@ -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 ` -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 ` if you need a transition that -stays in the same state **without** running exit/entry actions. +A {ref}`self-transition ` always executes the state's exit +and entry actions, as the SCXML spec requires. Use an +{ref}`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 diff --git a/docs/releases/upgrade_2x_to_3.md b/docs/releases/upgrade_2x_to_3.md index 630966cb..cac3df0a 100644 --- a/docs/releases/upgrade_2x_to_3.md +++ b/docs/releases/upgrade_2x_to_3.md @@ -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` | @@ -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):** @@ -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) ``` diff --git a/statemachine/factory.py b/statemachine/factory.py index 606553ff..a7ddc3ec 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -1,4 +1,5 @@ import re +import warnings from typing import Any from . import registry @@ -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) @@ -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: diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 639a21e0..dc72e407 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -83,9 +83,9 @@ class StateChart(Generic[TModel], metaclass=StateMachineMetaclass): :ref:`event` triggers. Default: ``True``.""" enable_self_transition_entries: bool = True - """If `False` (default), when a self-transition is selected, - the state entry/exit actions will not be executed. If `True`, the state entry actions - will be executed, which is conformant with the SCXML spec. + """Deprecated since 3.3.0, has no effect: setting it on a subclass emits a + ``DeprecationWarning``. An external self-transition always runs exit and entry actions; + use ``internal=True`` on an atomic state to skip them. """ atomic_configuration_update: bool = False @@ -546,6 +546,5 @@ def is_terminated(self): class StateMachine(StateChart): allow_event_without_transition: bool = False - enable_self_transition_entries: bool = False atomic_configuration_update: bool = True catch_errors_as_events: bool = False diff --git a/tests/examples/order_control_machine.py b/tests/examples/order_control_machine.py index 9c2f50a4..d615bd18 100644 --- a/tests/examples/order_control_machine.py +++ b/tests/examples/order_control_machine.py @@ -12,7 +12,6 @@ class OrderControl(StateChart): allow_event_without_transition = False - enable_self_transition_entries = False waiting_for_payment = State(initial=True) processing = State() shipping = State() diff --git a/tests/examples/order_control_rich_model_machine.py b/tests/examples/order_control_rich_model_machine.py index 99c7e674..de280ca1 100644 --- a/tests/examples/order_control_rich_model_machine.py +++ b/tests/examples/order_control_rich_model_machine.py @@ -38,7 +38,6 @@ def wait_for_payment(self): class OrderControl(StateChart): allow_event_without_transition = False - enable_self_transition_entries = False waiting_for_payment = State(initial=True, enter="wait_for_payment") processing = State() diff --git a/tests/test_statemachine_compat.py b/tests/test_statemachine_compat.py index 46ecb828..977ade8f 100644 --- a/tests/test_statemachine_compat.py +++ b/tests/test_statemachine_compat.py @@ -5,7 +5,6 @@ behaviour that differs from ``StateChart`` defaults: - ``allow_event_without_transition = False`` → ``TransitionNotAllowed`` -- ``enable_self_transition_entries = False`` - ``atomic_configuration_update = True`` - ``catch_errors_as_events = False`` → exceptions propagate directly - ``current_state`` deprecated property @@ -25,14 +24,11 @@ class TestStateMachineDefaults: - """Verify the four class-level flag defaults on StateMachine.""" + """Verify the class-level flag defaults on StateMachine.""" def test_allow_event_without_transition(self): assert StateMachine.allow_event_without_transition is False - def test_enable_self_transition_entries(self): - assert StateMachine.enable_self_transition_entries is False - def test_atomic_configuration_update(self): assert StateMachine.atomic_configuration_update is True @@ -272,16 +268,33 @@ async def after_go(self, **kwargs): # --------------------------------------------------------------------------- -# enable_self_transition_entries = False +# enable_self_transition_entries (deprecated, no effect) # --------------------------------------------------------------------------- class TestSelfTransitionNoEntries: - """With enable_self_transition_entries=False, internal self-transitions do NOT fire entry/exit. + """Self-transitions behave the same as on ``StateChart``.""" + + def test_setting_the_flag_is_deprecated(self): + with pytest.warns(DeprecationWarning, match="enable_self_transition_entries"): + + class SM(StateMachine): + enable_self_transition_entries = False + + s1 = State(initial=True) + s2 = State(final=True) + + go = s1.to(s2) + + def test_inheriting_the_flag_does_not_warn(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + + class SM(StateMachine): + s1 = State(initial=True) + s2 = State(final=True) - Note: ``enable_self_transition_entries`` only applies to *internal* self-transitions - (``internal=True``). External self-transitions always fire entry/exit regardless. - """ + go = s1.to(s2) def test_internal_self_transition_does_not_fire_enter_exit(self): log = [] diff --git a/tests/test_transitions.py b/tests/test_transitions.py index b71497f0..1b0c7829 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -307,8 +307,6 @@ def test_internal_self_transition_skips_state_actions(self, engine): calls = [] class TestStateMachine(StateChart): - enable_self_transition_entries = False - initial = State(initial=True) loop = initial.to.itself(internal=True) From 1ec1f07e912a058e6b07c7d650efce1fb555d4f7 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Wed, 14 Oct 2026 00:03:48 -0300 Subject: [PATCH 3/5] fix: an atomic internal self-transition leaves its ancestors untouched A nested atomic state targeting itself with internal=True re-entered every ancestor, and inside a parallel state it also re-entered the sibling regions, activating two states of the same region. Skip the entry set of a transition whose domain is its own target. Refs #692 Signed-off-by: Fernando Macedo --- docs/diagram.md | 2 +- statemachine/engines/base.py | 19 ++++------ tests/test_internal_transitions.py | 57 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/docs/diagram.md b/docs/diagram.md index 0ff10c2c..2c3c4329 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -701,7 +701,7 @@ A transition from a state back to itself. ### Internal transitions -Internal transitions execute actions without exiting/entering the state. +Internal transitions run their actions without exiting the source state. See {ref}`internal transition`. ```{literalinclude} ../tests/machines/showcase_internal.py :pyobject: InternalSC diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 6d46e660..1b826a18 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -690,6 +690,11 @@ 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) @@ -697,9 +702,6 @@ def compute_entry_set( 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) @@ -741,8 +743,7 @@ def add_descendant_states_to_enter( return # Add the state to the entry set - if not self._remains_active(info): - states_to_enter.add(info) + states_to_enter.add(info) state = info.state if state.parallel: @@ -754,14 +755,6 @@ def add_descendant_states_to_enter( info, states_to_enter, states_for_default_entry, default_history_content ) - def _remains_active(self, info: StateTransition) -> bool: - """Whether ``info.state`` is the domain of its internal transition. - - The domain is never exited, so taking the transition must not enter it again. - """ - transition = info.transition - return transition.internal and info.state is self.get_transition_domain(transition) - def _add_history_states_to_enter( self, info: StateTransition, diff --git a/tests/test_internal_transitions.py b/tests/test_internal_transitions.py index 4737825d..fb4840b4 100644 --- a/tests/test_internal_transitions.py +++ b/tests/test_internal_transitions.py @@ -8,6 +8,7 @@ import pytest +from statemachine import HistoryState from statemachine import State from statemachine import StateChart from statemachine import StateMachine @@ -93,6 +94,46 @@ class hotel(State.Compound): return Trip +def nested_atomic_machine(base): + class Console(base): + class session(State.Parallel): + class editor(State.Compound): + clean = State(initial=True) + dirty = State() + + edit = clean.to(dirty) + type_key = dirty.to.itself(internal=True, on="effect") + + class status(State.Compound): + ready = State(initial=True) + + closed = State(final=True) + + quit = session.to(closed) + + return Console + + +def history_machine(base): + class Wizard(base): + class steps(State.Compound): + first = State(initial=True) + second = State() + h = HistoryState() + + advance = first.to(second) + + paused = State() + done = State(final=True) + + resume = steps.to(steps.h, internal=True, on="effect") + pause = steps.to(paused) + unpause = paused.to(steps.h) + finish = steps.to(done) | paused.to(done) + + return Wizard + + @pytest.mark.timeout(5) class TestInternalTransitions: async def test_atomic_self_runs_only_the_effect(self, sm_runner, base): @@ -178,3 +219,19 @@ async def test_parallel_self_exits_and_reenters_source(self, sm_runner, base): "hotel", "browsing", } + + async def test_nested_atomic_self_leaves_ancestors_untouched(self, sm_runner, base): + sm, recorder = await start(sm_runner, nested_atomic_machine(base), "edit") + + await sm_runner.send(sm, "type_key") + + assert recorder.log == ["effect type_key"] + assert sm.configuration_values == {"session", "editor", "dirty", "status", "ready"} + + async def test_local_transition_to_recorded_history_keeps_source_active(self, sm_runner, base): + sm, recorder = await start(sm_runner, history_machine(base), "advance", "pause", "unpause") + + await sm_runner.send(sm, "resume") + + assert recorder.log == ["exit second", "effect resume", "enter second"] + assert sm.configuration_values == {"steps", "second"} From 974a5df2fd1d896086b58fc1866701ab96e305b2 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Wed, 14 Oct 2026 00:09:26 -0300 Subject: [PATCH 4/5] fix: keep SCXML semantics for internal transitions from atomic sources A multi-target transition whose first target is its atomic source was taken as an atomic internal self-transition, dropping the other targets; the no-op domain now requires a single target. The SCXML reader ignores type="internal" on an atomic source, which SCXML treats as external. Document which internal transitions exit their source. Refs #692 Signed-off-by: Fernando Macedo --- docs/diagram.md | 2 +- docs/transitions.md | 17 +++++++-- statemachine/engines/base.py | 4 +- statemachine/io/scxml/reader.py | 11 ++++-- statemachine/transition.py | 9 +++-- tests/scxml/test_internal_transitions.py | 36 ++++++++++++++++++ tests/test_internal_transitions.py | 47 ++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 tests/scxml/test_internal_transitions.py diff --git a/docs/diagram.md b/docs/diagram.md index 2c3c4329..7056d2e0 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -701,7 +701,7 @@ A transition from a state back to itself. ### Internal transitions -Internal transitions run their actions without exiting the source state. See {ref}`internal transition`. +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 diff --git a/docs/transitions.md b/docs/transitions.md index 067db1ae..3e6113b7 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -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`, the source state is not exited. 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 `|` @@ -216,8 +216,9 @@ re-enters the state, running all exit and entry actions: (internal transition)= (internal-transition)= -An **internal transition** does not exit its source state. Use `internal=True` -on a transition whose target is the source itself or one of its descendants. +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: @@ -291,7 +292,8 @@ enter escalated 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. To run an action on a compound state without +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 @@ -326,6 +328,13 @@ leaving it, declare a **targetless** transition, `to(None)`: ``` +```{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`. +``` + + (eventless)= ## Eventless (automatic) transitions diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 1b826a18..44778ee7 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -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: diff --git a/statemachine/io/scxml/reader.py b/statemachine/io/scxml/reader.py index 38a41fbf..70c8d0e0 100644 --- a/statemachine/io/scxml/reader.py +++ b/statemachine/io/scxml/reader.py @@ -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 element @@ -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 ````; ``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) diff --git a/statemachine/transition.py b/statemachine/transition.py index 5fed38ca..38e95f8e 100644 --- a/statemachine/transition.py +++ b/statemachine/transition.py @@ -23,10 +23,11 @@ class Transition: event (Optional[Union[str, List[str]]]): List of designators of events that trigger this transition. Can be either a list of strings, or a space-separated string list of event descriptors. - internal (bool): Is the transition internal or external? An internal transition does - not exit its source; an atomic source targeting itself is neither exited nor - entered, while a compound or parallel source targeting itself is exited and - re-entered, as in SCXML. Default ``False``. + internal (bool): Is the transition internal or external? An atomic source targeting + itself is neither exited nor entered, and a compound source targeting a + descendant stays active. A compound or parallel source targeting itself, or a + parallel source targeting a descendant, is exited and re-entered, as in SCXML. + Default ``False``. validators (Optional[Union[str, Callable, List[Callable]]]): The validation callbacks to be invoked before the transition is executed. cond (Optional[Union[str, Callable, List[Callable]]]): The condition callbacks to be diff --git a/tests/scxml/test_internal_transitions.py b/tests/scxml/test_internal_transitions.py new file mode 100644 index 00000000..ff46c5bd --- /dev/null +++ b/tests/scxml/test_internal_transitions.py @@ -0,0 +1,36 @@ +"""``type="internal"`` keeps the SCXML meaning: it only applies to a compound source. + +On an atomic source the transition is external, so targeting itself runs ``onexit`` and +``onentry`` again. + +Theme: a lantern that is relit. +""" + +import pytest +from statemachine.io.scxml.processor import SCXMLProcessor + +pytestmark = pytest.mark.scxml + +LANTERN_SCXML = """ + + + + + + + + + + + +""" + + +def test_internal_self_transition_on_an_atomic_state_is_external(): + processor = SCXMLProcessor() + processor.parse_scxml("lantern", LANTERN_SCXML) + sm = processor.start() + + sm.send("relight") + + assert (sm.model.lightings, sm.model.snuffs) == (2, 1) diff --git a/tests/test_internal_transitions.py b/tests/test_internal_transitions.py index fb4840b4..696ec3fd 100644 --- a/tests/test_internal_transitions.py +++ b/tests/test_internal_transitions.py @@ -6,7 +6,10 @@ ``StateChart`` and ``StateMachine``. """ +import json + import pytest +from statemachine.io import load from statemachine import HistoryState from statemachine import State @@ -235,3 +238,47 @@ async def test_local_transition_to_recorded_history_keeps_source_active(self, sm assert recorder.log == ["exit second", "effect resume", "enter second"] assert sm.configuration_values == {"steps", "second"} + + +def test_multi_target_internal_from_an_atomic_source_is_external(): + sm = load( + json.dumps( + { + "name": "Beacons", + "states": { + "beacons": { + "parallel": True, + "initial": True, + "states": { + "east": { + "states": { + "east_dark": { + "initial": True, + "transitions": [ + { + "event": "light", + "target": "east_dark west_lit", + "internal": True, + } + ], + } + } + }, + "west": { + "states": { + "west_dark": {"initial": True}, + "west_lit": {}, + } + }, + }, + } + }, + } + ), + format="json", + validate=True, + )() + + sm.send("light") + + assert sm.configuration_values == {"beacons", "east", "east_dark", "west", "west_lit"} From 98217c26342acb18eea16929683b72d4127838cf Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 16 Oct 2026 19:22:01 -0300 Subject: [PATCH 5/5] docs: release notes for the internal transition fixes Signed-off-by: Fernando Macedo --- docs/releases/3.3.0.md | 48 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/releases/3.3.0.md b/docs/releases/3.3.0.md index 163556be..8e83fe72 100644 --- a/docs/releases/3.3.0.md +++ b/docs/releases/3.3.0.md @@ -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 @@ -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).