Template System Tutorial
This tutorial is about the template system itself. The goal is not only to use an existing built-in template, but to understand how pyfcstm turns a state machine model into generated files so you can design, test, and maintain your own templates.
Understand The Template System
What The Template System Does
pyfcstm follows a clear separation of responsibilities:
The DSL parser reads
.fcstmtext and builds an AST.The model layer converts the AST into a
pyfcstm.model.StateMachine.The renderer loads a template directory and renders files from that model.
The template decides what output files look like.
The renderer is not a compiler backend by itself. It does not know what your target project structure should be, what naming conventions you want, or how large the generated runtime should be. Those decisions belong to the template.
The simplest mental model is:
DSL text
-> StateMachine model
-> StateMachineCodeRenderer(template_dir)
-> rendered files in output_dir
Template authors should think in terms of “model in, file tree out”.
Current Rendering Boundaries
The current renderer has a few important boundaries that shape template design:
Files ending in
.j2are rendered through Jinja2.Non-
.j2files are copied as-is.Directory structure is preserved.
Output file names are fixed by template file names.
machine.py.j2becomesmachine.py.Ignore patterns come from
config.yamland use gitignore-style matching.
This means a template controls file contents very well, but does not currently
control output file names dynamically. If you need a file called
TrafficLightMachine.py, that is not a template-only change today. You would
need renderer support for templated output paths.
Template Directory Anatomy
A template directory is usually small and explicit:
my_template/
├── config.yaml
├── machine.py.j2
├── README.md
├── README.md.j2
└── static/
└── helper.txt
Typical file roles:
config.yaml: renderer configuration, helper definitions, style overrides, and ignore rules.*.j2: rendered files.static files: copied to the output directory unchanged.
template
READMEfiles: documentation for template maintainers.generated
READMEtemplates such asREADME.md.j2: documentation for users of the generated output.
This distinction matters. Template-maintainer docs explain how the template is organized. Generated docs explain how to use the generated artifact.
Build The Template Foundation
The Role Of config.yaml
config.yaml is the main integration point between the renderer and your
template. It usually contains:
expr_stylesstmt_stylesglobalsfilterstestsignores
Example:
expr_styles:
python_scope_expr:
base_lang: python
Name: "scope[{{ node.name | tojson }}]"
globals:
state_path:
type: template
params: [state]
template: "{{ state.path | join('.') }}"
filters:
state_path:
type: template
params: [state]
template: "{{ state.path | join('.') }}"
ignores:
- 'README.md'
How to think about each section:
expr_styles: customize expression rendering for a target language or a target scope.stmt_styles: customize operational-statement rendering, including temporary-variable handling for static or dynamic languages.globals: helper functions or values available as template globals.filters: transformation helpers used in{{ value | filter_name }}.tests: Jinja2 tests for conditions such asvalue is my_test.ignores: files or directories the renderer should skip.
Use config.yaml when the logic is renderer-oriented or naming-oriented.
Use a Jinja2 macro when the logic is mainly about repeated file structure.
Use The Rendering Interfaces
Understanding expr_render
expr_render is the expression-level renderer. Use it when you need one DSL
expression rendered into a target-language expression string.
Quick selection rule:
If the input is one expression node, use
expr_renderIf the input is one operation statement, use
stmt_renderIf the input is a whole block such as
action.operations, usestmts_render
Typical examples:
{{ transition.guard.to_ast_node() | expr_render(style='python') }}
{{ some_expr | expr_render(style='c') }}
Parameter overview:
Field |
Required |
Meaning |
Typical values |
|---|---|---|---|
|
Yes |
The single expression node to render |
|
|
No |
Which expression style to use |
|
The default behavior of style matters:
on a top-level call, omitting
style=...usesdefaultinside recursive expression rendering, omitting
style=...inherits the current style
What it is for:
guard expressions
assigned values
effect conditions
custom naming or scope remapping for expression nodes
Typical input shapes:
one
pyfcstm.model.exprnodeone DSL AST expression node such as
guard.to_ast_node()a primitive literal such as
1orTruewhen you intentionally want it normalized through the expression renderer
What it returns:
one target-language expression string
not a complete statement
usually not something with indentation or branch structure
What it is not for:
full operation blocks
assignment statements
if / else if / elsestatement trees
If you need executable statement output, use stmt_render or
stmts_render instead.
How expr_render Resolves Template Keys
This is the part template authors usually need spelled out. When you override
expr_styles in config.yaml, you are not replacing “a whole language”.
You are overriding template entries for specific expression-node shapes.
The matching rule is:
try the most specific key first
if it does not exist, fall back to the generic key for that node family
if that still does not exist, fall back to
default
The key patterns that matter most are:
Key form |
Matches |
Example |
Typical use |
|---|---|---|---|
|
float literals |
|
change float formatting |
|
decimal integer literals |
|
change integer formatting |
|
boolean literals |
target-language |
map boolean literals |
|
DSL constant nodes |
|
map constants to |
|
hexadecimal integers |
|
preserve hex output |
|
parenthesized expressions |
|
control parenthesis preservation |
|
variable names |
|
scope remapping such as |
|
any unary function call |
|
define generic function-call rendering |
|
one specific unary function |
|
special-case one function |
|
any unary operator |
|
define generic unary rendering |
|
one specific unary operator |
|
map |
|
any binary operator |
|
define generic binary rendering |
|
one specific binary operator |
|
map exponentiation to |
|
ternary conditional expressions |
|
map to the target-language ternary form |
|
final fallback |
when no more specific key exists |
last-resort rendering |
The important precedence points are:
UFunc(sin)wins overUFuncUnaryOp(!)wins overUnaryOpBinaryOp(**)wins overBinaryOpnode families such as
Name,Float, andIntegermatch directly by their node type name
That is why a Python-style override such as:
expr_styles:
default:
base_lang: python
UnaryOp(!): 'not {{ node.expr | expr_render }}'
BinaryOp(&&): '{{ node.expr1 | expr_render }} and {{ node.expr2 | expr_render }}'
BinaryOp(||): '{{ node.expr1 | expr_render }} or {{ node.expr2 | expr_render }}'
does not replace the whole Python expression system. It only says:
override
!override
&&override
||keep using the inherited Python templates for everything else
That is the core advantage of the style system: most template authors should override a few keys, not copy an entire expression-style dictionary.
How style inheritance works:
each custom style starts from
base_langyou only override the node mappings you actually need
recursive rendering inside that style inherits the current style unless you explicitly pass another one
That last point matters. If you define:
expr_styles:
python_scope_expr:
base_lang: python
Name: "scope[{{ node.name | tojson }}]"
then a nested expression such as counter + 1 will continue using
python_scope_expr for the inner Name node. You do not need to re-copy
every built-in operator template just to keep recursion aligned.
One more practical detail matters in real templates:
when a template is rendered by
pyfcstm.render.StateMachineCodeRenderer, callingexpr_renderwithoutstyle=...uses thedefaultexpression style fromconfig.yamlif you define
defaultas a thin wrapper overpython, most template sites no longer need to spell outstyle='python'repeatedly
How To Override Those Keys
The most common override cases are these.
Override only name mapping:
expr_styles:
default:
base_lang: python
Name: "scope[{{ node.name | tojson }}]"
Effect:
counter + 1becomesscope["counter"] + 1everything else still uses the inherited Python style
Override only one operator:
expr_styles:
default:
base_lang: c
BinaryOp(**): 'pow({{ node.expr1 | expr_render }}, {{ node.expr2 | expr_render }})'
Effect:
a ** bbecomespow(a, b)all other binary operators keep the inherited C behavior
Override only one function:
expr_styles:
default:
base_lang: python
UFunc(sin): 'fast_sin({{ node.expr | expr_render }})'
Effect:
sin(x)becomesfast_sin(x)cos(x),sqrt(x), and the rest still use the inherited Python style
The anti-pattern to avoid is:
copying
Float/Integer/BinaryOp/UFuncjust to change oneNamerepeating dozens of unrelated keys to customize one operator
pushing expression strategy into ad hoc Jinja snippets instead of keeping it in styles
A practical refactor looks like this.
Before:
if {{ transition.guard.to_ast_node() | expr_render(style='python') }}:
...
value = {{ some_expr | expr_render(style='python') }}
After:
expr_styles:
default:
base_lang: python
python_scope_expr:
base_lang: python
Name: "scope[{{ node.name | tojson }}]"
if {{ transition.guard.to_ast_node() | expr_render }}:
...
value = {{ some_expr | expr_render }}
Effect:
shorter template bodies
more centralized target-language decisions
fewer chances to forget or mismatch the intended style
Understanding stmt_render And stmts_render
stmt_render and stmts_render are the statement-level counterparts to
expr_render.
Use:
stmt_renderfor one operation statementstmts_renderfor a sequence of statements, usually a full action block
Typical input shapes:
stmt_rendertakes oneOperationStatementor one DSL operational AST statementstmts_rendertakes an iterable of those statements, for exampleaction.operations
Examples:
{{ one_statement | stmt_render(style='python') }}
{{ action.operations | stmts_render(style='python') }}
Parameter overview for stmt_render:
Field |
Required |
Meaning |
Typical values |
|---|---|---|---|
|
Yes |
One operation statement |
|
|
No |
Which statement style to use |
|
|
No |
Names that should be treated as persistent state variables |
|
|
No |
Variable type mapping, mainly for static-language styles |
|
|
No |
Temporary names already visible before this statement |
|
|
No |
Type mapping for already-visible temporary names |
|
|
No |
One indentation unit |
|
|
No |
Initial indentation depth |
|
Parameter overview for stmts_render:
Field |
Required |
Meaning |
Typical values |
|---|---|---|---|
|
Yes |
A sequence of operation statements |
|
|
No |
Which statement style to use |
|
|
No |
Names that should be treated as persistent state variables |
|
|
No |
Variable type mapping, mainly for static-language styles |
|
|
No |
Temporary names already visible before this block |
|
|
No |
Type mapping for already-visible temporary names |
|
|
No |
One indentation unit |
|
|
No |
Initial indentation depth |
|
|
No |
Separator between top-level rendered statements |
|
They are the correct entry points when the DSL block may contain:
assignments
temporary variables
if / else if / elsenested branches
What they return:
executable target-language statement text
indentation-aware multi-line output
branch structure that still matches DSL block semantics
For template authors, the most important semantic detail is that statement rendering distinguishes persistent state variables from block-local temporary variables.
In renderer-driven template rendering, if you do not explicitly pass
state_vars or var_types, pyfcstm.render.StateMachineCodeRenderer
automatically injects defaults from model.defines. That means most
templates can simply write:
{{ action.operations | stmts_render(style='python') }}
instead of repeatedly spelling out the full state-variable set.
Why this matters:
persistent variables should render to the target state container such as
scope['counter']orscope->countertemporary variables should stay local to the block
branch-local temporaries should follow the same visibility rules as the runtime semantics
A common refactor is to stop hand-writing statement expansion.
Before:
{% for op in action.operations %}
{{ op.target.name }} = {{ op.expr.to_ast_node() | expr_render(style='python') }}
{% endfor %}
Problems with that approach:
it only covers the simplest assignment shape
temporary-variable semantics are easy to get wrong
if / else if / elsequickly forces you to reimplement a statement renderer
After:
{{ action.operations | stmts_render(style='python') }}
Effect:
assignments, temporaries, and branches all go through one renderer
the template gets shorter and less error-prone
scope or typing changes become style changes rather than template rewrites
Built-in statement styles already encode these target-language conventions for
dsl, c, cpp, python, java, js, ts, rust, and
go.
One more distinction is important:
operation_stmt_renderandoperation_stmts_renderare useful when you want DSL-text displaystmt_renderandstmts_renderare the correct tools for target-language code generation
If you are generating executable code, prefer stmt_render /
stmts_render.
The fastest way to avoid misuse is to think in examples:
Goal |
Input |
Correct filter |
Typical output shape |
|---|---|---|---|
Render one guard expression |
|
|
|
Render one assignment statement |
one item from |
|
|
Render one whole action block |
|
|
multiple statements with indentation and nested |
Common mistakes:
feeding
action.operationsintoexpr_renderand expecting a blockfeeding a guard expression into
stmts_renderand expecting it to become a validifstatement automaticallyusing
operation_stmts_renderwhen the real goal is target-language code rather than DSL echo text
Template Context: What You Can Access
Rendered templates receive the state machine model as model. In practice,
template authors often use:
model.root_statemodel.definesmodel.walk_states()state paths, parent/child relationships, events, actions, transitions
Example:
Root: {{ model.root_state.name }}
Variables:
{% for def_item in model.defines.values() %}
- {{ def_item.type }} {{ def_item.name }}
{% endfor %}
States:
{% for state in model.walk_states() %}
- {{ state.path | join('.') }}
{% endfor %}
When you want the full model surface, continue with pyfcstm.model.
That API documentation is the right place to inspect:
what
pyfcstm.model.StateMachineexposeswhat is available on states, transitions, events, and lifecycle-action objects
how model objects and expression nodes are organized
Design Real Templates
Generation Scale And Template Shape
Template authors need a clear idea of how output size scales:
one template file usually produces one output file
output size often scales with the number of states, transitions, events, and lifecycle actions
nested loops and repeated inline expressions can quickly make templates hard to read and hard to maintain
Practical guidance:
move repeated naming logic into helpers
move repeated structural fragments into macros
prefer one clear expansion pass over many nearly-identical blocks
keep generated code readable enough that downstream users can debug it
For large generated runtimes, readability is a feature, not decoration. The template should not rely on a formatter as a crutch for fundamentally messy structure.
Minimal Template From Scratch
The fastest way to learn the system is to write a tiny template first.
Directory:
demo_template/
├── config.yaml
└── summary.txt.j2
config.yaml:
globals:
state_path:
type: template
params: [state]
template: "{{ state.path | join('.') }}"
summary.txt.j2:
Root state: {{ model.root_state.name }}
Variables:
{% for def_item in model.defines.values() %}
- {{ def_item.type }} {{ def_item.name }}
{% endfor %}
States:
{% for state in model.walk_states() %}
- {{ state | state_path }}
{% endfor %}
Render it with:
pyfcstm generate -i ./machine.fcstm -t ./demo_template -o ./out
This is enough to verify the full renderer path before you attempt a large runtime template.
To make that example more concrete, assume the input DSL is:
def int counter = 0;
state TrafficLight {
[*] -> Red;
state Red;
state Green;
}
Then the rendered out/summary.txt will look like:
Root state: TrafficLight
Variables:
- int counter
States:
- TrafficLight
- TrafficLight.Red
- TrafficLight.Green
That kind of concrete output check is useful because it lets you see the generated shape immediately instead of reasoning only from template source.
Built-In Templates
The render system is general, but the repository also ships built-in templates that serve as real reference implementations.
Current built-in templates:
python- status: current built-in template - design position: reference implementation for template-system structure andsimulator-aligned runtime semantics
c- status: current built-in template - design position: self-contained generated C runtime with a documentedpublic API and stronger focus on deployment/runtime integration
c_poll- status: current built-in template - design position: self-contained generated C runtime with hook-polled eventacquisition, intended for scan-cycle and control-loop style integrations
For built-in templates, pyfcstm also emits generated usage guides into the
output directory. In practice, users will find README.md and
README_zh.md alongside the generated artifacts, and those generated
documents are the primary place to explain target-specific usage details.
python - Reference Runtime Template For Simulator-Aligned Codegen
The python template is the clearest reference implementation for how the
template system comes together end to end.
Its current design position is:
a reference implementation for built-in template layout
a single-file runtime template
generated README templates for end users
runtime behavior aligned with the simulator
protected-hook extension points for abstract actions
If you want the most approachable reference for template structure, helper
design, generated runtime shape, and simulator-aligned behavior, start from the
python template under templates/python/ and the generated
README.md / README_zh.md it emits.
c - Self-Contained Generated Runtime For Native Integration
The c template is the native-runtime-oriented built-in option. It generates
a self-contained runtime around machine.h and machine.c rather than a
single-file Python runtime, but it still follows the same template-system
model.
Its current design position is:
a generated C runtime intended for direct embedding and integration
explicit public header/runtime API instead of Python import-based usage
generated
README.md/README_zh.mdas the primary user-facing integration guideruntime tests and alignment tests that validate generated behavior against the simulator where applicable
If you want the native-runtime example, go to templates/c/ and treat the
generated README.md / README_zh.md in output directories as the
authoritative integration guide for end users.
c_poll - Hook-Polled Runtime For Scan-Cycle Control Integration
The c_poll template is closely related to c and reuses the same broad
runtime direction: generated machine.h and machine.c, a documented
public API, and generated user-facing README.md / README_zh.md files.
The key design difference is the event-input model:
cexpects the integration layer to collect events first and then submit a per-cycle event-id set intocycle(...)c_pollexpects the integration layer to install generatedcheck_xxxhooks once, and the runtime then queries those hooks lazily while deciding transitions insidecycle()
That difference matters because it changes who owns event observation:
in
c, the application owns event collection and passes a normalized event set into the runtimein
c_poll, the runtime owns event observation timing and asks “is this event active right now?” through the installed event-check table
This makes c_poll a better fit for many real control-system and embedded
integration patterns, for example:
cyclic scan loops that read current inputs every control tick
PLC-like logic where transition conditions are evaluated against the current sampled world state
embedded control systems where “event active this cycle” is derived from input pins, sensors, fieldbus snapshots, or shared process images
integrations that do not already have a clean external event-dispatch layer
In practical terms, choose c when your surrounding system already has a
clear event aggregation or dispatch step and you want to feed explicit event
sets into the runtime. Choose c_poll when your system is naturally
cycle-driven and event truth is better expressed as polled checks over the
current input snapshot.
Test And Consolidate
Testing Templates
Template work should be tested at multiple levels.
Start with a template-author workflow, not with a giant machine:
prepare one tiny DSL sample for the exact behavior you are implementing
render into a temporary output directory
inspect the generated file names, structure, indentation, and variable mapping
if this is a runtime template, actually import and execute the generated code
turn that sample into a regression test before expanding the template further
Do not debug a new template against a large state machine first. Template bugs and model complexity compound very quickly.
Renderer-Level Tests
Use pyfcstm.render.StateMachineCodeRenderer directly when you want to
check file output for a controlled model.
Typical checks:
expected files exist
copied static files stay unchanged
rendered files contain the expected text
custom
expr_stylesandstmt_stylesbehave correctly
Generated-Artifact Tests
For runtime templates, do not stop at string comparison. Import the generated artifact and execute it.
Typical checks:
generated Python files import successfully
generated public API matches the template contract
generated code behaves correctly on simple state-machine scenarios
Behavior-Alignment Tests
If a template is intended to match pyfcstm.simulate.SimulationRuntime,
keep alignment tests that compare both runtimes on the same DSL samples.
This level catches semantic drift in:
transition selection
initial transitions
pseudo-state handling
aspect actions
hot start
temporary-variable scope
CLI End-To-End Tests
Add CLI tests when your template is intended to be used through the command line:
generate with
pyfcstm generate -t ./your_templateverify output files exist
import or run the generated artifact
check one minimal behavior path
When template debugging gets stuck, a good triage order is:
check whether the helper or style in
config.yamlalready does what you think it doescheck whether the object you pass into
expr_render/stmt_render/stmts_renderis the right oneif you suspect the DSL block shape rather than the target-language rendering, echo it first with
operation_stmt_renderoroperation_stmts_renderif the generated file text looks correct but behavior is wrong, move to generated-artifact tests
When To Put Logic Where
A common source of template sprawl is putting logic in the wrong place.
Use config.yaml helpers when:
the logic is naming-related
the same helper is reused across multiple files
the template would otherwise duplicate long Jinja expressions
Use Jinja2 macros when:
the logic is mostly structural
a repeated block has many lines
the output layout, not the helper interface, is the main concern
Inline logic in a .j2 file only when:
it is local to that file
it is short
extracting it would make the template harder to read
Summary
The key ideas for template authors are:
think in terms of
StateMachinemodel in, file tree outkeep renderer-facing logic in
config.yamlkeep repeated structure in macros
learn the official Jinja material for syntax depth, then apply pyfcstm-specific rules here
test templates at renderer, generated-artifact, and CLI levels when needed
Once these pieces are clear, writing a new template becomes a disciplined engineering task rather than trial-and-error Jinja editing.