Skip to content

polyspec-fsm-python ​

English

fsm의 python 소비자(docs/plans/fsm-v1.md, C-2)다. 패키지는 비공개이며 test bed이고 공개하지 않는다. fsm 정의로부터 생성한 python 코드를 어떻게 쓰는지 보여주고, 그 코드를 conformance vector로 검사한다. 생성 코드는 표준 라이브러리만 쓴다.

프로젝트에 생성 ​

프로젝트의 패키지 디렉터리에 쓴다. 생성된 module은 상대 import로 서로를 가져오므로 디렉터리 이름은 아무거나 된다:

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

정의를 바꿀 때마다 명령을 다시 실행한다. vector는 fsm-gen vectors --kind python --out <file>로 자신의 test module에 쓴다(test bed는 tests/vectors.py에 쓴다). 생성 코드는 Python 3.11 이상이 필요하다(StrEnum, match). checkout은 config/toolchain.json이 선언한 Python 3.14로 테스트를 실행한다.

생성 파일 ​

파일내용
__init__.py패키지: from . import order, fsm과 __all__
fsm.py공유 module fsm: StrEnum Code(CONFLICT = "E_CONFLICT", INVALID_TRANSITION, UNKNOWN_CODE, RESERVED_CODE)와 예외 Error
order.py기계마다 하나인 module: IntEnum 클래스 State(값이 code, member는 대문자: DRAFT)와 Event(위치, 1부터: NEXT), frozen dataclass Step, 함수

각 파일은 # Code generated by fsm-gen from <machine>; DO NOT EDIT.로 시작한다.

API ​

패키지는 module을 내보낸다(위 예제 디렉터리라면 from order_fsm import order, fsm). order의 함수(order.py):

함수결과
initial() -> State초기 상태
code(state) -> int상태의 code
key(state) -> str상태의 key, 예: draft
from_code(value) -> Statecode의 상태. 상태가 아닌 값은 E_UNKNOWN_CODE를 던진다
is_final(state) -> bool상태가 최종 상태인지
events_from(state) -> list[Event]상태에서 edge가 있는 event, 선언 순서
target(expected, event) -> Stateevent가 이르는 상태. edge가 없으면 E_INVALID_TRANSITION을 던진다
apply(expected, event, actual) -> Step한 번 시도의 결과(루트 README의 전이 절 참고)

Step은 to와 changed 필드를 가진다.

영속화 예제 examples/persist.py는 transaction 안에서 apply를 완전하게 쓰는 예이며, 따라 할 패턴이다(영속화 절). 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)

이 program은 checkout의 .venv의 Python 3.14로, 위 명령이 ./app/order_fsm에 쓴 패키지로 실행했다. module 경로에는 app/을 넣었다(PYTHONPATH=app). 출력은 다음과 같다:

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

오류 ​

함수는 fsm.Error(fsm.py)를 던진다. Exception의 하위 class다:

속성값
codeCode member이며 str이다: "E_CONFLICT", "E_INVALID_TRANSITION", "E_UNKNOWN_CODE" 또는 "E_RESERVED_CODE"(fsm.Code.CONFLICT는 "E_CONFLICT"와 같다)
expected기대한 상태 code, 또는 None
actual저장된 상태 code, 또는 None
eventevent 위치(1부터), 또는 None

메시지는 예외의 첫 인자이며 기계 이름을 담는다.

속성이 어떤 code에서 설정되는지는 명세 B-6(docs/spec/fsm.md)의 규칙이며, tests/vectors.py의 vector가 이를 단언한다:

Codeexpectedactualevent
E_CONFLICT설정됨설정됨설정됨
E_INVALID_TRANSITION설정됨None설정됨
E_UNKNOWN_CODE, E_RESERVED_CODENoneNoneNone

E_UNKNOWN_CODE에서는 메시지가 값을 담는다(order: 99 is not a state of order).

영속화 ​

examples/persist.py는 SQLite, PostgreSQL, MySQL과 polyspec orm 위에서 docs/spec/fsm.md, B-4의 패턴이다. 저장된 상태는 row의 code다. 전이는 한 transaction 안에서 실행한다. row를 잠금(for_update()) 아래 읽고, event를 호출자가 관찰한 상태에 적용하며, step이 changed일 때만 row를 갱신한다:

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은 기계의 module이므로, 같은 함수가 모든 기계에 쓰인다. apply의 오류는 transaction 안에서 던져져 transaction을 되돌린다. 같은 expected로 다시 시도하면 changed=False가 되어 아무것도 쓰지 않는다. 같은 파일의 open_database, seed, fsm_of는 table을 만들고 row를 읽는다. 모델 Item은 orm-gen이 contracts/schema/persist.dbs로부터 examples/model/에 생성한다.

테스트 ​

python 패키지는 가상환경의 Python 3.14가 필요하다. checkout 루트에서 실행한다:

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)

실행 결과는 290개 case 중 290개 통과다. tests/test_conformance.py는 vector case마다 unittest method 하나를 test_<feature>__<id> 이름으로 만들고, 생성 함수나 오류 code의 결과를 단언한다. 생성 파일은 commit되어 있으므로 test는 생성기를 필요로 하지 않는다. make -C packages/python gen이 다시 생성하며, 루트의 make gen-check가 최신인지 검사한다. make parity-python은 모든 case의 결과 기록을 검사한다. uv.lock이 트리의 모든 package를 잠근다(정확한 버전, sha256 hash, git commit). make python-lock-check가 이를 pyproject.toml과 대조하고, kit gate가 읽는 PEP 751 export pylock.toml이 현재 export인지 검사한다.