Skip to content

polyspec-fsm-python ​

한국어

The python consumer of fsm (docs/plans/fsm-v1.md, C-2). The package is private and is a test bed: it is not published. It shows how the generated python code of a fsm definition is used, and it checks that code against the conformance vectors. The generated code uses the standard library only.

Generate into your project ​

Generate into a package directory of your project. The generated modules import each other with relative imports, so the directory can have any name:

sh
fsm-gen gen --project /path/to/order-machines --kind python --out ./app/order_fsm
text
[fsm-gen] gen: ok

Run the command again after every change of the definition. The vectors come from fsm-gen vectors --kind python --out <file>, written into a test module of your own (the test bed writes tests/vectors.py). The generated code needs Python 3.11 or later (StrEnum, match); the checkout runs its tests with Python 3.14, which config/toolchain.json declares.

Generated files ​

FileContent
__init__.pythe package: from . import order, fsm and __all__
fsm.pythe shared module fsm: the StrEnum Code (CONFLICT = "E_CONFLICT", INVALID_TRANSITION, UNKNOWN_CODE, RESERVED_CODE) and the exception Error
order.pyone module per machine: the IntEnum classes State (the value is the code, members in upper case: DRAFT) and Event (the position from 1: NEXT), the frozen dataclass Step, and the functions

Each file starts with # Code generated by fsm-gen from <machine>; DO NOT EDIT..

API ​

The package exports the modules (from order_fsm import order, fsm, for the directory of the example above). The functions of order (order.py):

FunctionResult
initial() -> Statethe initial state
code(state) -> intthe code of a state
key(state) -> strthe key of a state, such as draft
from_code(value) -> Statethe state of a code; a value that is no state raises E_UNKNOWN_CODE
is_final(state) -> boolwhether a state is final
events_from(state) -> list[Event]the events that have an edge from a state, in declaration order
target(expected, event) -> Statethe state that the event leads to; raises E_INVALID_TRANSITION when it has no edge
apply(expected, event, actual) -> Stepthe result of one attempt (see the root README, Transitions)

Step has the fields to and changed.

The persistence example examples/persist.py holds the complete apply use inside a transaction; it is the pattern to copy (see Persistence). The pure call for a value that is not in a database:

python
from order_fsm import order, fsm

step = order.apply(order.State.DRAFT, order.Event.NEXT, order.State.DRAFT)
print(step)
try:
    order.apply(order.State.DRAFT, order.Event.NEXT, order.State.SHIPPED)
except fsm.Error as error:
    print(error.code, error.expected, error.actual, error.event)

This program was run with the Python 3.14 of the checkout's .venv on the package that the command above writes into ./app/order_fsm, with app/ on the module path (PYTHONPATH=app). Its output:

text
Step(to=<State.PAID: 2>, changed=True)
E_CONFLICT 1 3 1

Errors ​

The functions raise fsm.Error (fsm.py), an Exception:

AttributeValue
codea Code member, which is a str: "E_CONFLICT", "E_INVALID_TRANSITION", "E_UNKNOWN_CODE" or "E_RESERVED_CODE" (fsm.Code.CONFLICT compares equal to "E_CONFLICT")
expectedthe expected state code, or None
actualthe stored state code, or None
eventthe event position (from 1), or None

The message is the first argument of the exception and names the machine.

Which attributes a code sets is the rule of spec B-6 (docs/spec/fsm.md), and the vectors of tests/vectors.py assert it:

Codeexpectedactualevent
E_CONFLICTsetsetset
E_INVALID_TRANSITIONsetNoneset
E_UNKNOWN_CODE, E_RESERVED_CODENoneNoneNone

For E_UNKNOWN_CODE the message holds the value (order: 99 is not a state of order).

Persistence ​

examples/persist.py is the pattern of docs/spec/fsm.md, B-4 with polyspec orm on SQLite, PostgreSQL and MySQL (the DSN given to open_database selects the dialect). The stored state is a code in a row. A transition runs in one transaction: the row is read under its lock (for_update()), the event is applied to the state that the caller observed, and the row is updated only when the step changed:

python
# packages/python/examples/persist.py, transition
def transition(db, machine, row_id, expected, event):
    """One attempt of the transition `event` from the observed state `expected` on the row `row_id`.

    `machine` is the generated module of the machine (src/polyspec_fsm/<machine>.py). It returns the Step of apply. An error
    of apply (E_CONFLICT, E_INVALID_TRANSITION or E_UNKNOWN_CODE) is raised, and the transaction rolls back, so the stored state
    stays as it was.
    """

    def attempt():
        row = Item().connect(db).id(row_id).for_update().get()
        step = machine.apply(expected, event, machine.from_code(row.get_status()))
        if step.changed:
            row.set_status(machine.code(step.to)).update()
        return step

    return db.transaction(attempt)

machine is the module of the machine, so the same function serves every machine. An error of apply is raised inside the transaction and rolls it back. A retry with the same expected returns changed=False and writes nothing. open_database, seed and fsm_of in the same file create the tables and read a row. The model Item is generated by orm-gen from contracts/schema/persist.dbs into examples/model/.

Tests ​

The python package needs Python 3.14 in a virtual environment. From the checkout root:

sh
make install-tools TOOLS=python            # online: Python 3.14 of pythonBuild in config/toolchain.json, into var/tools (var/tools/bin/python3; no system path changes)
make -C packages/python install    # online: .venv, made by the kit tool (make python-venv) from the install-tools Python 3.14 as a copy, filled from pylock.toml (kit's make python-lock: orm v0.0.18 and ordered-json v0.0.9 from their release wheels, every artifact by sha256)
make test-python                       # offline: the vector cases and the persist cases, run by the kit runner with --venv .venv (no PATH change)
make python-persist-test-postgres # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make python-persist-test-mysql    # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)

The run passes 290 of 290 cases. tests/test_conformance.py generates one unittest method per vector case, named test_<feature>__<id>, and asserts the result of the generated function or its error code. The generated files are committed, so test needs no generator; make -C packages/python gen regenerates them, and the root make gen-check checks that they are current. make parity-python checks the result record of every case. uv.lock locks every package of the tree (exact versions, sha256 hashes, git commits), and make python-lock-check checks it against pyproject.toml, and that pylock.toml, the PEP 751 export the kit gate reads, is its current export.