Skip to content

Commit 05fe36d

Browse files
authored
feat: Generate StateMachine diagram from cmd line (#311)
1 parent 04b57a1 commit 05fe36d

3 files changed

Lines changed: 111 additions & 11 deletions

File tree

docs/diagram.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Graphviz. For example, on Debian-based systems (such as Ubuntu), you can use the
2929
3030
```
3131

32-
## How to generate a diagram
32+
## How to generate a diagram at runtime
3333

3434

3535
```py
@@ -81,6 +81,37 @@ The current state is also highlighted:
8181
![OrderControl](images/order_control_machine_processing.png)
8282

8383

84+
## Generate from the command line
85+
86+
You can also generate a diagram from the command line using the `statemachine.contrib.diagram` as a module.
87+
88+
```bash
89+
❯ python -m statemachine.contrib.diagram --help
90+
usage: diagram.py [OPTION] <classpath> <out>
91+
92+
Generate diagrams for StateMachine classes.
93+
94+
positional arguments:
95+
classpath A fully-qualified dotted path to the StateMachine class.
96+
out File to generate the image using extension as the output format.
97+
98+
optional arguments:
99+
-h, --help show this help message and exit
100+
```
101+
102+
Example:
103+
104+
```bash
105+
python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine m.png
106+
```
107+
108+
```{note}
109+
Supported formats include: `dia`, `dot`, `fig`, `gif`, `jpg`, `pdf`, `png`, `ps`, `svg` and many others.
110+
Please see [pydot](https://github.com/pydot/pydot) and [Graphviz](https://graphviz.org/) for a
111+
complete list.
112+
```
113+
114+
84115
## JupyterLab / Jupyter integration
85116

86117
Machines instances are automatically displayed as a diagram when used on JupyterLab cells:

statemachine/contrib/diagram.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import sys
12
import pydot
2-
from statemachine.factory import StateMachineMetaclass
3+
import importlib
4+
5+
from ..factory import StateMachineMetaclass
6+
from ..statemachine import BaseStateMachine
37

48

59
class DotGraphMachine(object):
@@ -25,7 +29,9 @@ def __init__(self, machine):
2529

2630
def _get_graph(self):
2731
machine = self.machine
28-
sm_class = machine if isinstance(machine, StateMachineMetaclass) else machine.__class__
32+
sm_class = (
33+
machine if isinstance(machine, StateMachineMetaclass) else machine.__class__
34+
)
2935
return pydot.Dot(
3036
"list",
3137
graph_type="digraph",
@@ -95,17 +101,11 @@ def _state_as_node(self, state):
95101
return node
96102

97103
def _transition_as_edge(self, transition):
98-
99104
def _get_condition_repr(cond):
100105
name = getattr(cond.func, "__name__", cond.func)
101106
return name if cond.expected_value else "!{}".format(name)
102107

103-
cond = ", ".join(
104-
[
105-
_get_condition_repr(cond)
106-
for cond in transition.cond
107-
]
108-
)
108+
cond = ", ".join([_get_condition_repr(cond) for cond in transition.cond])
109109
if cond:
110110
cond = "\n[{}]".format(cond)
111111
return pydot.Edge(
@@ -133,3 +133,52 @@ def get_graph(self):
133133

134134
def __call__(self):
135135
return self.get_graph()
136+
137+
138+
def import_sm(qualname):
139+
module_name, class_name = qualname.rsplit(".", 1)
140+
module = importlib.import_module(module_name)
141+
smclass = getattr(module, class_name, None)
142+
if not smclass or not issubclass(smclass, BaseStateMachine):
143+
raise ValueError("{} is not a subclass of StateMachine".format(class_name))
144+
145+
return smclass
146+
147+
148+
def write_image(qualname, out):
149+
"""
150+
Given a `qualname`, that is the fully qualified dotted path to a StateMachine
151+
classe, imports the class and generates a dot graph using the `pydot` lib.
152+
Writes the graph representation to the filename 'out' that will
153+
open/create and truncate such file and write on it a representation of
154+
the graph defined by the statemachine, in the format specified by
155+
the extension contained in the out path (out.ext).
156+
"""
157+
smclass = import_sm(qualname)
158+
159+
graph = DotGraphMachine(smclass).get_graph()
160+
out_extension = out.rsplit(".", 1)[1]
161+
graph.write(out, format=out_extension)
162+
163+
164+
def main(argv=None):
165+
import argparse
166+
167+
parser = argparse.ArgumentParser(
168+
usage="%(prog)s [OPTION] <classpath> <out>",
169+
description="Generate diagrams for StateMachine classes.",
170+
)
171+
parser.add_argument(
172+
"classpath", help="A fully-qualified dotted path to the StateMachine class."
173+
)
174+
parser.add_argument(
175+
"out",
176+
help="File to generate the image using extension as the output format.",
177+
)
178+
179+
args = parser.parse_args(argv)
180+
write_image(qualname=args.classpath, out=args.out)
181+
182+
183+
if __name__ == "__main__": # pragma: no cover
184+
sys.exit(main())

tests/test_contrib_diagram.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from statemachine.contrib.diagram import DotGraphMachine
5+
from statemachine.contrib.diagram import DotGraphMachine, main
66

77

88
@pytest.fixture(params=[
@@ -37,3 +37,23 @@ def test_machine_dot(OrderControl):
3737

3838
dot_str = dot.to_string() # or dot.to_string()
3939
assert dot_str.startswith("digraph list {")
40+
41+
42+
class TestDiagramCmdLine:
43+
44+
def test_generate_image(self, tmp_path):
45+
out = tmp_path / 'sm.svg'
46+
47+
main(["tests.examples.traffic_light_machine.TrafficLightMachine", str(out)])
48+
49+
assert out.read_text().startswith(
50+
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<!DOCTYPE svg'
51+
)
52+
53+
def test_generate_complain_about_bad_sm_path(self, capsys, tmp_path):
54+
out = tmp_path / 'sm.svg'
55+
56+
with pytest.raises(ValueError) as e:
57+
main(["tests.examples.traffic_light_machine.TrafficLightMachineXXX", str(out)])
58+
59+
assert e.match("TrafficLightMachineXXX is not a subclass of StateMachine")

0 commit comments

Comments
 (0)