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)
class Battle(State.Compound):
fighting = State(initial=True)
won = State(final=True)
victory = fighting.to(won)
class War(StateChart):
class at_war(State.Parallel):
battles = [Battle, Battle, Battle] # ← 3 identical regions
triumphant = 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:
from statemachine.io import create_machine_class_from_definition
def make_war_class(n: int):
region = {
"initial": True,
"states": {
"fighting": {"initial": True},
"won": {"final": True},
},
"on": {"victory": [{"target": "won"}]},
}
return create_machine_class_from_definition(
"War",
states={
"at_war": {
"initial": True,
"parallel": True,
"states": {f"battle_{i}": region for i in range(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.
class War(StateChart):
class at_war(State.Parallel.replicate(3, Battle)):
pass
triumphant = 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__.
class War(StateChart):
NUM_FRONTS = 3
class at_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__.
class War(StateChart):
class at_war(State.DynamicParallel):
region = Battle
count = "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.
class War(StateChart):
at_war = State(initial=True, invoke=lambda ctx: ctx.battles)
triumphant = State(final=True)
declare_victory = at_war.to(triumphant)
def __init__(self, num_battles):
self.battles = [Battle() for _ in range(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
| System |
Construct |
Reference |
| BPMN 2.0 |
Multi-instance subprocess (parallel) |
OMG BPMN 2.0 §10.3.5 |
| SCXML (W3C) |
No dynamic-region construct |
W3C SCXML spec |
| XState v5 |
Actor model (spawnChild) |
XState actors |
| AWS Step Functions |
Map state |
Amazon States Language — Map state |
| Airflow / Prefect |
Dynamic task mapping |
Astronomer docs |
- Is the library strictly bound to W3C SCXML semantics?
- → If yes, only A, B, and D are viable.
- Would extending
create_machine_class_from_definition with a template + count shorthand be the preferred minimal solution?
- For proposal D, should
invoke support lists, or is one-to-one intentional?
- 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.
Summary
The current
State.Parallelconstruct 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: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
Warstatechart that must track several simultaneousBattlesub-machines. The number of battles is not known until the orchestrating machine is instantiated. The desired pseudo-code would be: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 aStateChartsubclass from a dict at runtime. A dynamic parallel state with N regions can be assembled via:This works but has serious ergonomic costs:
StateChartsubclass 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 factoryComplexity: 🟢 Low lift · Spec-conformant
A class method on
State.Parallelthat returns a newState.Parallelsubclass withnuniquely-named copies oftemplatebaked in at metaclass time.nmust be a literal integer — resolved at class definition.Tradeoff: Hardcodes
nat class definition. Does not solve the runtime-count use case. Lowest risk.B —
count=andregion=parameters onState.ParallelComplexity: 🟢 Low lift · Spec-conformant
Same semantics as A, but expressed via
__init_subclass__.Tradeoff: More readable than A, still static. Mostly syntax sugar over dict-based construction.
C —
State.DynamicParallelwith runtime region countComplexity: 🔴 High lift · Beyond SCXML
A new state type that defers region instantiation to
__init__.Tradeoff: This actually solves the problem. But:
done.statesemantics.D —
invoke=accepting a list of sub-machine instancesComplexity: 🟡 Medium lift · Spec-aligned
Generalize
invoketo accept a runtime list ofStateChartinstances.Tradeoff: Most spec-aligned approach. However:
In(state)).Prior art and references
spawnChild)Questions for @fgmacedo
create_machine_class_from_definitionwith atemplate + countshorthand be the preferred minimal solution?invokesupport lists, or is one-to-one intentional?contribmodule until stabilized?Environment