Skip to content

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_flow.py
@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:

>>> dataclass_flow.SupportTicket('a', 'b') == dataclass_flow.SupportTicket('a', 'b')
True

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_flow.py
@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.