Skip to content

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.

inheritance.py
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:

>>> guest.restore()
>>> guest.state
<ReviewState.DRAFT: 'DRAFT'>

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:

inheritance.py
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().archive resolves to the base's bound transition, and .original() calls its undecorated function. Calling super().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.