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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: symmetric state param for on_exit_state across compound boundaries by fgmacedo · Pull Request #635 · fgmacedo/python-statemachine · GitHub
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
11 changes: 7 additions & 4 deletions docs/actions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,13 @@ levels of the hierarchy.

```{note}
The generic `on_exit_state()` and `on_enter_state()` callbacks also fire
once per state in the set, but the `state` parameter is bound to the
transition's `source` or `target` — not the individual state being
exited/entered. Use `event_data` if you need the full context, or prefer
state-specific callbacks for clarity.
once per state in the set, and the `state` parameter is bound to the
individual state being exited or entered, so you can tell each level of a
compound apart. On entry, `target` matches `state`; on exit, `source`
matches `state`. The opposite endpoint stays fixed at the transition's
`source` (on entry) or `target` (on exit). Prefer state-specific callbacks
(`on_exit_<state>`, `on_enter_<state>`) when you want to target one level
directly.
```

```{seealso}
Expand Down
4 changes: 4 additions & 0 deletions docs/behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,6 +158,10 @@ When `True` (SCXML default), runtime exceptions in action callbacks
internal `error.execution` events. When `False` (legacy default), exceptions
propagate normally to the caller.

This flag only governs exceptions raised **inside** an action callback. An event
that doesn't match any enabled transition is a different case, controlled by
{ref}`allow_event_without_transition <behaviour>` instead.

```{note}
{ref}`Validators <validators>` are **not** affected by this flag — they
always propagate exceptions to the caller, regardless of the
Expand Down
77 changes: 77 additions & 0 deletions docs/events.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,83 @@ delayed events, and cancellation.
```


(events-as-method-protocol)=

## Events as a method-call protocol

Because every event is a callable method, a state machine can act as a guard
for the **order** in which your object's methods may be called. Set
`allow_event_without_transition = False` so that any call that doesn't match an
enabled transition raises `TransitionNotAllowed` instead of being silently
ignored. The transitions then describe the legal
sequence, and the machine enforces it for you:

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

>>> class Connection(StateChart):
... "A client whose methods can only be called in a valid order."
... allow_event_without_transition = False # reject any unexpected call
...
... disconnected = State(initial=True)
... connected = State()
... authenticated = State()
...
... connect = disconnected.to(connected)
... authenticate = connected.to(authenticated)
... disconnect = connected.to(disconnected) | authenticated.to(disconnected)
...
... def on_connect(self):
... print("socket opened")
...
... def on_authenticate(self):
... print("credentials accepted")

```

Calling the methods in a valid order works:

```py
>>> conn = Connection()
>>> conn.connect()
socket opened
>>> conn.authenticate()
credentials accepted
>>> "authenticated" in conn.configuration_values
True

```

A call that isn't valid in the current state raises, so an out-of-order method
call fails loudly instead of doing nothing:

```py
>>> fresh = Connection()

>>> fresh.authenticate() # must connect first
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't Authenticate when in Disconnected.

```

The same protection applies to unknown event names sent dynamically:

```py
>>> fresh.send("teleport")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't teleport when in Disconnected.

```

```{seealso}
{ref}`behaviour` explains `allow_event_without_transition` and the other
class-level flags that switch between this strict mode and the tolerant,
event-driven default.
```


(event-parameter)=

## The `event` parameter on transitions
Expand Down
71 changes: 71 additions & 0 deletions docs/releases/3.2.1.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
# StateChart 3.2.1

*Not released yet*

## Bug fixes in 3.2.1

### Symmetric `state` for `on_exit_state` across compound boundaries

When exiting a compound state directly (a transition like `child -> outsider`),
the generic `on_exit_state()` callback reported the transition's `source` for
**every** exited state. Exiting `child` and its parent `parent` both arrived
with `state` and `source` bound to `child`, so the parent level was never
observable and the two exit calls were indistinguishable.

This was asymmetric with `on_enter_state()`, which already binds `state` (and
`target`) to each individual state being entered. The exit side now matches:
`state` (and `source`) is bound to the individual state being exited.

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

>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")

>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)

```

Before this fix, the last line read `exit child (source=child)`, hiding the
parent. State-specific callbacks (`on_exit_<state>`) were already correctly
keyed per state and are unaffected. Flat (non-compound) machines are also
unaffected, since there the exited state is always the transition's `source`.

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

### Negative indices in `OrderedSet.__getitem__`

`OrderedSet.__getitem__` raised `ValueError` (leaking from `itertools.islice`)
when called with a negative index, instead of following the sequence protocol.
Negative indices now count from the end like any Python sequence, and an index
that is still out of range after normalisation raises `IndexError`:

```py
>>> from statemachine.orderedset import OrderedSet

>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range

```

[#633](https://github.com/fgmacedo/python-statemachine/pull/633).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.1
3.2.0
3.1.2
3.1.1
Expand Down
18 changes: 15 additions & 3 deletions statemachine/engines/async_.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,17 +71,27 @@ def _reject_pending_futures(self, exc: Exception):
# --- Callback dispatch overrides (async versions of BaseEngine methods) ---

async def _get_args_kwargs(
self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
self,
transition: "Transition",
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# See the sync engine for the rationale: bind `state`/`target` to the
# entered state and `state`/`source` to the exited state, keeping the
# generic enter/exit callbacks symmetric across compound boundaries.
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -173,7 +183,9 @@ async def _exit_states( # type: ignore[override]
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = await self._get_args_kwargs(
info.transition, trigger_data, source=info.state
)

if info.state is not None: # pragma: no branch
self._debug("%s Exiting state: %s", self._log_id, info.state)
Expand Down
17 changes: 14 additions & 3 deletions statemachine/engines/base.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -419,19 +419,30 @@ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
return result

def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
self,
transition: Transition,
trigger_data: TriggerData,
target: "State | None" = None,
source: "State | None" = None,
):
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))
cache_key = (id(transition), id(trigger_data), id(target), id(source))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
# Bind `state`/`target` to the individual state being entered, and
# `state`/`source` to the individual state being exited, so the generic
# `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
# compound state boundaries (the transition only knows its own endpoints).
if target:
event_data.state = target
event_data.target = target
if source:
event_data.state = source
event_data.source = source

args, kwargs = event_data.args, event_data.extended_kwargs

Expand DownExpand Up@@ -500,7 +511,7 @@ def _exit_states(
if info.state is not None: # pragma: no branch
self._invoke_manager.cancel_for_state(info.state)

args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)

# Execute `onexit` handlers — same per-block error isolation as onentry.
if info.state is not None: # pragma: no branch
Expand Down
50 changes: 50 additions & 0 deletions tests/test_statechart_compound.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,6 +123,56 @@ def on_enter_outside(self):
await sm_runner.send(sm, "leave")
assert log == ["exit_day", "exit_realm", "enter_outside"]

async def test_generic_enter_exit_state_param_is_symmetric(self, sm_runner):
"""Generic ``on_enter_state``/``on_exit_state`` bind ``state`` to the
individual state being crossed, symmetrically in both directions.

Regression test for #634: exiting a compound used to report the
transition ``source`` for every exited state (``child`` twice), so the
parent state was never observable. Entering already reported the
individual state, so the two callbacks were asymmetric.
"""
enters: list = []
exits: list = []

class SymmetricCompound(StateChart):
orphan = State(initial=True)

class parent(State.Compound):
child = State()

switch = orphan.to(parent.child) | parent.child.to(orphan)

def on_enter_state(self, source, target, state):
enters.append((source.id, target.id, state.id))

def on_exit_state(self, source, target, state):
exits.append((source.id, target.id, state.id))

sm = await sm_runner.start(SymmetricCompound)
enters.clear()
exits.clear()

# Enter the compound: `state`/`target` track each entered state.
await sm_runner.send(sm, "switch")
assert enters == [
("orphan", "parent", "parent"),
("orphan", "child", "child"),
]
assert exits == [("orphan", "child", "orphan")]

enters.clear()
exits.clear()

# Exit the compound: `state`/`source` track each exited state, so the
# parent is now distinguishable from the child.
await sm_runner.send(sm, "switch")
assert exits == [
("child", "orphan", "child"),
("parent", "orphan", "parent"),
]
assert enters == [("child", "orphan", "orphan")]

async def test_callbacks_inside_compound_class(self, sm_runner):
"""Methods defined inside the State.Compound class body are discovered."""
log = []
Expand Down
Loading