Dataclasses¶
A State field works on a dataclass exactly as it does on a plain class, with
one rule to remember: declare it without an annotation.
@dataclass
class SupportTicket(StateEngine):
subject: str
reporter: str
assignee: str = ''
notes: list[str] = field(default_factory=list)
state = State(TicketState, default=TicketState.OPEN)
@state.transition(
source=TicketState.OPEN,
target=TicketState.TRIAGED,
conditions=[lambda ticket: bool(ticket.assignee)],
)
def triage(self) -> None:
"""Accept the ticket, once someone owns it."""
self.notes.append(f'triaged by {self.assignee}')
@state.transition(source=TicketState.TRIAGED, target=TicketState.RESOLVED)
def resolve(self, summary: str) -> None:
"""Record how the ticket was resolved."""
self.notes.append(summary)
@state.transition(source=[TicketState.RESOLVED, TicketState.TRIAGED], target=TicketState.CLOSED)
def close(self) -> None:
"""Close the ticket for good."""
The state is a descriptor on the class, not a value on the instance, so it is
not data the dataclass should be generating an __init__ parameter for. Leaving
the annotation off is what tells @dataclass to ignore it:
>>> from dataclasses import fields
>>> [field.name for field in fields(dataclass_flow.SupportTicket)]
['subject', 'reporter', 'assignee', 'notes']
Everything a dataclass gives you is intact — the generated __init__, repr
and __eq__ — and the machine is layered on top:
>>> ticket = dataclass_flow.SupportTicket(subject='Login fails', reporter='ada')
>>> ticket
SupportTicket(subject='Login fails', reporter='ada', assignee='', notes=[])
>>> ticket.state
<TicketState.OPEN: 'OPEN'>
The state is absent from the repr and from __eq__, because it is not a
field. Two tickets with the same data compare equal whatever state they are in:
Put the state in the repr if you want it there
Add def __repr__(self) yourself, or expose the state as a
@property under a different name. Do not try to make it a field.
Conditions read the dataclass fields¶
A predicate is called with the instance, so a dataclass field is exactly what it
should be testing. triage is refused until the ticket has an assignee:
>>> ticket.triage.can_proceed()
False
>>> ticket.assignee = 'grace'
>>> ticket.triage.can_proceed()
True
Transition bodies mutate fields like any other method:
>>> ticket.triage()
>>> ticket.resolve('cleared the cache')
>>> ticket.state
<TicketState.RESOLVED: 'RESOLVED'>
>>> ticket.notes
['triaged by grace', 'cleared the cache']
The annotation trap¶
Annotating the field makes @dataclass treat it as a real field with a default,
so the generated __init__ tries to assign it — and the descriptor refuses:
>>> from dataclasses import dataclass
>>> @dataclass
... class Trap(StateEngine):
... subject: str
... state: State = State(['open', 'closed'], default='open')
>>> Trap('anything')
Traceback (most recent call last):
...
AttributeError: Direct state modification is not allowed
The error is the same one you get from instance.state = value, because it is
that: the dataclass-generated __init__ assigning to the descriptor. Drop the
: State annotation and it goes away.
The failure is at construction, not at import
A wrongly annotated field builds a perfectly valid class. Nothing complains until something instantiates it. If you are converting an existing flow to a dataclass, construct one instance in a test.
Frozen dataclasses¶
frozen=True blocks setattr, and the default storage is a setattr — so the
first transition fails on the way in:
>>> @dataclass(frozen=True)
... class NaiveFrozen(StateEngine):
... subject: str
... state = State(['open', 'closed'], default='open')
... @state.transition(source='open', target='closed')
... def close(self):
... """Never gets to run."""
>>> NaiveFrozen('anything').close()
Traceback (most recent call last):
...
dataclasses.FrozenInstanceError: cannot assign to field '__fsm_state'
Give the field its own accessors and it works. object.__setattr__ is the same
escape hatch a frozen dataclass's own __post_init__ uses:
@dataclass(frozen=True)
class FrozenTicket(StateEngine):
subject: str
state = State(TicketState, default=TicketState.OPEN)
@state.getter()
def _read_state(self) -> TicketState | None:
return getattr(self, '_state_value', None)
@state.setter()
def _write_state(self, value: TicketState) -> None:
object.__setattr__(self, '_state_value', value)
@state.transition(source=TicketState.OPEN, target=TicketState.CLOSED)
def close(self) -> None:
"""Close the ticket without mutating any declared field."""
>>> frozen = dataclass_flow.FrozenTicket('Frozen one')
>>> frozen.state
<TicketState.OPEN: 'OPEN'>
>>> frozen.close()
>>> frozen.state
<TicketState.CLOSED: 'CLOSED'>
>>> frozen
FrozenTicket(subject='Frozen one')
Be honest about what this buys you: the declared fields are still immutable, but the object now has a mutable state. If you want a genuinely immutable value, do not put a machine on it — keep the state in a mutable flow object that holds the frozen value, the same way Binding state to storage keeps it in a record.
slots=True¶
A slotted dataclass that inherits StateEngine works unchanged. StateEngine
declares no __slots__ of its own, so instances still get a __dict__ from it,
and the default storage has somewhere to live:
>>> @dataclass(slots=True)
... class SlottedTicket(StateEngine):
... subject: str
... state = State(['open', 'closed'], default='open')
... @state.transition(source='open', target='closed')
... def close(self):
... """Close the ticket."""
>>> slotted = SlottedTicket('Slotted one')
>>> slotted.close()
>>> slotted.state
'closed'
Note what that means, though: the __slots__ are no longer saving you the
per-instance __dict__, which is usually the reason for asking for them.
Drop the mixin — using the module-level functions
for introspection instead — and there is no __dict__ to fall back on:
>>> @dataclass(slots=True)
... class LeanTicket:
... subject: str
... state = State(['open', 'closed'], default='open')
... @state.transition(source='open', target='closed')
... def close(self):
... """Never gets to run."""
>>> LeanTicket('anything').close()
Traceback (most recent call last):
...
AttributeError: 'LeanTicket' object has no attribute '__fsm_state'
The fix is the same shape as the frozen one: a getter and setter over storage
the class actually has. Declare it as a field with a leading underscore, a
default, and repr=False:
>>> from dataclasses import field
>>> @dataclass(slots=True)
... class LeanTicket:
... subject: str
... _state_value: str = field(default='open', repr=False)
... state = State(['open', 'closed'], default='open')
... @state.getter()
... def _read_state(self):
... return self._state_value
... @state.setter()
... def _write_state(self, value):
... self._state_value = value
... @state.transition(source='open', target='closed')
... def close(self):
... """Close the ticket."""
>>> lean = LeanTicket('Lean one')
>>> lean.close()
>>> lean.state
'closed'
>>> lean
LeanTicket(subject='Lean one')
Which variant to use¶
| Dataclass | Works out of the box | What to do |
|---|---|---|
@dataclass |
Yes | Declare state = State(...) with no annotation |
@dataclass(slots=True) with StateEngine |
Yes | Nothing, but you keep a __dict__ |
@dataclass(slots=True) without the mixin |
No | Add a getter/setter over a declared field |
@dataclass(frozen=True) |
No | Add a getter/setter using object.__setattr__ |
Annotated state: State |
Never | Remove the annotation |
The same reasoning applies to attrs, pydantic.BaseModel and any other class
generator: if it turns annotated class attributes into instance state, keep the
State field out of the annotations, and if it restricts setattr, give the
field a setter.
Related guides¶
- The State field — declaring and storing the state
- Binding state to storage — getters, setters and saving
- Conditions — predicates over your fields