ufsm generate python turns a model into one fully typed Python module
for Python 3.8 or newer. It passes pyright --strict and imports only
enum and typing from the standard library, so there is nothing to
install.
Behaviour is defined by semantics.md; the generated code is
a direct print of the IR (ufsm ir <model> shows it as pseudo-code), the
same IR the C backend prints, and it is tested against the
reference interpreter (see Testing).
The generated code may be used under any terms (output exception); uFSM itself is GPL-3.0-or-later.
npm run ufsm -- generate python examples/dimmer.ufsm -o build/
# wrote build/dimmer.pyThe model must pass ufsm check without errors. Settings come from the
model's targets.python section and can be overridden on the command line:
targets.python key |
Flag | Default | Meaning |
|---|---|---|---|
className |
--class-name |
model name in PascalCase (test_guard1 → TestGuard1) |
Machine class; also names <Class>Event, <Class>Handler and <Class>Error |
module |
model name as an identifier | File name: <module>.py |
|
out |
-o |
current directory | Output folder, relative to the model's file or absolute (targets) |
Unknown keys, and the behaviour options below, are reported as warnings and ignored there.
The model's options section sets what every target
shares; the flags override it for one run:
options key |
Flag | Default | Meaning |
|---|---|---|---|
signalQueue |
--signal-queue |
16 | Capacity of the signal queue (only generated if the model emits signals) |
maxMicrosteps |
--max-microsteps |
10000 | Microsteps per reset/process call before a livelock error; 0 removes the check |
debugInfo |
--debug-info |
false | Name lookup and an is-active query (Debugging) |
trace |
--trace |
false | A trace hook the application implements (Debugging) |
signalQueue and maxMicrosteps change behaviour, so they are set in
options only, never per target: a model behaves the same in every language
and in the simulator, queue overflows included. debugInfo and trace change
only the generated API, so targets.python may override them for this language.
For examples/dimmer.ufsm:
class DimmerError(Exception):
code: str # "queue-overflow", "livelock", "unknown-event", "not-started" or "busy"
class DimmerEvent(IntEnum):
eButton = 0
eOffButton = 1
class DimmerHandler(Protocol): # what the machine calls
def lamp_off(self) -> None: ...
def lamp_10_percent(self) -> None: ...
# ... one method per action; guards: def <guard>(self) -> bool: ...
class Dimmer:
def __init__(self, handler: DimmerHandler) -> None: ... # stores the handler; runs nothing yet
def reset(self) -> None: ... # start, or restart; no exit actions run
def process(self, event: DimmerEvent) -> None: ...- The handler is any object with one method per action and guard, named as
in the model and called without arguments:
handler.lamp_off().<Class>Handleris atyping.Protocol, so the handler does not inherit from anything; a type checker reports a missing or misspelled method where the machine is constructed. Guards return abool. They may be called more than once per event and in any order, so they should not have side effects. - The constructor does not start the machine, so the handler can keep a
reference to it before the first entry action runs. Call
reset()first;process()before that raisesnot-started. - Errors raise
<Class>Errorwith the interpreter's error code. After an error the machine is in an undefined state untilreset(). - An exception raised by an action or guard is not caught: it leaves
reset()/process()unchanged, half-way through a microstep. Until the nextreset(),process()then raisesbusy, as it does when an action or guard callsprocess()itself (that nested call changes nothing; the outer call goes on). See Failed and nested calls. - Events are an
IntEnumin declaration order.processalso accepts plain ints at run time (untyped callers), and rejects numbers outside the enum withunknown-event. - A declaration name that is a Python keyword gets a trailing underscore
(PEP 8): the event
notisTestGuardTermsEvent.not_, an actionpasswould behandler.pass_(). The generator stops with an error if that makes two names equal, or for names starting with__(Python would mangle them inside the class). - Signals are internal: actions emit them, and
processruns them (FIFO) after the event until the queue is empty.queue-overflowmeans more thansignalQueuesignals were waiting at once. - After a terminate pseudo-state is reached,
processdoes nothing untilreset(). - Everything else (
_cfg,_step_eButton, ...) is internal and may change between uFSM versions.
A model with timers gets a timer enum, two more
handler methods and a module function that gives the event to process when
a timer expires (none of this without timers). For door with timers
travel and poll:
class DoorTimer(IntEnum):
travel = 0 # after 20000 ms -> eTravelTimeout
poll = 1 # every 500 ms -> ePoll
def door_timer_event(timer: DoorTimer) -> DoorEvent: ... # <module>_timer_event
class DoorHandler(Protocol):
def timer_start(self, timer: DoorTimer, ms: int, periodic: bool) -> None: ...
def timer_stop(self, timer: DoorTimer) -> None: ...
# ... actions and guardsThe handler runs the clock (an asyncio task, threading.Timer, a GUI
toolkit's timer, ...) and, on expiry, calls
machine.process(door_timer_event(timer)) from the thread that owns the
machine. With debugInfo, Door.TIMER_NAMES names them. An action or guard
named timer_start or timer_stop clashes with the handler's methods. The
contract:
- Stopping a timer that is not running does nothing.
- A periodic timer is started once; the application re-arms it every period.
- After a stop returns, no expiry of the earlier start should be delivered. One that is already queued arrives as an ordinary event and is handled as such (in a state that does not react to it, nothing happens).
- Start and stop run inside
reset()andprocess(): they must not block or call the machine.reset()stops every timer first, whatever runs.
See Timers for when the machine starts and stops them.
Always generated: a state enum (normal and final states in document order,
named after the states; a name that repeats is qualified with its parent
states, and free text is made an identifier: 10% becomes s10_) and the
model's fingerprint, which identifies how events, signals, states and
transitions are numbered (ufsm info prints it for a model file; see
Numbering and trace hooks). A host
that decodes ids should check it.
class DimmerState(IntEnum):
Off = 0
On = 1
s10_ = 2 # 10%
# ...
class Dimmer:
FINGERPRINT = 0xc57ac6eaWith debugInfo, the machine class gets the names as in the model, by number,
and an is-active query:
EVENT_NAMES: Tuple[str, ...] = ("eButton", "eOffButton")
SIGNAL_NAMES: Tuple[str, ...] = ()
STATE_NAMES: Tuple[str, ...] = ("Off", "On", "10%", "30%", "50%", "100%")
def is_active(self, state: DimmerState) -> bool: ...With trace, DimmerTraceKind (event, signal, transition, enter,
exit) and one more handler method, called with ids only (the transition
numbers are listed in a comment in the class; a DimmerSignal enum is
generated when the model has signals):
class DimmerHandler(Protocol):
# ... actions and guards ...
def trace(self, kind: DimmerTraceKind, id: int) -> None: ...trace runs inside reset() and process(), so it must not block or call the
machine. An action named trace clashes with it while trace is on.
from dimmer import Dimmer, DimmerEvent
class Lamp: # implements DimmerHandler
def __init__(self) -> None:
self.percent = 0
def lamp_off(self) -> None:
self.percent = 0
def lamp_10_percent(self) -> None:
self.percent = 10
def lamp_30_percent(self) -> None:
self.percent = 30
def lamp_50_percent(self) -> None:
self.percent = 50
def lamp_100_percent(self) -> None:
self.percent = 100
lamp = Lamp()
machine = Dimmer(lamp)
machine.reset() # Off
machine.process(DimmerEvent.eButton) # On, 10%
machine.process(DimmerEvent.eButton) # 30%
machine.process(DimmerEvent.eOffButton) # Off
machine.process(DimmerEvent.eButton) # On again: history restores 30%
print("lamp at %d%%" % lamp.percent) # lamp at 30%The machine class starts with a table of regions and their states (the
value of _cfg[region] for each state). Its private methods are the IR
functions: _enter_rN, _exit_rN, _record_rN, _restore_rN,
_step_<event> / _step_<signal> / _eventless (one microstep; they
return whether one ran) and _settle. Errors are exceptions, so unlike in
C no return codes are passed around. The constructor builds _on_event and
_on_signal, tuples of bound step methods indexed by event and signal
number.
packages/core/test/backend-python.test.ts needs Python 3.8 or newer
($PYTHON, else python3, python or py -3; the tests skip without
one, and fail instead when UFSM_REQUIRE_PYTHON=1). It runs many generated
machines in one Python process and checks that:
- all conformance traces in
tests/traces/pass; - on random event sequences (with random guard values), every repo model,
300 random models (
UFSM_PY_RANDOM=<n>for more) and 100 random models with a 2-slot queue call exactly the same actions, end in the same states and fail with the same errors as the reference interpreter; - the modules parse with the Python 3.8 grammar and import only
enumandtyping; - the modules, and the example above, pass
pyright --strictwith Python 3.8 rules, while a caller with an incomplete handler or a plain int event is rejected (needspyrighton PATH or$PYRIGHT; skipped without it, required whenUFSM_REQUIRE_PYRIGHT=1); - the example above prints
lamp at 30%.