Skip to content

Commit bb3f554

Browse files
committed
feat: auto-expand {statechart:FORMAT} placeholders in class docstrings
The metaclass now detects {statechart:FORMAT} placeholders in docstrings and replaces them at class definition time with the rendered output. The docstring always stays in sync with the actual states and transitions. Any registered format works: md, rst, mermaid, dot, etc. Indentation of the placeholder line is preserved in the output.
1 parent c4f8e98 commit bb3f554

5 files changed

Lines changed: 241 additions & 1 deletion

File tree

docs/diagram.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ stateDiagram-v2
181181

182182
```
183183

184-
Supported format specs: `dot`, `mermaid`, `md` (or `markdown`), `rst`.
184+
Supported format specs: `dot`, `svg`, `mermaid`, `md` (or `markdown`), `rst`.
185185
An empty spec falls back to `repr()`.
186186

187187
The `dot` format returns the Graphviz DOT language source (same output as
@@ -196,6 +196,71 @@ digraph TrafficLightMachine {
196196
```
197197

198198

199+
## Auto-expanding docstrings
200+
201+
Use `{statechart:FORMAT}` placeholders in your class docstring to embed
202+
a live representation of the state machine. The placeholder is replaced
203+
at class definition time, so the docstring always reflects the actual
204+
states and transitions:
205+
206+
```py
207+
>>> from statemachine.statemachine import StateChart
208+
>>> from statemachine.state import State
209+
210+
>>> class TrafficLight(StateChart):
211+
... """A traffic light.
212+
...
213+
... {statechart:md}
214+
... """
215+
... green = State(initial=True)
216+
... yellow = State()
217+
... red = State()
218+
... cycle = green.to(yellow) | yellow.to(red) | red.to(green)
219+
220+
>>> print(TrafficLight.__doc__)
221+
A traffic light.
222+
<BLANKLINE>
223+
| State | Event | Guard | Target |
224+
| ------ | ----- | ----- | ------ |
225+
| Green | cycle | | Yellow |
226+
| Yellow | cycle | | Red |
227+
| Red | cycle | | Green |
228+
<BLANKLINE>
229+
<BLANKLINE>
230+
231+
```
232+
233+
Any registered format works: `{statechart:rst}`, `{statechart:mermaid}`,
234+
`{statechart:dot}`, etc.
235+
236+
### Choosing the right format
237+
238+
| Context | Recommended format |
239+
|---------|-------------------|
240+
| Sphinx with RST (autodoc default) | `{statechart:rst}` |
241+
| Sphinx with MyST Markdown | `{statechart:md}` |
242+
| `help()` in terminal / IDE | Either works; `md` reads more cleanly |
243+
244+
### Sphinx autodoc integration
245+
246+
Since the placeholder is expanded at class definition time, Sphinx `autodoc`
247+
sees the final rendered text — no extra configuration needed.
248+
249+
For example, this class uses `{statechart:rst}` in its docstring:
250+
251+
```{literalinclude} ../tests/machines/showcase_simple.py
252+
:pyobject: SimpleSC
253+
:language: python
254+
```
255+
256+
And here is the rendered autodoc output:
257+
258+
```{eval-rst}
259+
.. autoclass:: tests.machines.showcase_simple.SimpleSC
260+
:noindex:
261+
```
262+
263+
199264
## Mermaid output
200265

201266
The `MermaidGraphMachine` facade generates

docs/releases/3.1.0.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,28 @@ from the same diagram IR. See {ref}`diagram:Text representations with format()`
7575
and {ref}`diagram:Mermaid output` for details.
7676

7777

78+
### Auto-expanding docstrings
79+
80+
Use `{statechart:FORMAT}` placeholders in your class docstring to embed a
81+
live representation of the state machine. The placeholder is replaced at
82+
class definition time, so the docstring always stays in sync with the code:
83+
84+
```python
85+
class TrafficLight(StateChart):
86+
"""A traffic light.
87+
88+
{statechart:md}
89+
"""
90+
green = State(initial=True)
91+
yellow = State()
92+
red = State()
93+
cycle = green.to(yellow) | yellow.to(red) | red.to(green)
94+
```
95+
96+
Any registered format works: `md`, `rst`, `mermaid`, `dot`, etc.
97+
See {ref}`diagram:Auto-expanding docstrings` for details.
98+
99+
78100
### Bugfixes in 3.1.0
79101

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

statemachine/factory.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import Any
23
from typing import Dict
34
from typing import List
@@ -91,6 +92,37 @@ def __init__(
9192

9293
cls._check()
9394
cls._setup()
95+
cls._expand_docstring()
96+
97+
_STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")
98+
99+
def _expand_docstring(cls) -> None:
100+
"""Replace ``{statechart:FORMAT}`` placeholders in the class docstring."""
101+
doc = cls.__doc__
102+
if not doc:
103+
return
104+
105+
from .contrib.diagram.formatter import formatter
106+
107+
def _replace(match: "re.Match[str]") -> str:
108+
fmt = match.group(1)
109+
rendered = formatter.render(cls, fmt) # type: ignore[arg-type]
110+
111+
# Respect the indentation of the placeholder line.
112+
line_start = doc.rfind("\n", 0, match.start())
113+
if line_start == -1:
114+
indent = ""
115+
else:
116+
indent_match = re.match(r"[ \t]*", doc[line_start + 1 : match.start()])
117+
indent = indent_match.group() if indent_match else ""
118+
119+
if indent:
120+
lines = rendered.split("\n")
121+
rendered = lines[0] + "\n" + "\n".join(indent + line for line in lines[1:])
122+
123+
return rendered
124+
125+
cls.__doc__ = cls._STATECHART_RE.sub(_replace, doc)
94126

95127
def __format__(cls, fmt: str) -> str:
96128
from .contrib.diagram.formatter import formatter

tests/machines/showcase_simple.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33

44

55
class SimpleSC(StateChart):
6+
"""A simple three-state machine.
7+
8+
{statechart:rst}
9+
"""
10+
611
idle = State(initial=True)
712
running = State()
813
done = State(final=True)

tests/test_contrib_diagram.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,122 @@ def test_format_invalid_class_raises(self):
13281328
f"{TrafficLightMachine:invalid}"
13291329

13301330

1331+
class TestDocstringExpansion:
1332+
"""Tests for {statechart:FORMAT} placeholder expansion in docstrings."""
1333+
1334+
def test_md_placeholder(self):
1335+
from statemachine.state import State
1336+
from statemachine.statemachine import StateChart
1337+
1338+
class MyMachine(StateChart):
1339+
"""Machine.
1340+
1341+
{statechart:md}
1342+
"""
1343+
1344+
s1 = State(initial=True)
1345+
s2 = State(final=True)
1346+
1347+
go = s1.to(s2)
1348+
1349+
assert "| State" in MyMachine.__doc__
1350+
assert "{statechart:md}" not in MyMachine.__doc__
1351+
1352+
def test_rst_placeholder(self):
1353+
from statemachine.state import State
1354+
from statemachine.statemachine import StateChart
1355+
1356+
class MyMachine(StateChart):
1357+
"""Machine.
1358+
1359+
{statechart:rst}
1360+
"""
1361+
1362+
s1 = State(initial=True)
1363+
s2 = State(final=True)
1364+
1365+
go = s1.to(s2)
1366+
1367+
assert "+---" in MyMachine.__doc__
1368+
assert "{statechart:rst}" not in MyMachine.__doc__
1369+
1370+
def test_mermaid_placeholder(self):
1371+
from statemachine.state import State
1372+
from statemachine.statemachine import StateChart
1373+
1374+
class MyMachine(StateChart):
1375+
"""{statechart:mermaid}"""
1376+
1377+
s1 = State(initial=True)
1378+
s2 = State(final=True)
1379+
1380+
go = s1.to(s2)
1381+
1382+
assert "stateDiagram-v2" in MyMachine.__doc__
1383+
1384+
def test_no_placeholder_unchanged(self):
1385+
from statemachine.state import State
1386+
from statemachine.statemachine import StateChart
1387+
1388+
class MyMachine(StateChart):
1389+
"""Just a plain docstring."""
1390+
1391+
s1 = State(initial=True)
1392+
s2 = State(final=True)
1393+
1394+
go = s1.to(s2)
1395+
1396+
assert MyMachine.__doc__ == "Just a plain docstring."
1397+
1398+
def test_no_docstring(self):
1399+
from statemachine.state import State
1400+
from statemachine.statemachine import StateChart
1401+
1402+
class MyMachine(StateChart):
1403+
s1 = State(initial=True)
1404+
s2 = State(final=True)
1405+
1406+
go = s1.to(s2)
1407+
1408+
assert MyMachine.__doc__ is None
1409+
1410+
def test_indentation_preserved(self):
1411+
from statemachine.state import State
1412+
from statemachine.statemachine import StateChart
1413+
1414+
class MyMachine(StateChart):
1415+
__doc__ = "Doc.\n\n Table:\n\n {statechart:md}\n\n End.\n"
1416+
1417+
s1 = State(initial=True)
1418+
s2 = State(final=True)
1419+
1420+
go = s1.to(s2)
1421+
1422+
lines = MyMachine.__doc__.split("\n")
1423+
table_lines = [line for line in lines if "|" in line]
1424+
for line in table_lines:
1425+
assert line.startswith(" |")
1426+
assert "End." in MyMachine.__doc__
1427+
1428+
def test_multiple_placeholders(self):
1429+
from statemachine.state import State
1430+
from statemachine.statemachine import StateChart
1431+
1432+
class MyMachine(StateChart):
1433+
"""MD: {statechart:md}
1434+
1435+
Mermaid: {statechart:mermaid}
1436+
"""
1437+
1438+
s1 = State(initial=True)
1439+
s2 = State(final=True)
1440+
1441+
go = s1.to(s2)
1442+
1443+
assert "| State" in MyMachine.__doc__
1444+
assert "stateDiagram-v2" in MyMachine.__doc__
1445+
1446+
13311447
class TestFormatter:
13321448
"""Tests for the Formatter facade (render, register_format, supported_formats)."""
13331449

0 commit comments

Comments
 (0)