Inheritance¶
A subclass extends the machine its base declared, using the base's own State
field. There are three things you can do: add a transition, replace one, or wrap
one.
class GuestArticle(Article):
"""A guest contribution: only published work may be archived, and archiving is reversible."""
@Article.state.transition(source=ReviewState.PUBLISHED, target=ReviewState.ARCHIVED)
def archive(self) -> None:
"""Replaces the base ``State.ANY`` declaration with a narrower one."""
@Article.state.transition(source=ReviewState.ARCHIVED, target=ReviewState.DRAFT)
def restore(self) -> None:
"""A transition the base class does not have."""
The decorator is spelled @Article.state.transition(...) — the field lives on
the base, and the subclass reaches through the class to get at it.
Adding a transition¶
restore is new. The subclass has it, the base does not:
>>> guest = inheritance.GuestArticle('Guest post', body='A body.')
>>> [transition.slug for transition in guest.get_transitions()]
['approve', 'archive', 'publish', 'reject', 'restore', 'submit']
>>> [transition.slug for transition in publication.Article('Staff post').get_transitions()]
['approve', 'archive', 'publish', 'reject', 'submit']
Replacing a transition¶
This is the part that surprises people. Redeclaring a transition on a subclass replaces the base's declaration rather than adding to it.
Article.archive is declared with State.ANY, so a staff article can be
archived from anywhere:
>>> staff = publication.Article('Staff post')
>>> staff.archive()
>>> staff.state
<ReviewState.ARCHIVED: 'ARCHIVED'>
GuestArticle.archive redeclares it for PUBLISHED only. The wildcard is gone,
not merged:
>>> guest.archive()
Traceback (most recent call last):
...
open_fsm.base.NoTransition: Archive :: no transition from "DRAFT"
Take the article all the way to PUBLISHED and the narrowed transition applies:
>>> guest.submit()
>>> guest.approve()
>>> guest.publish()
>>> guest.archive()
>>> guest.state
<ReviewState.ARCHIVED: 'ARCHIVED'>
And the transition restore added is reachable from there:
Replacement is per method, not per source
Redeclaring one source does not leave the base's other sources in place — the subclass's method owns its own transition table entirely. A subclass that means to narrow a transition must restate every source it wants to keep.
This is worth a test. Nothing warns you that a base transition disappeared, and a machine that silently lost a legal move fails only when someone tries to make it.
The merged view¶
Introspection reports the machine as the subclass sees it — inherited entries and new ones together, with replaced ones gone:
>>> for source, target in sorted(inheritance.declared_transitions(guest), key=str):
... print(str(source), '->', str(target))
ReviewState.APPROVED -> ReviewState.PUBLISHED
ReviewState.ARCHIVED -> ReviewState.DRAFT
ReviewState.DRAFT -> ReviewState.IN_REVIEW
ReviewState.IN_REVIEW -> ReviewState.APPROVED
ReviewState.IN_REVIEW -> ReviewState.REJECTED
ReviewState.PUBLISHED -> ReviewState.ARCHIVED
There is no ANY -> ARCHIVED row. Compare it against the base's own table when
you want to see what a subclass changed.
Wrapping an inherited transition¶
Sometimes you want the base's states and the base's body, plus something of your
own. Restating source= and target= would duplicate the declaration and let
the two drift. @state.super() inherits them:
class AuditedArticle(Article):
def __init__(self, title: str, body: str = '') -> None:
super().__init__(title, body)
self.audit_log: list[str] = []
@Article.state.super()
def archive(self) -> None:
"""Wraps the inherited transition without restating its source or target."""
self.audit_log.append(f'archived {self.title}')
super().archive.original()
>>> audited = inheritance.AuditedArticle('Audited', body='A body.')
>>> audited.archive()
>>> audited.state
<ReviewState.ARCHIVED: 'ARCHIVED'>
>>> audited.audit_log
['archived Audited']
The declaration is still the base's — the wildcard survives, because nothing redeclared it:
>>> [(str(t.source), str(t.target)) for t in inheritance.AuditedArticle.archive.get_transitions()]
[('ANY', 'ReviewState.ARCHIVED')]
Two details in that body:
super().archiveresolves to the base's bound transition, and.original()calls its undecorated function. Callingsuper().archive()instead would run the whole machine a second time, from the state your wrapper is already in.- The state has already moved by the time your body runs, since the target is an ordinary one. Read the source state before the call if you need it.
@state.super() needs a base to find
It searches the MRO for a transition method of the same name and raises
ValueError('Base transition not found') if there is none. It is for
wrapping, never for declaring.
Conditions across the hierarchy¶
A condition declared with this resolves against the instance's class, so a
subclass can supply the predicate a base transition refers to, and a subclass
may override it:
>>> class Reviewed(StateEngine):
... state = State(['draft', 'submitted'], default='draft')
... def __init__(self, body=''):
... self.body = body
... def is_ready(self):
... return State.CONDITION(bool(self.body), unmet='no body')
... @state.transition(source='draft', target='submitted', conditions=[this.is_ready])
... def submit(self):
... """Submit for review."""
>>> class StrictlyReviewed(Reviewed):
... def is_ready(self):
... return State.CONDITION(len(self.body) > 20, unmet='fewer than 20 characters')
>>> Reviewed('short').submit.can_proceed()
True
>>> StrictlyReviewed('short').submit.can_proceed()
False
The transition was never redeclared. Only the predicate it names was overridden, which is usually the cleaner way to make a subclass stricter — the transition table stays identical and only the rule changes.
Related guides¶
- Transitions — declaring them in the first place
- Conditions —
thisandState.CONDITION - Introspection — reading the merged machine