Skip to main content

Declarative finite state machine for SQLAlchemy models — typed, tested, drop-in for both SQLAlchemy 1.4 and 2.x.

Project description

PyPI version Python versions CI License: MIT Downloads Ruff Checked with pyright

sqlalchemy-fsm

Declarative finite state machine for SQLAlchemy models. Add an FSMField column, decorate methods with @transition, and let the library enforce which transitions are reachable from which states.

Requirements

Python 3.10+, SQLAlchemy 1.4+ (2.x supported).

Install

pip install sqlalchemy-fsm

Quickstart

import sqlalchemy as sa
from sqlalchemy.orm import declarative_base
from sqlalchemy_fsm import FSMField, transition

Base = declarative_base()


class BlogPost(Base):
    __tablename__ = "blog_post"
    id = sa.Column(sa.Integer, primary_key=True)
    state = sa.Column(FSMField, nullable=False, default="draft")

    @transition(source="draft", target="published")
    def publish(self):
        """Side effects of publishing go here (notifications, cache busts, …).
        The return value is discarded."""

    @transition(source=["draft", "published"], target="archived")
    def archive(self):
        ...


post = BlogPost()
post.publish.can_proceed()   # True — we're in 'draft'
post.publish.set()           # state is now 'published'
post.publish.set()           # raises InvalidSourceStateError

source accepts a single state, a list of states, "*" (any state), or None (matches a nullable column's NULL).

Transition API

For a transition decorated as BlogPost.publish:

Expression Returns
BlogPost.publish() SQLAlchemy filter for rows in the transition's target state — use in .filter(...).
BlogPost.publish.is_(True) Equivalent to BlogPost.publish() == True.
post.publish() bool — is this instance currently in the target state?
post.publish.set(*args, **kwargs) Execute the transition. Raises InvalidSourceStateError if the current state isn't allowed, or PreconditionError if any condition returns falsy.
post.publish.can_proceed(*args, **kwargs) bool — would set() succeed right now?

set() mutates the field in memory; commit the session yourself to persist.

Note: target=None is not supported — every transition must declare an explicit target state.

Conditions

Pass callables to conditions to gate the transition. Each is called with the instance (plus any args forwarded from set() / can_proceed()) and must return truthy.

def can_publish(instance) -> bool:
    return datetime.now().hour <= 17

class BlogPost(Base):
    ...
    @transition(source="draft", target="published", conditions=[can_publish])
    def publish(self):
        ...

# can_proceed() must receive the same args you'd pass to set():
post.publish.can_proceed()   # checks conditions without mutating
post.publish.set()

Conditions must be side-effect-free — can_proceed() evaluates them too.

Class-grouped transitions

To branch on the source state with different handlers, decorate a class:

@transition(target="published")
class publish:
    @transition(source="draft")
    def from_draft(self, instance):
        instance.published_via = "fresh"

    @transition(source="archived")
    def from_archive(self, instance):
        instance.published_via = "republish"

Invocation is still post.publish.set() — the right sub-handler is picked by the current state.

Query helpers

Use the class-bound form inside .filter():

session.query(BlogPost).filter(BlogPost.publish())          # currently 'published'
session.query(BlogPost).filter(~BlogPost.publish())         # everything else

Events

The library hooks into SQLAlchemy's event system and emits before_state_change and after_state_change per transition:

from sqlalchemy.event import listens_for

@listens_for(BlogPost, "after_state_change")
def on_change(instance, source, target):
    ...

Remove with sqlalchemy.event.remove(...).

Type checking

The package ships type information (PEP 561 py.typed). pyright / mypy pick up annotations automatically once installed.

Development

pdm install                                 # project + dev deps
pdm run pytest                              # tests
pdm run ruff check ./src ./tests            # lint
pdm run ruff format --check ./src ./tests   # format check
pdm run pyright                             # type check

Releasing

Tagged commits drive releases:

git tag v2.1.0
git push --follow-tags

CI runs the matrix, pdm-backend derives the version from the tag, the artifacts are Sigstore-signed and published to TestPyPI then PyPI via OIDC trusted publishing. A GitHub Release is created with notes from CHANGELOG.md.

How does this differ from django-fsm?

  • Cannot commit data from inside a transition handler.
  • Condition callables accept arguments forwarded from set().

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sqlalchemy_fsm-2.2.0.tar.gz (23.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqlalchemy_fsm-2.2.0-py3-none-any.whl (14.6 kB view details)

Uploaded Python 3

File details

Details for the file sqlalchemy_fsm-2.2.0.tar.gz.

File metadata

  • Download URL: sqlalchemy_fsm-2.2.0.tar.gz
  • Upload date:
  • Size: 23.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sqlalchemy_fsm-2.2.0.tar.gz
Algorithm Hash digest
SHA256 0dbbafd8c5ff517a51c9f5a4d244bdfe93b5c5c476acb6d4e4aa90dc3da450f6
MD5 c05054144f22f73e519f09f87d6be803
BLAKE2b-256 48c18a03a0bb3e75ed06486af6218c13cf9204073b57d78d6777b4a52011f222

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqlalchemy_fsm-2.2.0.tar.gz:

Publisher: release.yml on IljaOrlovs/sqlalchemy-fsm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sqlalchemy_fsm-2.2.0-py3-none-any.whl.

File metadata

  • Download URL: sqlalchemy_fsm-2.2.0-py3-none-any.whl
  • Upload date:
  • Size: 14.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sqlalchemy_fsm-2.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0d2c158084e979c1841099fb84b9b5c38df13d3803edcd84378c4f828958aa69
MD5 68cc40bd8534d21387d803c9dc6afeea
BLAKE2b-256 d540049e07557f97bad8878851d43c5c5c72d5bfecb3016f6872b1eec29c9036

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqlalchemy_fsm-2.2.0-py3-none-any.whl:

Publisher: release.yml on IljaOrlovs/sqlalchemy-fsm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page