Skip to content

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo
, '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

fix: symmetric state param for on_exit_state across compound boundaries - #635

Merged
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634
Jun 26, 2026
Merged

fix: symmetric state param for on_exit_state across compound boundaries#635
fgmacedo merged 3 commits into
developfrom
fix/on-exit-state-symmetry-634

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Symmetric state for on_exit_state across compound boundaries

Closes#634.

Problem

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

Fix

_get_args_kwargs gains a source override mirroring the existing target
one, and _exit_states passes source=info.state. Now state/source track
the individual state being exited. Applied to both the sync and async
engines (the async engine re-implements these methods).

exit child (source=child)
exit parent (source=parent) # was: exit child (source=child)

State-specific callbacks (on_exit_<state>) were already correctly keyed per
state and are unaffected. Flat machines are unaffected, since there the exited
state is always the transition's source.

Tests & docs

  • New parametric sm_runner test exercising both engines on the exact scenario
    from the issue (tests/test_statechart_compound.py).
  • 100% branch coverage preserved on both engine modules.
  • docs/actions.md note corrected (it previously described the old, asymmetric
    behavior).
  • Release notes docs/releases/3.2.1.md added (also covering fix: support negative indices in OrderedSet.__getitem__ #633), listed in
    the toctree.

Also included (docs, Refs #631)

This branch also carries a docs-only commit documenting
allow_event_without_transition as a method-call ordering protocol
(docs/events.md) and clarifying the scope of catch_errors_as_events
(docs/behaviour.md).

Exiting a compound state directly reported the transition's source for every
exited state, so on_exit_state fired with state/source bound to the child and
the parent level was never observable. This was asymmetric with on_enter_state,
which already binds state/target to each individual state being entered.
Add a source override to _get_args_kwargs (mirroring the existing target
override) and pass source=info.state from _exit_states, in both the sync and
async engines. Covered by a parametric sm_runner test on both engines, plus
release notes for 3.2.1.
Closes#634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Add an events.md section showing how allow_event_without_transition = False
turns a state machine into a guard for the legal order of method calls, with
doctests for the valid sequence, out-of-order calls, and unknown event names.
Clarify in behaviour.md that catch_errors_as_events only governs exceptions
raised inside action callbacks, while a non-matching event is a separate case
controlled by allow_event_without_transition.
Refs #631
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
Update the actions.md note now that on_exit_state binds state/source to the
individual exited state (was describing the old asymmetric behavior), and add
3.2.1 to the release notes toctree so the new page is not orphaned.
Refs #634
Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedofgmacedo self-assigned this Jun 26, 2026
@codecov

codecovBot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2c1aa0c) to head (d0869c0).

Additional details and impacted files
@@ Coverage Diff @@## develop #635 +/- ##
=========================================
Coverage 100.00% 100.00% =========================================
Files 52 52 Lines 5499 5505 +6 Branches 867 869 +2 =========================================
+ Hits 5499 5505 +6 
FlagCoverage Δ
unittests100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo
fgmacedo merged commit aff3977 into developJun 26, 2026
12 of 13 checks passed
@fgmacedo
fgmacedo deleted the fix/on-exit-state-symmetry-634 branch June 26, 2026 21:17
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate FailedQuality Gate failed

Failed conditions
26.6% Duplication on New Code (required ≤ 10%)

See analysis details on SonarQube Cloud

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

on_exit_state and on_enter_state should be symmetrical when exiting compound states

1 participant

@fgmacedo