Description
States currently have no built-in data ownership. Users manage per-state variables manually on the model or the machine instance, with no scoping rules, no lifecycle, and no cleanup on exit. This adds per-state data: declared on the State, initialized on entry, removed on exit, scoped hierarchically, and observable via a change log.
Motivation
- Data logically belonging to a state (counters, accumulators, retries, form drafts) should live and die with the state
- Hierarchical scoping removes boilerplate passing of ancestor values into child callbacks
- Predictable lifecycle (fresh copy per entry) eliminates stale-state bugs
Proposed solution
Declaration
class SM(StateChart):
s1 = State(data={"count": 0, "items": DataVar(factory=list), "name": DataVar(default="x", type=str)})
data accepts a dict of string keys → defaults; DataVar replaces plain defaults with optional type= enforcement and mutually exclusive default=/factory= (violations raise InvalidDefinition)
- Plain callables in
data are treated as factories producing fresh values per entry
- Compound/parallel states accept
data as metaclass keyword (class Compound(State.Compound, data={...}))
- Invalid declarations (
data not a dict with string keys) raise InvalidDefinition at class construction
Lifecycle — on entry, data initializes as a fresh copy of defaults (factories called per entry); on exit, data is removed; re-entry resets to defaults. Data persists through on_enter/on_exit callbacks (init before onentry, removal after onexit).
Scoping — callbacks receive state_data alongside source, target, event_data: a write-through mapping merging ancestor data (child shadows parent on collision). Parallel regions isolate scopes naturally via their parent chains.
History — history recall restores saved data snapshots: deep copy for full descendants, shallow for direct children.
Public API on StateChart
| API |
Behavior |
get_state_data(state) |
Active data dict or None |
state_data_values |
Property: snapshot of all active data by state id |
set_state_data(state, key, value) |
Validates active state, declared key, DataVar type constraints — else InvalidDefinition |
get_data_changes() |
DataChangeInfo records for the current macrostep (cleared at each macrostep boundary), with state_id, key, old_value, new_value |
Integrations — data survives pickle; SCXML <datamodel>/<data id="..." expr="..."> on states parsed as Python literals into data; diagrams annotate state data variables; DataVar and DataChangeInfo importable from the statemachine package.
Affected components
statemachine/state.py — data kwarg, declaration validation
statemachine/state_data.py (new) — DataVar, DataChangeInfo, scope mapping
statemachine/statemachine.py — per-instance store, public API, pickle
statemachine/engines/base.py — enter/exit lifecycle, scope injection, history snapshots
statemachine/engines/sync.py, async_.py — macrostep-boundary change-log clearing
statemachine/__init__.py — exports
statemachine/io/scxml/{parser,processor,schema}.py — SCXML data elements
statemachine/contrib/diagram/ — data annotations
- Tests (TDD, 100% branch coverage):
tests/test_state_data.py, machines under tests/machines/state_data/, both engines via sm_runner
- Docs:
docs/states.md section + docs/api.md autoclass (doctests required)
Open decisions
- Undeclared-key writes via the injected
state_data scope: strict (raise InvalidDefinition, consistent with set_state_data, catches typos) vs. lenient (store on current state). Draft proposes strict.
- Change records cover entry-initialization, scoped/set writes, and exit-removal (any materialized old→new transition of a value).
Alternatives considered
- Global datamodel only (current SCXML behavior) — no scoping/lifecycle, keeps the manual-management problem
- Data on the shared
State class — breaks multi-instance isolation and pickle semantics
Additional context
Related but distinct: #597 (configuration persistence ergonomics), #576 (parent-child machine communication via invoke), #618 (persistence API). None cover per-state data ownership/scoping.
Description
States currently have no built-in data ownership. Users manage per-state variables manually on the model or the machine instance, with no scoping rules, no lifecycle, and no cleanup on exit. This adds per-state data: declared on the
State, initialized on entry, removed on exit, scoped hierarchically, and observable via a change log.Motivation
Proposed solution
Declaration
dataaccepts a dict of string keys → defaults;DataVarreplaces plain defaults with optionaltype=enforcement and mutually exclusivedefault=/factory=(violations raiseInvalidDefinition)dataare treated as factories producing fresh values per entrydataas metaclass keyword (class Compound(State.Compound, data={...}))datanot a dict with string keys) raiseInvalidDefinitionat class constructionLifecycle — on entry, data initializes as a fresh copy of defaults (factories called per entry); on exit, data is removed; re-entry resets to defaults. Data persists through
on_enter/on_exitcallbacks (init before onentry, removal after onexit).Scoping — callbacks receive
state_dataalongsidesource,target,event_data: a write-through mapping merging ancestor data (child shadows parent on collision). Parallel regions isolate scopes naturally via their parent chains.History — history recall restores saved data snapshots: deep copy for full descendants, shallow for direct children.
Public API on StateChart
get_state_data(state)Nonestate_data_valuesset_state_data(state, key, value)DataVartype constraints — elseInvalidDefinitionget_data_changes()DataChangeInforecords for the current macrostep (cleared at each macrostep boundary), withstate_id,key,old_value,new_valueIntegrations — data survives pickle; SCXML
<datamodel>/<data id="..." expr="...">on states parsed as Python literals intodata; diagrams annotate state data variables;DataVarandDataChangeInfoimportable from thestatemachinepackage.Affected components
statemachine/state.py—datakwarg, declaration validationstatemachine/state_data.py(new) —DataVar,DataChangeInfo, scope mappingstatemachine/statemachine.py— per-instance store, public API, picklestatemachine/engines/base.py— enter/exit lifecycle, scope injection, history snapshotsstatemachine/engines/sync.py,async_.py— macrostep-boundary change-log clearingstatemachine/__init__.py— exportsstatemachine/io/scxml/{parser,processor,schema}.py— SCXML data elementsstatemachine/contrib/diagram/— data annotationstests/test_state_data.py, machines undertests/machines/state_data/, both engines viasm_runnerdocs/states.mdsection +docs/api.mdautoclass (doctests required)Open decisions
state_datascope: strict (raiseInvalidDefinition, consistent withset_state_data, catches typos) vs. lenient (store on current state). Draft proposes strict.Alternatives considered
Stateclass — breaks multi-instance isolation and pickle semanticsAdditional context
Related but distinct: #597 (configuration persistence ergonomics), #576 (parent-child machine communication via invoke), #618 (persistence API). None cover per-state data ownership/scoping.