Appearance
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_fsmtext
[fsm-gen] gen: okRun 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
| File | Content |
|---|---|
__init__.py | the package: from . import order, fsm and __all__ |
fsm.py | the shared module fsm: the StrEnum Code (CONFLICT = "E_CONFLICT", INVALID_TRANSITION, UNKNOWN_CODE, RESERVED_CODE) and the exception Error |
order.py | one 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):
| Function | Result |
|---|---|
initial() -> State | the initial state |
code(state) -> int | the code of a state |
key(state) -> str | the key of a state, such as draft |
from_code(value) -> State | the state of a code; a value that is no state raises E_UNKNOWN_CODE |
is_final(state) -> bool | whether a state is final |
events_from(state) -> list[Event] | the events that have an edge from a state, in declaration order |
target(expected, event) -> State | the state that the event leads to; raises E_INVALID_TRANSITION when it has no edge |
apply(expected, event, actual) -> Step | the 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 1Errors
The functions raise fsm.Error (fsm.py), an Exception:
| Attribute | Value |
|---|---|
code | a 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") |
expected | the expected state code, or None |
actual | the stored state code, or None |
event | the 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:
| Code | expected | actual | event |
|---|---|---|---|
E_CONFLICT | set | set | set |
E_INVALID_TRANSITION | set | None | set |
E_UNKNOWN_CODE, E_RESERVED_CODE | None | None | None |
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.