Skip to content

Commit 7089a17

Browse files
committed
refactor: introduce Formatter facade with decorator-based format registry
Replace duplicated if/elif chains in StateChart.__format__ and StateMachineMetaclass.__format__ with a Formatter class that uses a decorator-based registry following the Open/Closed Principle. Adding a new format now requires only a decorated function — no changes to __format__, factory.py, or statemachine.py.
1 parent cfd0b6e commit 7089a17

5 files changed

Lines changed: 257 additions & 57 deletions

File tree

statemachine/contrib/diagram/__init__.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from urllib.request import urlopen
44

55
from .extract import extract
6+
from .formatter import formatter as formatter
67
from .renderers.dot import DotRenderer
78
from .renderers.dot import DotRendererConfig
89
from .renderers.mermaid import MermaidRenderer
@@ -175,8 +176,8 @@ def write_image(qualname, out, events=None, fmt=None):
175176
If `events` is provided, the machine is instantiated and each event is sent
176177
before rendering, so the diagram highlights the current active state.
177178
178-
If `fmt` is provided, it overrides the output format: ``"mermaid"`` writes
179-
Mermaid source text, ``"md"``/``"rst"`` write a transition table.
179+
If `fmt` is provided, it overrides the output format (any registered text
180+
format such as ``"mermaid"``, ``"dot"``, ``"md"``, ``"rst"``).
180181
Use ``out="-"`` to write to stdout.
181182
"""
182183
import sys
@@ -190,15 +191,8 @@ def write_image(qualname, out, events=None, fmt=None):
190191
else:
191192
machine = smclass
192193

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-
194+
if fmt is not None:
195+
text = formatter.render(machine, fmt)
202196
if out == "-":
203197
sys.stdout.write(text)
204198
else:
@@ -234,9 +228,9 @@ def main(argv=None):
234228
)
235229
parser.add_argument(
236230
"--format",
237-
choices=["mermaid", "md", "rst"],
231+
choices=formatter.supported_formats(),
238232
default=None,
239-
help="Output format: mermaid source, markdown table, or RST table.",
233+
help="Output as text format instead of Graphviz image.",
240234
)
241235

242236
args = parser.parse_args(argv)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Unified facade for rendering state machines in multiple text formats.
2+
3+
The :class:`Formatter` class provides a decorator-based registry where each
4+
renderer declares the format names it handles. Adding a new format only
5+
requires writing a renderer function and decorating it — no changes to
6+
``__format__``, ``factory.py``, or ``statemachine.py``.
7+
8+
A module-level :data:`formatter` instance is the single public entry point::
9+
10+
from statemachine.contrib.diagram import formatter
11+
12+
print(formatter.render(sm, "mermaid"))
13+
14+
@formatter.register_format("plantuml")
15+
def _render_plantuml(machine):
16+
...
17+
"""
18+
19+
from typing import TYPE_CHECKING
20+
from typing import Callable
21+
from typing import Dict
22+
from typing import List
23+
24+
if TYPE_CHECKING:
25+
from typing import Union
26+
27+
from statemachine.statemachine import StateChart
28+
29+
MachineRef = Union["StateChart", "type[StateChart]"]
30+
31+
32+
class Formatter:
33+
"""Unified facade for rendering state machines in multiple text formats."""
34+
35+
def __init__(self) -> None:
36+
self._formats: Dict[str, "Callable[[MachineRef], str]"] = {}
37+
38+
def register_format(
39+
self, *names: str
40+
) -> "Callable[[Callable[[MachineRef], str]], Callable[[MachineRef], str]]":
41+
"""Decorator factory that registers a renderer under one or more format names.
42+
43+
Usage::
44+
45+
@formatter.register_format("md", "markdown")
46+
def _render_md(machine_or_class):
47+
...
48+
"""
49+
50+
def decorator(
51+
fn: "Callable[[MachineRef], str]",
52+
) -> "Callable[[MachineRef], str]":
53+
for name in names:
54+
self._formats[name] = fn
55+
return fn
56+
57+
return decorator
58+
59+
def render(self, machine_or_class: "MachineRef", fmt: str) -> str:
60+
"""Render a state machine in the given text format.
61+
62+
Args:
63+
machine_or_class: A ``StateChart`` instance or class.
64+
fmt: Format name (e.g., ``"mermaid"``, ``"dot"``, ``"md"``).
65+
Empty string falls back to ``repr()``.
66+
67+
Raises:
68+
ValueError: If ``fmt`` is not registered.
69+
"""
70+
if fmt == "":
71+
return repr(machine_or_class)
72+
73+
renderer_fn = self._formats.get(fmt)
74+
if renderer_fn is None:
75+
primary = sorted({self._primary_name(fn) for fn in set(self._formats.values())})
76+
raise ValueError(
77+
f"Unsupported format: {fmt!r}. Use {', '.join(repr(n) for n in primary)}."
78+
)
79+
return renderer_fn(machine_or_class)
80+
81+
def supported_formats(self) -> List[str]:
82+
"""Return sorted list of all registered format names (including aliases)."""
83+
return sorted(self._formats)
84+
85+
def _primary_name(self, fn: "Callable[[MachineRef], str]") -> str:
86+
"""Return the first registered name for a given renderer function."""
87+
for name, registered_fn in self._formats.items():
88+
if registered_fn is fn:
89+
return name
90+
return "?" # pragma: no cover
91+
92+
93+
formatter = Formatter()
94+
"""Module-level :class:`Formatter` instance — the single public entry point."""
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Built-in format registrations
99+
# ---------------------------------------------------------------------------
100+
101+
102+
@formatter.register_format("dot")
103+
def _render_dot(machine_or_class: "MachineRef") -> str:
104+
from statemachine.contrib.diagram import DotGraphMachine
105+
106+
return DotGraphMachine(machine_or_class).get_graph().to_string() # type: ignore[no-any-return]
107+
108+
109+
@formatter.register_format("mermaid")
110+
def _render_mermaid(machine_or_class: "MachineRef") -> str:
111+
from statemachine.contrib.diagram import MermaidGraphMachine
112+
113+
return MermaidGraphMachine(machine_or_class).get_mermaid()
114+
115+
116+
@formatter.register_format("md", "markdown")
117+
def _render_md(machine_or_class: "MachineRef") -> str:
118+
from statemachine.contrib.diagram.extract import extract
119+
from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer
120+
121+
return TransitionTableRenderer().render(extract(machine_or_class), fmt="md")
122+
123+
124+
@formatter.register_format("rst")
125+
def _render_rst(machine_or_class: "MachineRef") -> str:
126+
from statemachine.contrib.diagram.extract import extract
127+
from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer
128+
129+
return TransitionTableRenderer().render(extract(machine_or_class), fmt="rst")

statemachine/factory.py

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,9 @@ def __init__(
9393
cls._setup()
9494

9595
def __format__(cls, fmt: str) -> str:
96-
if fmt == "mermaid":
97-
from .contrib.diagram import MermaidGraphMachine
98-
99-
return MermaidGraphMachine(cls).get_mermaid()
100-
elif fmt == "dot":
101-
from .contrib.diagram import DotGraphMachine
102-
103-
return DotGraphMachine(cls).get_graph().to_string() # type: ignore[no-any-return]
104-
elif fmt in ("md", "markdown"):
105-
from .contrib.diagram.extract import extract
106-
from .contrib.diagram.renderers.table import TransitionTableRenderer
107-
108-
return TransitionTableRenderer().render(extract(cls), fmt="md") # type: ignore[arg-type]
109-
elif fmt == "rst":
110-
from .contrib.diagram.extract import extract
111-
from .contrib.diagram.renderers.table import TransitionTableRenderer
112-
113-
return TransitionTableRenderer().render(extract(cls), fmt="rst") # type: ignore[arg-type]
114-
elif fmt == "":
115-
return repr(cls)
116-
else:
117-
raise ValueError(f"Unsupported format: {fmt!r}. Use 'dot', 'mermaid', 'md', or 'rst'.")
96+
from .contrib.diagram.formatter import formatter
97+
98+
return formatter.render(cls, fmt) # type: ignore[arg-type]
11899

119100
def _initials_by_document_order( # noqa: C901
120101
cls, states: List[State], parent: "State | None" = None, order: int = 1

statemachine/statemachine.py

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -240,28 +240,9 @@ def __repr__(self):
240240
)
241241

242242
def __format__(self, fmt: str) -> str:
243-
if fmt == "mermaid":
244-
from .contrib.diagram import MermaidGraphMachine
245-
246-
return MermaidGraphMachine(self).get_mermaid()
247-
elif fmt == "dot":
248-
from .contrib.diagram import DotGraphMachine
249-
250-
return DotGraphMachine(self).get_graph().to_string() # type: ignore[no-any-return]
251-
elif fmt in ("md", "markdown"):
252-
from .contrib.diagram.extract import extract
253-
from .contrib.diagram.renderers.table import TransitionTableRenderer
254-
255-
return TransitionTableRenderer().render(extract(self), fmt="md")
256-
elif fmt == "rst":
257-
from .contrib.diagram.extract import extract
258-
from .contrib.diagram.renderers.table import TransitionTableRenderer
259-
260-
return TransitionTableRenderer().render(extract(self), fmt="rst")
261-
elif fmt == "":
262-
return repr(self)
263-
else:
264-
raise ValueError(f"Unsupported format: {fmt!r}. Use 'dot', 'mermaid', 'md', or 'rst'.")
243+
from .contrib.diagram.formatter import formatter
244+
245+
return formatter.render(self, fmt)
265246

266247
def __getstate__(self):
267248
state = {k: v for k, v in self.__dict__.items() if not isinstance(v, InstanceState)}

tests/test_contrib_diagram.py

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

13301330

1331+
class TestFormatter:
1332+
"""Tests for the Formatter facade (render, register_format, supported_formats)."""
1333+
1334+
def test_render_mermaid(self):
1335+
from statemachine.contrib.diagram import formatter
1336+
1337+
from tests.examples.traffic_light_machine import TrafficLightMachine
1338+
1339+
result = formatter.render(TrafficLightMachine, "mermaid")
1340+
assert "stateDiagram-v2" in result
1341+
1342+
def test_render_dot(self):
1343+
from statemachine.contrib.diagram import formatter
1344+
1345+
from tests.examples.traffic_light_machine import TrafficLightMachine
1346+
1347+
result = formatter.render(TrafficLightMachine, "dot")
1348+
assert result.startswith("digraph TrafficLightMachine {")
1349+
1350+
def test_render_md(self):
1351+
from statemachine.contrib.diagram import formatter
1352+
1353+
from tests.examples.traffic_light_machine import TrafficLightMachine
1354+
1355+
result = formatter.render(TrafficLightMachine, "md")
1356+
assert "| State" in result
1357+
1358+
def test_render_markdown_alias(self):
1359+
from statemachine.contrib.diagram import formatter
1360+
1361+
from tests.examples.traffic_light_machine import TrafficLightMachine
1362+
1363+
assert formatter.render(TrafficLightMachine, "markdown") == formatter.render(
1364+
TrafficLightMachine, "md"
1365+
)
1366+
1367+
def test_render_rst(self):
1368+
from statemachine.contrib.diagram import formatter
1369+
1370+
from tests.examples.traffic_light_machine import TrafficLightMachine
1371+
1372+
result = formatter.render(TrafficLightMachine, "rst")
1373+
assert "+---" in result
1374+
1375+
def test_render_empty_repr_instance(self):
1376+
from statemachine.contrib.diagram import formatter
1377+
1378+
from tests.examples.traffic_light_machine import TrafficLightMachine
1379+
1380+
sm = TrafficLightMachine()
1381+
assert formatter.render(sm, "") == repr(sm)
1382+
1383+
def test_render_empty_repr_class(self):
1384+
from statemachine.contrib.diagram import formatter
1385+
1386+
from tests.examples.traffic_light_machine import TrafficLightMachine
1387+
1388+
assert formatter.render(TrafficLightMachine, "") == repr(TrafficLightMachine)
1389+
1390+
def test_render_invalid_raises(self):
1391+
from statemachine.contrib.diagram import formatter
1392+
1393+
with pytest.raises(ValueError, match="Unsupported format"):
1394+
formatter.render(object(), "invalid")
1395+
1396+
def test_supported_formats(self):
1397+
from statemachine.contrib.diagram import formatter
1398+
1399+
fmts = formatter.supported_formats()
1400+
assert "dot" in fmts
1401+
assert "mermaid" in fmts
1402+
assert "md" in fmts
1403+
assert "markdown" in fmts
1404+
assert "rst" in fmts
1405+
1406+
def test_register_custom_format(self):
1407+
from statemachine.contrib.diagram import formatter
1408+
1409+
@formatter.register_format("_test_custom")
1410+
def _render_custom(machine_or_class):
1411+
return "custom output"
1412+
1413+
try:
1414+
assert formatter.render(object(), "_test_custom") == "custom output"
1415+
finally:
1416+
formatter._formats.pop("_test_custom", None)
1417+
1418+
def test_register_format_with_aliases(self):
1419+
from statemachine.contrib.diagram import formatter
1420+
1421+
@formatter.register_format("_test_alias", "_test_alias2")
1422+
def _render_alias_test(machine_or_class):
1423+
return "alias output"
1424+
1425+
try:
1426+
assert formatter.render(object(), "_test_alias") == "alias output"
1427+
assert formatter.render(object(), "_test_alias2") == "alias output"
1428+
finally:
1429+
formatter._formats.pop("_test_alias", None)
1430+
formatter._formats.pop("_test_alias2", None)
1431+
1432+
def test_error_message_lists_primary_formats(self):
1433+
from statemachine.contrib.diagram import formatter
1434+
1435+
with pytest.raises(ValueError, match="'dot'") as exc_info:
1436+
formatter.render(object(), "nonexistent")
1437+
msg = str(exc_info.value)
1438+
# Should list primary names, not aliases
1439+
assert "'mermaid'" in msg
1440+
assert "'md'" in msg
1441+
assert "'rst'" in msg
1442+
# "markdown" is an alias, should not appear in error message
1443+
assert "'markdown'" not in msg
1444+
1445+
13311446
class TestDirectiveMermaidFormat:
13321447
"""Tests for the :format: mermaid Sphinx directive option."""
13331448

0 commit comments

Comments
 (0)