Skip to content

Commit cfd0b6e

Browse files
committed
feat: add Mermaid diagrams, transition tables, and __format__ support
- Add MermaidRenderer that converts DiagramGraph IR to Mermaid stateDiagram-v2 source (compound, parallel, history, guards, etc.) - Add TransitionTableRenderer for markdown and RST table output - Add MermaidGraphMachine facade mirroring DotGraphMachine - Add __format__ to StateChart and StateMachineMetaclass supporting dot, mermaid, md/markdown, and rst format specs - Extend CLI with --format option (mermaid, md, rst) and stdout support - Add :format: option to Sphinx statemachine-diagram directive with sphinxcontrib-mermaid integration - Update docs with new sections and doctests
1 parent 19556ce commit cfd0b6e

14 files changed

Lines changed: 1758 additions & 5 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"sphinx_gallery.gen_gallery",
5353
"sphinx_copybutton",
5454
"statemachine.contrib.diagram.sphinx_ext",
55+
"sphinxcontrib.mermaid",
5556
]
5657

5758
autosectionlabel_prefix_document = True

docs/diagram.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,118 @@ send events before rendering:
110110
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine diagram.png --events cycle cycle cycle
111111
```
112112

113+
Use `--format` to produce **Mermaid source** or a **transition table** instead
114+
of a Graphviz image:
115+
116+
```bash
117+
# Mermaid stateDiagram-v2
118+
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.mmd --format mermaid
119+
120+
# Markdown transition table
121+
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.md --format md
122+
123+
# RST transition table
124+
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.rst --format rst
125+
```
126+
127+
Use `-` as the output file to write to stdout (handy for piping):
128+
129+
```bash
130+
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine - --format mermaid
131+
```
132+
133+
134+
## Text representations with `format()`
135+
136+
State machines support Python's built-in `format()` protocol for quick text
137+
output — no diagram imports needed:
138+
139+
```py
140+
>>> from tests.examples.traffic_light_machine import TrafficLightMachine
141+
>>> sm = TrafficLightMachine()
142+
>>> print(f"{sm:mermaid}")
143+
stateDiagram-v2
144+
direction LR
145+
state "Green" as green
146+
state "Yellow" as yellow
147+
state "Red" as red
148+
[*] --> green
149+
green --> yellow : cycle
150+
yellow --> red : cycle
151+
red --> green : cycle
152+
<BLANKLINE>
153+
classDef active fill:#40E0D0,stroke:#333
154+
green:::active
155+
<BLANKLINE>
156+
157+
>>> print(f"{sm:md}")
158+
| State | Event | Guard | Target |
159+
| ------ | ----- | ----- | ------ |
160+
| Green | cycle | | Yellow |
161+
| Yellow | cycle | | Red |
162+
| Red | cycle | | Green |
163+
<BLANKLINE>
164+
165+
```
166+
167+
Works on **classes** too (no active-state highlighting):
168+
169+
```py
170+
>>> print(f"{TrafficLightMachine:mermaid}")
171+
stateDiagram-v2
172+
direction LR
173+
state "Green" as green
174+
state "Yellow" as yellow
175+
state "Red" as red
176+
[*] --> green
177+
green --> yellow : cycle
178+
yellow --> red : cycle
179+
red --> green : cycle
180+
<BLANKLINE>
181+
182+
```
183+
184+
Supported format specs: `dot`, `mermaid`, `md` (or `markdown`), `rst`.
185+
An empty spec falls back to `repr()`.
186+
187+
The `dot` format returns the Graphviz DOT language source (same output as
188+
`sm._graph().to_string()`):
189+
190+
```py
191+
>>> print(f"{sm:dot}") # doctest: +ELLIPSIS
192+
digraph TrafficLightMachine {
193+
...
194+
}
195+
196+
```
197+
198+
199+
## Mermaid output
200+
201+
The `MermaidGraphMachine` facade generates
202+
[Mermaid `stateDiagram-v2`](https://mermaid.js.org/syntax/stateDiagram.html)
203+
source text from any state machine — no external dependencies required:
204+
205+
```py
206+
>>> from statemachine.contrib.diagram import MermaidGraphMachine
207+
>>> from tests.examples.traffic_light_machine import TrafficLightMachine
208+
>>> print(MermaidGraphMachine(TrafficLightMachine).get_mermaid())
209+
stateDiagram-v2
210+
direction LR
211+
state "Green" as green
212+
state "Yellow" as yellow
213+
state "Red" as red
214+
[*] --> green
215+
green --> yellow : cycle
216+
yellow --> red : cycle
217+
red --> green : cycle
218+
<BLANKLINE>
219+
220+
```
221+
222+
Compound states, parallel regions, history pseudo-states, guards, and
223+
active-state highlighting are all supported.
224+
113225

114226
## Sphinx directive
115227

@@ -190,6 +302,11 @@ The directive supports the same layout options as the standard `image` and
190302
: Events to send in sequence. When present, the machine is instantiated and
191303
each event is sent before rendering.
192304

305+
`:format:` *(string)*
306+
: Output format. Use `mermaid` to render via
307+
[sphinxcontrib-mermaid](https://github.com/mgaitan/sphinxcontrib-mermaid)
308+
instead of Graphviz SVG. Default: DOT/SVG.
309+
193310
**Image/figure options:**
194311

195312
`:caption:` *(string)*

docs/releases/3.1.0.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@ instantiate the machine and send events before rendering, highlighting the
5656
current active state — matching the Sphinx directive's `:events:` option.
5757
See {ref}`diagram:Command line` for details.
5858

59+
60+
### Mermaid diagram support
61+
62+
State machines can now be rendered as
63+
[Mermaid `stateDiagram-v2`](https://mermaid.js.org/syntax/stateDiagram.html)
64+
source text — no Graphviz installation required.
65+
66+
Three ways to use it:
67+
68+
- **`format()` / f-strings:** `f"{sm:mermaid}"`, `f"{sm:md}"`, `f"{sm:rst}"`
69+
works on both instances and classes.
70+
- **CLI:** `python -m statemachine.contrib.diagram MyMachine - --format mermaid`
71+
- **Sphinx directive:** `:format: mermaid` renders via `sphinxcontrib-mermaid`.
72+
73+
A new `TransitionTableRenderer` produces markdown or RST transition tables
74+
from the same diagram IR. See {ref}`diagram:Text representations with format()`
75+
and {ref}`diagram:Mermaid output` for details.
76+
77+
5978
### Bugfixes in 3.1.0
6079

6180
- Fixes silent misuse of `Event()` with multiple positional arguments. Passing more than one

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dev = [
5252
"sphinx-autobuild; python_version >'3.8'",
5353
"furo >=2024.5.6; python_version >'3.8'",
5454
"sphinx-copybutton >=0.5.2; python_version >'3.8'",
55+
"sphinxcontrib-mermaid; python_version >'3.8'",
5556
"pdbr>=0.8.9; python_version >'3.8'",
5657
"babel >=2.16.0; python_version >='3.8'",
5758
"pytest-xdist>=3.6.1",

statemachine/contrib/diagram/__init__.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from .extract import extract
66
from .renderers.dot import DotRenderer
77
from .renderers.dot import DotRendererConfig
8+
from .renderers.mermaid import MermaidRenderer
9+
from .renderers.mermaid import MermaidRendererConfig
810

911

1012
class DotGraphMachine:
@@ -56,6 +58,32 @@ def __call__(self):
5658
return self.get_graph()
5759

5860

61+
class MermaidGraphMachine:
62+
"""Facade for generating Mermaid stateDiagram-v2 source from a state machine."""
63+
64+
direction = "LR"
65+
active_fill = "#40E0D0"
66+
active_stroke = "#333"
67+
68+
def __init__(self, machine):
69+
self.machine = machine
70+
71+
def _build_config(self) -> MermaidRendererConfig:
72+
return MermaidRendererConfig(
73+
direction=self.direction,
74+
active_fill=self.active_fill,
75+
active_stroke=self.active_stroke,
76+
)
77+
78+
def get_mermaid(self) -> str:
79+
ir = extract(self.machine)
80+
renderer = MermaidRenderer(config=self._build_config())
81+
return renderer.render(ir)
82+
83+
def __call__(self) -> str:
84+
return self.get_mermaid()
85+
86+
5987
def quickchart_write_svg(sm, path: str):
6088
"""
6189
If the default dependency of GraphViz installed locally doesn't work for you. As an option,
@@ -135,7 +163,7 @@ def import_sm(qualname):
135163
return smclass
136164

137165

138-
def write_image(qualname, out, events=None):
166+
def write_image(qualname, out, events=None, fmt=None):
139167
"""
140168
Given a `qualname`, that is the fully qualified dotted path to a StateMachine
141169
classes, imports the class and generates a dot graph using the `pydot` lib.
@@ -146,7 +174,13 @@ def write_image(qualname, out, events=None):
146174
147175
If `events` is provided, the machine is instantiated and each event is sent
148176
before rendering, so the diagram highlights the current active state.
177+
178+
If `fmt` is provided, it overrides the output format: ``"mermaid"`` writes
179+
Mermaid source text, ``"md"``/``"rst"`` write a transition table.
180+
Use ``out="-"`` to write to stdout.
149181
"""
182+
import sys
183+
150184
smclass = import_sm(qualname)
151185

152186
if events:
@@ -156,9 +190,27 @@ def write_image(qualname, out, events=None):
156190
else:
157191
machine = smclass
158192

159-
graph = DotGraphMachine(machine).get_graph()
160-
out_extension = out.rsplit(".", 1)[1]
161-
graph.write(out, format=out_extension)
193+
if fmt in ("mermaid", "md", "rst"):
194+
if fmt == "mermaid":
195+
text = MermaidGraphMachine(machine).get_mermaid()
196+
else:
197+
from .renderers.table import TransitionTableRenderer
198+
199+
ir = extract(machine)
200+
text = TransitionTableRenderer().render(ir, fmt=fmt)
201+
202+
if out == "-":
203+
sys.stdout.write(text)
204+
else:
205+
with open(out, "w") as f:
206+
f.write(text)
207+
else:
208+
graph = DotGraphMachine(machine).get_graph()
209+
if out == "-":
210+
sys.stdout.buffer.write(graph.create_svg()) # type: ignore[attr-defined]
211+
else:
212+
out_extension = out.rsplit(".", 1)[1]
213+
graph.write(out, format=out_extension)
162214

163215

164216
def main(argv=None):
@@ -180,6 +232,12 @@ def main(argv=None):
180232
nargs="+",
181233
help="Instantiate the machine and send these events before rendering.",
182234
)
235+
parser.add_argument(
236+
"--format",
237+
choices=["mermaid", "md", "rst"],
238+
default=None,
239+
help="Output format: mermaid source, markdown table, or RST table.",
240+
)
183241

184242
args = parser.parse_args(argv)
185-
write_image(qualname=args.class_path, out=args.out, events=args.events)
243+
write_image(qualname=args.class_path, out=args.out, events=args.events, fmt=args.format)

0 commit comments

Comments
 (0)