Skip to content

[Feature] Per-state data ownership with scoped lifecycle and change tracking #661

Description

@rmonsurate

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

  1. 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.
  2. 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.

Activity

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

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