') + ')', '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('^' + ".*" + ', '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" + ', '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('^' + ".*" + ', '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); } })(); })(); [Feature Request] Dynamic parallel regions — fan-out over runtime-sized lists of sub-machines · Issue #616 · fgmacedo/python-statemachine · GitHub
Skip to content

[Feature Request] Dynamic parallel regions — fan-out over runtime-sized lists of sub-machines #616

Description

@brunolnetto

Summary

The current State.Parallel construct requires all regions to be statically declared as uniquely named inner classes at class-definition time. This makes it impossible to express a common real-world pattern:

Fan-out over a runtime-sized list of identical items, each driven by its own sub-machine instance, with a fan-in barrier that advances the parent only once all items are done.

This is a feature discussion, not a bug. Multiple implementation paths exist; their tradeoffs are documented below. The goal of this issue is to settle on the right abstraction before any code is written. This is first mentioned on issue #614.


Motivation

Consider a War statechart that must track several simultaneous Battle sub-machines. The number of battles is not known until the orchestrating machine is instantiated. The desired pseudo-code would be:

# Desired (not valid today)classBattle(State.Compound):
fighting=State(initial=True)
won=State(final=True)
victory=fighting.to(won)
classWar(StateChart):
classat_war(State.Parallel):
battles= [Battle, Battle, Battle] # ← 3 identical regionstriumphant=State(final=True)
declare_victory=at_war.to(triumphant) # fires when ALL battles are won

Today this must be hand-wired through an orchestration wrapper, pushing coordination logic out of the statechart and into imperative Python. The result is boilerplate that the library should be able to absorb.

Current behavior and workaround

The closest existing hook is create_machine_class_from_definition, which can build a StateChart subclass from a dict at runtime. A dynamic parallel state with N regions can be assembled via:

fromstatemachine.ioimportcreate_machine_class_from_definitiondefmake_war_class(n: int):
region= {
"initial": True,
"states": {
"fighting": {"initial": True},
"won": {"final": True},
},
"on": {"victory": [{"target": "won"}]},
}
returncreate_machine_class_from_definition(
"War",
states={
"at_war": {
"initial": True,
"parallel": True,
"states": {f"battle_{i}": regionforiinrange(n)},
},
"triumphant": {"final": True},
},
)

This works but has serious ergonomic costs:

  • The declarative Python API is bypassed entirely.
  • Region names must be generated manually.
  • Type-checking is lost.
  • The class cannot carry methods defined in a normal StateChart subclass body.

Proposed alternatives

Four designs are outlined below in ascending order of implementation complexity. They are not mutually exclusive — proposals A and B could ship as near-term conveniences while C and D are discussed separately.

A — State.Parallel.replicate(n, template) class-level factory

Complexity: 🟢 Low lift · Spec-conformant

A class method on State.Parallel that returns a new State.Parallel subclass with n uniquely-named copies of template baked in at metaclass time. n must be a literal integer — resolved at class definition.

classWar(StateChart):
classat_war(State.Parallel.replicate(3, Battle)):
passtriumphant=State(final=True)
declare_victory=at_war.to(triumphant)

Tradeoff: Hardcodes n at class definition. Does not solve the runtime-count use case. Lowest risk.


B — count= and region= parameters on State.Parallel

Complexity: 🟢 Low lift · Spec-conformant

Same semantics as A, but expressed via __init_subclass__.

classWar(StateChart):
NUM_FRONTS=3classat_war(State.Parallel, count=NUM_FRONTS, region=Battle):
pass

Tradeoff: More readable than A, still static. Mostly syntax sugar over dict-based construction.


C — State.DynamicParallel with runtime region count

Complexity: 🔴 High lift · Beyond SCXML

A new state type that defers region instantiation to __init__.

classWar(StateChart):
classat_war(State.DynamicParallel):
region=Battlecount="num_battles"triumphant=State(final=True)
declare_victory=at_war.to(triumphant)
war=War(num_battles=5)

Tradeoff: This actually solves the problem. But:

  • Requires cloning state trees at runtime.
  • Dynamic active configuration management.
  • Extended done.state semantics.
  • Breaks strict SCXML compliance.
  • Impacts diagram generation and serialization.

D — invoke= accepting a list of sub-machine instances

Complexity: 🟡 Medium lift · Spec-aligned

Generalize invoke to accept a runtime list of StateChart instances.

classWar(StateChart):
at_war=State(initial=True, invoke=lambdactx: ctx.battles)
triumphant=State(final=True)
declare_victory=at_war.to(triumphant)
def__init__(self, num_battles):
self.battles= [Battle() for_inrange(num_battles)]
super().__init__()

Tradeoff: Most spec-aligned approach. However:

  • Semantics of completion must be defined precisely.
  • No cross-region guards (In(state)).
  • Shifts responsibility into invoke machinery.

Prior art and references

SystemConstructReference
BPMN 2.0Multi-instance subprocess (parallel)OMG BPMN 2.0 §10.3.5
SCXML (W3C)No dynamic-region constructW3C SCXML spec
XState v5Actor model (spawnChild)XState actors
AWS Step FunctionsMap stateAmazon States Language — Map state
Airflow / PrefectDynamic task mappingAstronomer docs

Questions for @fgmacedo

  1. Is the library strictly bound to W3C SCXML semantics?
    • → If yes, only A, B, and D are viable.
  2. Would extending create_machine_class_from_definition with a template + count shorthand be the preferred minimal solution?
  3. For proposal D, should invoke support lists, or is one-to-one intentional?
  4. Should proposal C live in a contrib module until stabilized?

Environment

  • python-statemachine version: 3.0.x
  • Python version: 3.11+

This is a feature request, not a regression.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions