CLI Templates Deep Dive
All 5 primary templates and 5 companion templates explained with complete generated output.
The xsm CLI offers five primary code generation templates, each producing a different code structure and API style, plus five companion templates (pytest, typed, plugin, pydantic-models, fastapi-router) that add a test module, a typed-context module, a plugin skeleton, pydantic models or a FastAPI router alongside any of them. This guide shows the complete generated output for each template using the same input JSON, so you can compare them side-by-side and choose the best fit for your project.
🧩 Template Overview
flowchart LR
J["📄 machine.json"] --> T{"--template"}
T --> A["pythonic-class"] & B["pythonic-builder"] & C["pythonic-functional"]
T --> D["class-json"] & E["function-json"]
A & B & C --> P["🐍 pure Python — JSON discarded"]
D & E --> Q["📎 Python + JSON kept side-by-side"]
T -. "--with-tests / --with-types / --with-plugin / --with-models / --with-api" .-> F["🧪 pytest · 🔤 typed · 🔌 plugin · 📐 pydantic · 🌐 fastapi"]
| Template | API Style | JSON at Runtime? | Logic Pattern | Default Mode |
|---|---|---|---|---|
pythonic-class |
StateMachine subclass |
No | Class methods with decorators | sync |
pythonic-builder |
MachineBuilder chain |
No | Module-level functions with decorators | sync |
pythonic-functional |
build_machine() call |
No | Module-level functions with decorators | sync |
class-json |
Class-based provider | Yes | Class methods (camelCase) | async |
function-json |
Module functions | Yes | Module-level functions | async |
Every example below assumes the default -fc/--file-count 2 (separate checkout_logic.py and checkout_runner.py files). Pass --file-count 1 (or -fc 1) to combine both into a single generated file instead.
Generated files also always start with a """Generated state machine logic — DO NOT EDIT BY HAND.""" docstring banner that embeds the source JSON filename, the template name, and the generator version, plus a Regenerate with:: snippet showing the exact command to reproduce the file. That banner is omitted from the code blocks on this page purely for brevity — the actual output on disk always includes it.
📄 Input JSON Example
All examples below use this checkout.json as input:
{
"id": "checkout",
"initial": "cart",
"context": {
"items": [],
"total": 0
},
"states": {
"cart": {
"on": {
"SUBMIT": {
"target": "payment",
"guard": "cartNotEmpty",
"actions": "calculateTotal"
}
}
},
"payment": {
"invoke": {
"src": "processPayment",
"onDone": {
"target": "confirmed",
"actions": "clearCart"
},
"onError": {
"target": "cart",
"actions": "showError"
}
}
},
"confirmed": {
"type": "final"
}
}
}
This machine has:
- 3 states:
cart,payment,confirmed(final) - 1 guard:
cartNotEmpty - 3 actions:
calculateTotal,clearCart,showError - 1 service:
processPayment(viainvoke) - 1 event:
SUBMIT
🚩 Additional generate-template flags
The examples on this page focus on --template/-t and --async-mode/-am, but generate-template (alias gt) accepts several other flags worth knowing about:
--with-tests,--with-types,--with-plugin— Also emit thepytest,typedandplugincompanion modules described in Companion templates below. Any combination; they never replace the primary output.--with-models,--with-api— Also emit thepydantic-models(<machine>_models.py) andfastapi-router(<machine>_api.py) companions for the[pydantic]/[fastapi]extras.--fixtures— With thepytestcompanion only: emit the@pytest.mark.xstate_machinemarker and thexsm_interp/xsm_clock/xsm_ranfixtures of the[testing]extra instead of building the interpreter and the stub logic by hand in the module (pip install "xstate-statemachine[testing]"; see Integration testing).-fc,--file-count {1,2}— Number of output files:1(combined) or2(logic/runner). Default:2. This page’s “Generated Logic File” / “Generated Runner File” pairs all assume the default of2; pass--file-count 1to get a single combined file instead.--no-verify— Skip the structural check that generated code rebuilds the source machine. Syntax is still validated. Use only to inspect output the generator would otherwise refuse to write.--check— Do not write anything. Exit with status 1 if the files on disk differ from what would be generated. Intended for CI, so generated code can be committed and kept honest.--diff— Like--check, but also print a unified diff of the differences. Implies--check.--log LOG— Include logging statements in the generated code:yesorno. Default:yes.--sleep SLEEP— Add a sleep call between events in the generated runner’s simulation:yesorno. Default:yes.--sleep-time SLEEP_TIME— Sleep duration in seconds for the simulation. Default:2.
🏛️ Template 1: pythonic-class
The pythonic-class template generates a StateMachine subclass whose State() attributes carry their own transitions via on=, plus @action / @guard / @service decorated methods. No JSON is needed at runtime — the machine definition is compiled directly into Python.
Command
xsm gt checkout.json --template pythonic-class --async-mode no
Generated Logic File: checkout_logic.py
from typing import Any, Dict, Union
from xstate_statemachine import (
StateMachine,
State,
Interpreter,
SyncInterpreter,
action,
guard,
service,
)
import logging
logger = logging.getLogger(__name__)
class CheckoutMachine(StateMachine):
"""Checkout state machine using the declarative class-based API."""
machine_id = "checkout"
initial_context = {
"items": [],
"total": 0,
}
cart = State(
"cart",
initial=True,
on={
"SUBMIT": {
"target": "payment",
"guard": "cartNotEmpty",
"actions": ["calculateTotal"],
},
},
)
payment = State(
"payment",
invoke={
"src": "processPayment",
"onDone": {
"target": "confirmed",
"actions": ["clearCart"],
},
"onError": {
"target": "cart",
"actions": ["showError"],
},
},
)
confirmed = State("confirmed", final=True)
# Actions
@action
def calculate_total(
self,
interpreter: Union[Interpreter, SyncInterpreter],
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""
Action handler for ``calculateTotal``.
Called when the SUBMIT transition fires.
"""
try:
logger.info("Executing action: calculateTotal")
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action calculateTotal")
raise
@action
def clear_cart(
self,
interpreter: Union[Interpreter, SyncInterpreter],
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""
Action handler for ``clearCart``.
Called when processPayment completes successfully.
"""
try:
logger.info("Executing action: clearCart")
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action clearCart")
raise
@action
def show_error(
self,
interpreter: Union[Interpreter, SyncInterpreter],
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""
Action handler for ``showError``.
Called when processPayment encounters an error.
"""
try:
logger.info("Executing action: showError")
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action showError")
raise
# Guards
@guard
def cart_not_empty(
self,
context: Dict[str, Any],
event: Any,
) -> bool:
"""
Guard for ``cartNotEmpty``.
Returns True to allow the transition, False to block it.
"""
logger.info("Evaluating guard: cartNotEmpty")
# TODO: implement guard logic
return True
# Services
@service
def process_payment(
self,
interpreter: Union[Interpreter, SyncInterpreter],
context: Dict[str, Any],
event: Any,
) -> Dict[str, Any]:
"""
Service handler for ``processPayment``.
Returns a dict that becomes the onDone event data.
"""
try:
logger.info("Running service: processPayment")
# TODO: implement service logic
return {
"result": "done",
}
except Exception:
logger.exception("Error in service processPayment")
raise
Generated Runner File: checkout_runner.py
import logging
from xstate_statemachine import SyncInterpreter
from checkout_logic import CheckoutMachine
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def main() -> None:
"""Executes the simulation for the checkout machine."""
machine = CheckoutMachine.create_machine()
# Interpreter Setup
interpreter = SyncInterpreter(machine)
interpreter.start()
logger.info(f'Initial state: {interpreter.current_state_ids}')
# Event Simulation
logger.info('Sending event: %s', 'SUBMIT')
interpreter.send("SUBMIT")
interpreter.stop()
if __name__ == "__main__":
main()
Key Feature: The runner calls
CheckoutMachine.create_machine()— no JSON loading needed. The machine definition is compiled from the class attributes at runtime.
🔗 Template 2: pythonic-builder
The pythonic-builder template generates module-level @action, @guard, @service decorated functions (no self parameter) and a build() function that uses the MachineBuilder fluent API to construct the machine.
Command
xsm gt checkout.json --template pythonic-builder --async-mode no
Generated Logic File: checkout_logic.py
from typing import Any, Dict
from xstate_statemachine import (
MachineBuilder,
SyncInterpreter,
action,
guard,
service,
)
import logging
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------
# Actions
# -----------------------------------------------------------------------
@action
def calculate_total(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``calculateTotal``."""
logger.info("Executing action: calculateTotal")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action calculateTotal")
raise
@action
def clear_cart(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``clearCart``."""
logger.info("Executing action: clearCart")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action clearCart")
raise
@action
def show_error(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``showError``."""
logger.info("Executing action: showError")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action showError")
raise
# -----------------------------------------------------------------------
# Guards
# -----------------------------------------------------------------------
@guard
def cart_not_empty(
context: Dict[str, Any],
event: Any,
) -> bool:
"""Guard for ``cartNotEmpty``."""
logger.info("Evaluating guard: cartNotEmpty")
# TODO: implement guard logic
return True
# -----------------------------------------------------------------------
# Services
# -----------------------------------------------------------------------
@service
def process_payment(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
) -> Dict[str, Any]:
"""Service handler for ``processPayment``."""
logger.info("Running service: processPayment")
try:
# TODO: implement service logic
return {
"result": "done",
}
except Exception:
logger.exception("Error in service processPayment")
raise
def build() -> Any:
"""Build the checkout machine using MachineBuilder."""
machine = (
MachineBuilder("checkout")
.context(
{
"items": [],
"total": 0,
}
)
.state("cart", initial=True)
.state(
"payment",
invoke={
"src": "processPayment",
"onDone": {
"target": "confirmed",
"actions": "clearCart",
},
"onError": {
"target": "cart",
"actions": "showError",
},
},
)
.state("confirmed")
.transition(
"cart",
"SUBMIT",
"payment",
actions=["calculateTotal"],
guard="cartNotEmpty",
)
.action("calculateTotal", calculate_total)
.action("clearCart", clear_cart)
.action("showError", show_error)
.guard("cartNotEmpty", cart_not_empty)
.service("processPayment", process_payment)
.build()
)
return machine
Generated Runner File: checkout_runner.py
import logging
from xstate_statemachine import SyncInterpreter
import checkout_logic
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def main() -> None:
"""Executes the simulation for the checkout machine."""
machine = checkout_logic.build()
# Interpreter Setup
interpreter = SyncInterpreter(machine)
interpreter.start()
logger.info(f'Initial state: {interpreter.current_state_ids}')
# Event Simulation
logger.info('Sending event: %s', 'SUBMIT')
interpreter.send("SUBMIT")
interpreter.stop()
if __name__ == "__main__":
main()
Key Feature: The runner calls
checkout_logic.build(), which uses theMachineBuilderfluent chain internally. Functions are registered by name via.action("calculateTotal", calculate_total).
🐍 Template 3: pythonic-functional
The pythonic-functional template generates module-level decorated functions and a build() function that creates State objects — each carrying its own transitions via on= — and assembles the machine with build_machine().
Command
xsm gt checkout.json --template pythonic-functional --async-mode no
Generated Logic File: checkout_logic.py
from typing import Any, Dict
from xstate_statemachine import (
State,
build_machine,
SyncInterpreter,
action,
guard,
service,
)
import logging
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------
# Actions
# -----------------------------------------------------------------------
@action
def calculate_total(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``calculateTotal``."""
logger.info("Executing action: calculateTotal")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action calculateTotal")
raise
@action
def clear_cart(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``clearCart``."""
logger.info("Executing action: clearCart")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action clearCart")
raise
@action
def show_error(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
action_def: Any,
) -> None:
"""Action handler for ``showError``."""
logger.info("Executing action: showError")
try:
# TODO: implement action logic
pass
except Exception:
logger.exception("Error in action showError")
raise
# -----------------------------------------------------------------------
# Guards
# -----------------------------------------------------------------------
@guard
def cart_not_empty(
context: Dict[str, Any],
event: Any,
) -> bool:
"""Guard for ``cartNotEmpty``."""
logger.info("Evaluating guard: cartNotEmpty")
# TODO: implement guard logic
return True
# -----------------------------------------------------------------------
# Services
# -----------------------------------------------------------------------
@service
def process_payment(
interpreter: SyncInterpreter,
context: Dict[str, Any],
event: Any,
) -> Dict[str, Any]:
"""Service handler for ``processPayment``."""
logger.info("Running service: processPayment")
try:
# TODO: implement service logic
return {
"result": "done",
}
except Exception:
logger.exception("Error in service processPayment")
raise
def build() -> Any:
"""Build the checkout machine (functional style)."""
cart = State(
"cart",
initial=True,
on={
"SUBMIT": {
"target": "payment",
"guard": "cartNotEmpty",
"actions": ["calculateTotal"],
},
},
)
payment = State(
"payment",
invoke={
"src": "processPayment",
"onDone": {
"target": "confirmed",
"actions": ["clearCart"],
},
"onError": {
"target": "cart",
"actions": ["showError"],
},
},
)
confirmed = State("confirmed", final=True)
return build_machine(
id="checkout",
states=[cart, payment, confirmed],
context={
"items": [],
"total": 0,
},
actions=[calculate_total, clear_cart, show_error],
guards=[cart_not_empty],
services=[process_payment],
)
Generated Runner File: checkout_runner.py
import logging
from xstate_statemachine import SyncInterpreter
import checkout_logic
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def main() -> None:
"""Executes the simulation for the checkout machine."""
machine = checkout_logic.build()
# Interpreter Setup
interpreter = SyncInterpreter(machine)
interpreter.start()
logger.info(f'Initial state: {interpreter.current_state_ids}')
# Event Simulation
logger.info('Sending event: %s', 'SUBMIT')
interpreter.send("SUBMIT")
interpreter.stop()
if __name__ == "__main__":
main()
Key Feature: The
build()function creates individualStateobjects and callsbuild_machine()— the most explicit functional approach, with function references passed directly.Transitions are declared on the
Stateitself viaon=.State.to()returns aTransitionrather than registering one, so it must be passed tobuild_machine(transitions=[...]); the generator useson=to avoid that trap entirely.
📎 Template 4: class-json
The class-json template generates a class-based logic provider with method stubs. The JSON config is loaded at runtime — the machine definition stays in JSON. The LogicLoader auto-discovers methods by matching snake_case function names to camelCase JSON names.
Command
xsm gt checkout.json --template class-json --async-mode no
Generated Logic File: checkout_logic.py
import logging
import time
from typing import Any, Dict, Union
from xstate_statemachine import (
ActionDefinition,
Event,
Interpreter,
SyncInterpreter,
)
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------
# Class-based Logic
# -----------------------------------------------------------------------
class CheckoutLogic:
# Actions
def calculate_total(
self,
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``calculateTotal`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action calculateTotal")
# TODO: implement
except Exception:
logger.exception("Action 'calculateTotal' failed")
raise
def clear_cart(
self,
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``clearCart`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action clearCart")
# TODO: implement
except Exception:
logger.exception("Action 'clearCart' failed")
raise
def show_error(
self,
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``showError`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action showError")
# TODO: implement
except Exception:
logger.exception("Action 'showError' failed")
raise
# Guards
def cart_not_empty(
self,
context: Dict[str, Any],
event: Event,
) -> bool:
"""
Evaluate the ``cartNotEmpty`` guard.
Args:
context: Current machine context dictionary.
event: The event being evaluated.
"""
logger.info("Evaluating guard cartNotEmpty")
# TODO: implement guard logic
return True
# Services
def process_payment(
self,
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
) -> Dict[str, Any]:
"""
Run the ``processPayment`` service.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this service.
"""
try:
logger.info("Running service processPayment")
time.sleep(1)
# TODO: implement service
return {
"result": "done",
}
except Exception:
logger.exception("Service 'processPayment' failed")
raise
processPayment = process_payment # alias for JSON name
Note: The trailing
processPayment = process_paymentline is a real alias, not a typo —LogicLoaderlooks up service/action names by their exact JSON key (processPayment), so the generator adds a class attribute alias from thesnake_casemethod name to thecamelCaseJSON name. Thetime.sleep(1)call is a generated placeholder service body; replace it with real work. When--async-mode yes(the default for this template) is used instead, methods areasync defand the placeholder becomesawait asyncio.sleep(1).
Generated Runner File: checkout_runner.py
from pathlib import Path
import json
from xstate_statemachine import create_machine, SyncInterpreter
from xstate_statemachine import LoggingInspector
import logging
from checkout_logic import CheckoutLogic as LogicProvider
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def main() -> None:
"""Executes the simulation for the checkout machine."""
config_path = Path(r"checkout.json")
if not config_path.is_absolute() and config_path.parent == Path('.'):
here = Path(__file__).resolve().parent
candidate = here / config_path.name
config_path = candidate if candidate.exists() else here.parent / config_path.name
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# Logic Binding
logic_provider = LogicProvider()
machine = create_machine(config, logic_providers=[logic_provider])
# Interpreter Setup
interpreter = SyncInterpreter(machine)
interpreter.use(LoggingInspector())
interpreter.start()
logger.info(f'Initial state: {interpreter.current_state_ids}')
# Event Simulation
logger.info('Sending event: %s', 'SUBMIT')
interpreter.send('SUBMIT')
interpreter.stop()
if __name__ == '__main__':
main()
Key Feature: The runner loads
checkout.jsonat runtime and binds logic vialogic_providers=[LogicProvider()]. TheLogicLoaderauto-discovers methods matchingsnake_casetocamelCase.
📎 Template 5: function-json
The function-json template generates module-level function stubs (no class wrapper). The JSON config is loaded at runtime, and logic is bound via logic_modules=[module].
Command
xsm gt checkout.json --template function-json --async-mode no
Generated Logic File: checkout_logic.py
import logging
import time
from typing import Any, Dict, Union
from xstate_statemachine import (
ActionDefinition,
Event,
Interpreter,
SyncInterpreter,
)
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------
# Actions
# -----------------------------------------------------------------------
def calculate_total(
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``calculateTotal`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action calculateTotal")
# TODO: implement
except Exception:
logger.exception("Action 'calculateTotal' failed")
raise
def clear_cart(
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``clearCart`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action clearCart")
# TODO: implement
except Exception:
logger.exception("Action 'clearCart' failed")
raise
def show_error(
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
action_def: ActionDefinition,
) -> None:
"""
Execute the ``showError`` action.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this action.
action_def: Metadata about the action being executed.
"""
try:
logger.info("Executing action showError")
# TODO: implement
except Exception:
logger.exception("Action 'showError' failed")
raise
# -----------------------------------------------------------------------
# Guards
# -----------------------------------------------------------------------
def cart_not_empty(
context: Dict[str, Any],
event: Event,
) -> bool:
"""
Evaluate the ``cartNotEmpty`` guard.
Args:
context: Current machine context dictionary.
event: The event being evaluated.
"""
logger.info("Evaluating guard cartNotEmpty")
# TODO: implement guard logic
return True
# -----------------------------------------------------------------------
# Services
# -----------------------------------------------------------------------
def process_payment(
interpreter: Union[Interpreter[Any], SyncInterpreter[Any]],
context: Dict[str, Any],
event: Event,
) -> Dict[str, Any]:
"""
Run the ``processPayment`` service.
Args:
interpreter: The running interpreter instance.
context: Mutable machine context dictionary.
event: The event that triggered this service.
"""
try:
logger.info("Running service processPayment")
time.sleep(1)
# TODO: implement service
return {
"result": "done",
}
except Exception:
logger.exception("Service 'processPayment' failed")
raise
processPayment = process_payment # alias for JSON name
Note: The trailing
processPayment = process_paymentline is a real alias, not a typo —LogicLoaderlooks up service/action names by their exact JSON key (processPayment), so the generator adds a module-level alias from thesnake_casefunction name to thecamelCaseJSON name. Thetime.sleep(1)call is a generated placeholder service body; replace it with real work. When--async-mode yes(the default for this template) is used instead, functions areasync defand the placeholder becomesawait asyncio.sleep(1).
Generated Runner File: checkout_runner.py
from pathlib import Path
import json
from xstate_statemachine import create_machine, SyncInterpreter
from xstate_statemachine import LoggingInspector
import logging
import checkout_logic
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def main() -> None:
"""Executes the simulation for the checkout machine."""
config_path = Path(r"checkout.json")
if not config_path.is_absolute() and config_path.parent == Path('.'):
here = Path(__file__).resolve().parent
candidate = here / config_path.name
config_path = candidate if candidate.exists() else here.parent / config_path.name
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# Logic Binding
machine = create_machine(config, logic_modules=[checkout_logic])
# Interpreter Setup
interpreter = SyncInterpreter(machine)
interpreter.use(LoggingInspector())
interpreter.start()
logger.info(f'Initial state: {interpreter.current_state_ids}')
# Event Simulation
logger.info('Sending event: %s', 'SUBMIT')
interpreter.send('SUBMIT')
interpreter.stop()
if __name__ == '__main__':
main()
Key Feature: Logic binding uses
logic_modules=[checkout_logic]— theLogicLoaderscans the module for functions whosesnake_casenames match thecamelCasenames in JSON.
🧪 Companion templates
The companion templates each produce one extra module next to the primary output. Request them together with any primary template:
xsm gt checkout.json -t pythonic-class --with-tests --with-types --with-plugin
OK Generated logic file: checkout_logic.py
OK Generated runner file: checkout_runner.py
OK Generated pytest file: test_checkout.py
OK Generated typed file: checkout_types.py
OK Generated plugin file: checkout_observer.py
…or on their own (xsm gt checkout.json -t pytest). Like every other output they are compiled before writing, carry the provenance banner, and are covered by --check / --diff in CI.
pytest — a test module recorded from the engine
Nothing in this file is guessed. At generation time the chart is built with stub logic (actions record their name, guards return True, services no-op), driven along its reachable event sequence on a SimulatedClock, and the configuration after every step is written down as an assertion. The suite runs green the moment it is written and goes red the moment the JSON — or the library — behaves differently.
ACTIONS: List[str] = ["calculateTotal", "clearCart", "showError"]
GUARDS: List[str] = ["cartNotEmpty"]
SERVICES: List[str] = ["processPayment"]
def stub_logic(ran: List[str], guards_pass: bool = True) -> MachineLogic:
"""Stub every implementation: actions record, guards decide, services no-op."""
...
def test_initial_state(interp) -> None:
assert sorted(interp.current_state_ids) == ["checkout.cart"]
# Each step replays every step before it, so a test is self-contained and
# can be run alone. The recorded sequence is the machine's reachable walk.
STEPS = [
(
"event",
"SUBMIT",
None,
["checkout.confirmed"],
["calculateTotal", "clearCart"],
),
]
def test_step_01_SUBMIT(interp, clock: SimulatedClock, ran: List[str]) -> None:
_replay(interp, clock, 0)
ran.clear()
interp.send("SUBMIT")
assert sorted(interp.current_state_ids) == ["checkout.confirmed"]
assert ran == ["calculateTotal", "clearCart"]
def test_reaches_final_state(interp, clock: SimulatedClock) -> None:
_replay(interp, clock, len(STEPS))
assert interp.status == "done"
def test_guarded_transitions_are_denied_when_guards_fail(
denied_interp, clock: SimulatedClock
) -> None:
...
def test_every_declared_event_is_known(machine) -> None:
...
def test_snapshot_round_trip(interp, clock: SimulatedClock, machine) -> None:
...
# Events declared by the chart that the recorded walk never fired -- worth
# a hand-written test each, if they matter to you:
# - done.invoke.checkout.payment
# - error.platform.checkout.payment
Note the SUBMIT step lands in confirmed and ran clearCart: with a no-op stub service the invoke completes synchronously and onDone fires within the same send(). The recorded walk is exactly what the library does — including the after timers it fires by advancing the simulated clock (("clock", None, 2000, [...], [...]) steps).
typed — TypedDict context, Literal events, typed stubs
EventType = Literal["SUBMIT"]
StateId = Literal["checkout.cart", "checkout.confirmed", "checkout.payment"]
AnyEvent = Union[Event, DoneEvent, ErrorEvent, AfterEvent]
class CheckoutContext(TypedDict):
"""Shape of `context`, inferred from the chart's initial value."""
items: List[Any]
total: int
def calculate_total(
interpreter: BaseInterpreter[CheckoutContext],
context: CheckoutContext,
event: AnyEvent,
action: ActionDefinition,
) -> None:
"""Action `calculateTotal`."""
raise NotImplementedError
def cart_not_empty(context: CheckoutContext, event: AnyEvent) -> bool:
"""Guard `cartNotEmpty`."""
raise NotImplementedError
def process_payment(
interpreter: BaseInterpreter[CheckoutContext],
context: CheckoutContext,
event: AnyEvent,
) -> Any:
"""Service `processPayment`."""
raise NotImplementedError
def logic() -> MachineLogic:
"""A MachineLogic bound to the stubs above."""
return MachineLogic(
actions={
"calculateTotal": calculate_total,
"clearCart": clear_cart,
"showError": show_error,
},
guards={
"cartNotEmpty": cart_not_empty,
},
services={
"processPayment": process_payment,
},
)
The context type is inferred from the JSON context value ([] → List[Any], 0 → int, "" → str, a nested object → Dict[str, Any], null → Optional[Any]); a chart that declares no object context gets Context = Dict[str, Any]. The stubs are a typed contract for the logic you go on to implement, and logic() binds them so the module is usable as-is once the NotImplementedErrors are replaced.
plugin — a PluginBase wired for this chart
class CheckoutObserver(PluginBase):
"""Observes the checkout; see the hook docstrings."""
def on_transition(
self, interpreter, from_states, to_states, transition
) -> None:
"""every settled transition."""
_record("transition", interpreter, from_states=from_states, ...)
# TODO: your metrics / alerting here
def on_action_error(self, interpreter, action, error) -> None:
"""a user action raised (contained)."""
...
def on_service_error(self, interpreter, invocation, error) -> None:
"""an invoke failed."""
...
def on_chain_budget_exceeded(self, interpreter, error, event) -> None:
"""maxIterations cut a self-fed chain -- sticky, page on this."""
...
# Hooks NOT emitted because the chart does not use the feature:
# - on_unhandled_event (an event selected no transition)
# - on_transition_failed (actionErrorPolicy rolled back / stopped)
Only hooks the chart can actually fire are generated: on_service_* and on_invocation_stranded because it invokes something, on_guard_error because it has guards, on_done because it has a final state, on_transition_failed only under a rollback/fail actionErrorPolicy, on_unhandled_event only when onUnhandled is not the default. The always-relevant lifecycle and sticky-signal hooks (on_transition, on_action_error, on_chain_budget_exceeded, on_invalid_event, on_event_dropped, on_error) are always present. Every hook writes one JSON line through logging so the skeleton is useful before you touch it; the trailing comment lists what was left out and why.
pydantic-models — a context model and one EventModel per event
xsm gt checkout.json -t pydantic-models --with-api
checkout_models.py imports only the [pydantic] extra:
class CheckoutContext(BaseModel):
"""Shape of `context`, inferred from the chart's initial value.
Unknown keys are kept (`extra='allow'`): actions may add keys the
initial value does not show.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
items: List[Any] = Field(default_factory=lambda: [])
total: int = 0
class SubmitEvent(EventModel):
"""Accepted in: cart."""
model_config = ConfigDict(extra="allow", populate_by_name=True)
type: Literal["SUBMIT"] = "SUBMIT"
EVENT_MODELS: Tuple[Type[EventModel], ...] = (SubmitEvent,)
def event_schemas() -> Dict[str, Any]:
"""`create_machine(event_schemas=...)` for every model above."""
return events_union(*EVENT_MODELS)
The context model is inferred like the typed template’s TypedDict, with the chart’s initial values as defaults. An event whose JSON declares a payload — a transition’s meta.payload, or the root meta.events.<EVENT>.payload / meta.eventSchemas.<EVENT> — gets typed fields ("number" → float, {"type": "string", "required": true} → required str, JSON-Schema properties/required also work) and extra="forbid". SUBMIT declares none, so it accepts any payload (extra="allow"). Pass event_schemas() to create_machine and every send is validated.
fastapi-router — a router you own
checkout_api.py imports only the [fastapi] extra. With --with-models too, each route’s body is that event’s model:
def authorize(
conn: HTTPConnection, *, name: str, key: str, event: Optional[str]
) -> bool:
"""Decide whether *conn* may read (``event=None``) or send *event*
to instance *key*. Return True to admit, False for a 403."""
# 🔐 X0.1 -- closed by default. A generated API must not serve
# anyone until you decide who may read and drive which
# instance, so this stub RAISES instead of admitting: an
# unfinished router fails loudly (500) rather than shipping
# wide open. Replace the body with your check, e.g.
# return conn.user.is_authenticated and owns(conn.user, key)
# For a local demo ONLY, pass `authorize=allow_all` to
# `register()` -- it logs a warning the first time it is used.
raise NotImplementedError(
"authorize() is a generated stub: implement it (X0.1)"
)
registry = StatechartRegistry(MemoryStore())
def register(
machine: Any, *, authorize: Any = authorize, **kwargs: Any
) -> None:
"""Register *machine* (built from the checkout chart) with `registry`."""
registry.register(MACHINE_NAME, machine, authorize=authorize, **kwargs)
@router.get("/{id}", operation_id=f"{MACHINE_NAME}_get", response_model=None)
async def get_state(
request: Request, instance_id: str = INSTANCE_ID
) -> Response:
"""Current state of a checkout instance"""
...
@router.post(
"/{id}/events/SUBMIT",
operation_id=f"{MACHINE_NAME}_submit",
summary="Send SUBMIT",
response_model=None,
responses=SEND_RESPONSES,
)
async def send_submit(
request: Request,
instance_id: str = INSTANCE_ID,
body: Optional[SubmitEvent] = Body(None),
) -> Response:
"""Accepted in: cart."""
return await _send(request, instance_id, "SUBMIT", _payload(body))
# 📝 Declared LAST: the literal routes above win; anything else is
# an event this chart does not declare.
@router.post("/{id}/events/{event}", include_in_schema=False)
async def unknown_event(event: str) -> Response:
return problem(404, "Unknown event")
Wire it with register(create_machine(config, logic=...)), app.include_router(router) and instrument_app(app, registry). Every send goes through registry.send_event under bounded_route_class – exactly the path the library’s StatechartRouter uses – so the generated router has the registry’s rules, not a lookalike: 200 with the new state, 409 when a guard denies (and nothing saved), 422 when the body does not fit the model (problem+json listing only field locations and error types, at most 50 + errors_total), 413 over max_body_bytes, 415 for a non-JSON body, 501 for an Idempotency-Key without an inbox, a replay answered duplicate without a save; the reserved send options wait, priority and the discriminator type are refused as payload field names at generation. An event the chart does not declare is a 404 problem. Use get_interpreter for the custom routes you add beside the generated ones. Docstrings come from the transition’s description (or meta.description) and the states that accept the event; event names that are not URL-safe ("BTN: Abort") are slugged in the path (/events/btn_abort) but sent under their real name.
When fastapi is installed, the generator imports the module, mounts it on a throwaway FastAPI() and checks that openapi() lists one route per event before writing. Without it, the CLI prints a note and checks syntax only. --check / --diff report drift in both files: add an event to the JSON and CI shows the missing model and route.
Template Comparison Table
| Feature | pythonic-class |
pythonic-builder |
pythonic-functional |
class-json |
function-json |
|---|---|---|---|---|---|
| JSON needed at runtime | No | No | No | Yes | Yes |
| Logic in a class | Yes (StateMachine) |
No | No | Yes (provider) | No |
| Uses decorators | @action, @guard, @service |
@action, @guard, @service |
@action, @guard, @service |
No | No |
| Type hints | Full | Full | Full | Full | Full |
| OOP pattern | Subclass | Builder | Functional | Provider | Module |
self parameter |
Yes | No | No | Yes | No |
| Machine creation | MyMachine.create_machine() |
build() via MachineBuilder |
build() via build_machine() |
create_machine(cfg, logic_providers=...) |
create_machine(cfg, logic_modules=...) |
| Best for large machines | Excellent | Excellent | Good | Excellent | Good |
| Name auto-mapping | snake_case → camelCase | snake_case → camelCase | snake_case → camelCase | snake_case via LogicLoader |
snake_case via LogicLoader |
| Error handling in stubs | try/except | try/except | try/except | try/except | try/except |
| Default async mode | sync | sync | sync | async | async |
When to Use Each Template
Use pythonic-class when:
- You prefer a single, self-contained class that defines your entire machine
- You want the most Pythonic, declarative API
- You don’t need to keep JSON files around after generation
- Your team is comfortable with OOP and decorators
Use pythonic-builder when:
- You want module-level functions without class overhead
- You need to dynamically assemble machines (e.g., add states conditionally)
- You prefer the fluent builder pattern
- You want to keep logic functions decoupled from the machine structure
Use pythonic-functional when:
- You want the simplest, most explicit machine construction
- You prefer functional programming style
- You want full control over
Stateobjects andbuild_machine()arguments - Your machine is relatively straightforward
Use class-json when:
- You have existing JSON configs from Stately.ai that you want to keep as source of truth
- You want to update the JSON without regenerating Python code
- You prefer organizing logic as methods on a class
- Your workflow involves frequent JSON config changes
Use function-json when:
- You have existing JSON configs and want minimal code overhead
- You prefer flat module-level functions over classes
- You want the lightest-weight generated code
- You’re prototyping or building quick scripts