Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Understanding FSMs by Building One From Scratch

Дата публикации: 20-04-2026 11:08:09

After having worked with the transitions library for a while, Bob wondered how Finite State Machines work under the hood. This article shows you how he built one from scratch, modelling GitHub pull requests.

Основное содержимое страницы с новостью.

Not sure what's worth building next? That's the first thing I work through with the developers I coach. Let's figure it out →

Libraries like transitions make using finite state machines (FSMs) easy. Maybe too easy. You wire up states, transitions, and callbacks, and it 'just' works. But when you need to customize behavior, debug a stuck workflow, or explain to a teammate why a transition silently failed, you need a deeper understanding. And what better way than building a simple FSM from scratch?

I've used the transitions library in production for years to manage content workflows: draft to review to approved to published, with callbacks that assign reviewers and send notifications. It looks something like this:

from enum import StrEnum, auto
from transitions import Machine

class States(StrEnum):
    DRAFT = auto()
    REVIEW = auto()
    APPROVED = auto()
    PUBLISHED = auto()

TRANSITIONS = [
    {"trigger": "submit", "source": States.DRAFT, "dest": States.REVIEW, "after": "assign_reviewer"},
    {"trigger": "approve", "source": States.REVIEW, "dest": States.APPROVED, "after": "notify_author"},
    {"trigger": "publish", "source": States.APPROVED, "dest": States.PUBLISHED, "after": "clear_tasks"},
]

Machine(model=model, states=list(States), transitions=TRANSITIONS, initial=States.DRAFT)

StrEnum + auto() gives each member a value equal to its lowercased name (States.DRAFT == "draft"). Because StrEnum instances are strings, you can use them directly as dict keys, in comparisons, and as arguments; no .value unwrapping needed.

Powerful. But what's actually happening when you call model.submit()? How does it validate transitions, run guards, fire callbacks? Building one from scratch answers those questions and makes you better at using (and extending) the library.

Build a simple FSM from scratch

Let's model a GitHub pull request (PR) workflow, something every developer knows. Here's the state diagram:

FSM state diagram for GitHub PR workflow

PRs move through states with rules:

  • You can't merge without approval,
  • Closed PRs can reopen,
  • The merged state is terminal.

Start with the transition map. This is the core data structure: a dictionary where each state maps to its set of valid next states.

TRANSITIONS = {
    "draft": {"open", "closed"},
    "open": {"changes_requested", "approved", "closed"},
    "changes_requested": {"open", "closed"},
    "approved": {"merged", "open", "closed"},
    "merged": set(),  # terminal
    "closed": {"open"},
}

An empty set means terminal, done. This makes it easy to check, relying on Python's truthiness: if not transitions[state]: means "this state has no valid next states."

Next, let's build the StateMachine class. I often say: start with functions, but FSMs bundle state and behavior in a way that makes classes the right fit. sm.current, sm.transition("open"), sm.can_transition("merged"): a clean interface that only a class gives you.

The transition map is passed in rather than hardcoded. This keeps the class reusable for any workflow and makes the dependency explicit.

class InvalidTransition(Exception):
    pass


class StateMachine:
    def __init__(self, initial, transitions: dict[str, set[str]], *, guards=None, hooks=None, context=None):
        self._state = initial
        self._transitions = transitions
        self._guards = guards if guards is not None else []
        self._hooks = hooks if hooks is not None else {}
        self._context = context if context is not None else {}
        self._history: list[tuple[str, str]] = []

    @property
    def current(self):
        return self._state

    @property
    def context(self):
        return self._context

    @property
    def history(self):
        return list(self._history)

    def can_transition(self, to):
        return to in self._transitions.get(self._state, set())

    def transition(self, to):
        if not self.can_transition(to):
            raise InvalidTransition(f"Cannot go from {self._state} to {to}")

        errors = []
        for guard in self._guards:
            if not guard(self, to):
                errors.append(guard.__name__)

        if errors:
            raise InvalidTransition(
                f"Cannot go from {self._state} to {to} "
                f"due to failed guards: {', '.join(errors)}"
            )

        previous = self._state
        self._state = to
        self._history.append((previous, to))

        for hook in self._hooks.get(to, []):
            hook(self, previous, to)

Less than 50 lines. Let's break down the four extension points:

  1. Guards are functions that say "not yet." They receive the machine and target state, returning a boolean. All must pass. For example, a PR can't be merged without approvals:
def require_approval(sm, to):
    return to != "merged" or sm.context.get("approvals", 0) >= 1

sm = StateMachine("draft", TRANSITIONS, guards=[require_approval], context={"approvals": 0})
sm.transition("open")
sm.transition("approved")
sm.transition("merged")  # raises InvalidTransition: failed guard
  1. Hooks fire after a successful transition. They're callbacks keyed by destination state:
def notify_reviewers(sm, from_state, to_state):
    sm.context["notifications"] = sm.context.get("notifications", 0) + 1

sm = StateMachine("draft", TRANSITIONS, hooks={"open": [notify_reviewers]})
sm.transition("open")
assert sm.context["notifications"] == 1
  1. Context is a dict guards and hooks can read and write — approval counts, assigned reviewers, timestamps. Not needed in this example, but in production it's where you track everything that doesn't belong in state itself.

  2. History is an append-only audit trail. Every successful transition records (from_state, to_state). Failed guard names surface in the InvalidTransition exception message — no instance state needed, no risk of stale data between calls.

The full implementation with tests is available as a GitHub Gist, a single file you can run with uvx pytest fsm.py -v.

The functional alternative

If you don't need guards, hooks, or history, Python's match/case can offer a simpler state dispatcher:

def next_state(current: str, action: str) -> str:
    match (current, action):
        case ("draft", "open"): return "open"
        case ("open", "approve"): return "approved"
        case ("approved", "merge"): return "merged"
        case _: raise ValueError(f"Invalid: {action} from {current}")

Clean and readable for 3-4 states. But it doesn't scale. Add guards, hooks, and history, and it gets messy fast. That's when a class-based FSM is the better choice.


FSMs enforce business logic around valid system transitions. No more "how did this PR end up merged without approval?"

A simple implementation can be a class of less than 100 lines of code, and it makes you understand the patterns behind FSMs: the transition map, guards, hooks, context, and history. If you want to use another level of abstraction, the transitions library is great.

The next time you see a workflow with states and rules, implement your own FSM.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Python in a Sandbox With MicroPython and WASM07.1628-06-2026
2A Primer on Database States0514-07-2026
3build-your-own-openclaw - A step-by-step guide to build your own AI agent.028.1802-06-2026
4Объяснение лямбда исчисления через Python07.9422-04-2026
5 Firestore Data Modeling Guide: Embedded Documents vs Referencing (with a Blog Case Study) 08.1924-07-2026
6Recursive structural pattern matching06.9111-01-2026
7 How to Diagnose Production Bugs When You Can't Reproduce Them Locally 07.6624-07-2026
8Argo CD vs Portainer GitOps: An Implementation-Level Comparison0706-01-2026
9A practical guide to React error monitoring07.0629-07-2026
10fapost/support08.2603-08-2026

Классификация: . Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 7.71. Источник: pythondigest.ru.