From 9f4c896e06688174045d68c5154ebcfabde3c2cb Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 12 Oct 2026 13:42:34 -0300 Subject: [PATCH 1/2] fix: report Graphviz stderr when dot fails to render a diagram pydot printed dot's stderr to stdout and raised an AssertionError that only carried the exit code, so the svg format, _repr_svg_, the command line and the Sphinx directive all failed with 'returned code: 1' and no reason. Every path now runs dot directly, feeding the DOT source on stdin, and raises GraphvizError with the requested format and what Graphviz wrote to stderr. A missing dot binary reports that it is not on PATH, with a link to the Graphviz download page. Fixes #649 Signed-off-by: Fernando Macedo --- docs/diagram.md | 18 ++++ docs/images/readme_trafficlightmachine.png | Bin 14537 -> 12501 bytes docs/releases/3.2.2.md | 14 ++++ statemachine/contrib/diagram/__init__.py | 13 ++- statemachine/contrib/diagram/formatter.py | 6 +- statemachine/contrib/diagram/graphviz.py | 52 ++++++++++++ tests/scrape_images.py | 4 +- tests/test_contrib_diagram.py | 92 +++++++++++++++++++++ tests/test_contrib_diagram_graphviz.py | 72 ++++++++++++++++ 9 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 statemachine/contrib/diagram/graphviz.py create mode 100644 tests/test_contrib_diagram_graphviz.py diff --git a/docs/diagram.md b/docs/diagram.md index a6512a75..6c49772c 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -486,6 +486,24 @@ JupyterLab cells — no extra code needed: ![Approval machine on JupyterLab](images/lab_approval_machine_accepted.png) +## When Graphviz fails + +Every path that runs `dot` (the `svg` format, `_repr_svg_()` in Jupyter, the command line +and the Sphinx directive) raises +{class}`~statemachine.contrib.diagram.GraphvizError` when the program is missing from +`PATH`, cannot run or exits with an error. The message carries the format that was requested and +what Graphviz wrote to stderr, such as +`Error: : syntax error in line 25 near ','`. The Sphinx directive reports the same +text as a build warning. + +Formats requested directly on the `pydot.Dot` object (`write_png()`, `create_svg()`, ...) +are run by pydot, which reports only the exit code. + +```{eval-rst} +.. autoexception:: statemachine.contrib.diagram.GraphvizError +``` + + ## Online generation (QuickChart) If you prefer not to install Graphviz locally, you can generate diagrams diff --git a/docs/images/readme_trafficlightmachine.png b/docs/images/readme_trafficlightmachine.png index 2f65f4110932850e093a1b47865e7a640c1e1ea3..2df8eb5f9eabb77bcd78467bcc671280bdfaa6d9 100644 GIT binary patch literal 12501 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: syntax error in line 25 near ','`, was printed to +stdout by pydot, apart from the exception and from the Sphinx warning. These paths now run `dot` directly and raise +`GraphvizError` with the format that was requested and what Graphviz wrote to stderr. The +Sphinx directive reports the same text in its warning, and a missing `dot` says so, with a +link to the Graphviz download page. + +Reported by [@barnettwilliam](https://github.com/barnettwilliam). + +[#649](https://github.com/fgmacedo/python-statemachine/issues/649). diff --git a/statemachine/contrib/diagram/__init__.py b/statemachine/contrib/diagram/__init__.py index a5f5c368..d3b5f56b 100644 --- a/statemachine/contrib/diagram/__init__.py +++ b/statemachine/contrib/diagram/__init__.py @@ -5,6 +5,8 @@ from .extract import extract from .formatter import formatter as formatter +from .graphviz import GraphvizError as GraphvizError +from .graphviz import run_graphviz if TYPE_CHECKING: from .renderers.dot import DotRendererConfig @@ -208,12 +210,17 @@ def write_image(qualname, out, events=None, fmt=None): with open(out, "w") as f: f.write(text) else: - graph = DotGraphMachine(machine).get_graph() + dot_source = DotGraphMachine(machine).get_graph().to_string() if out == "-": - sys.stdout.buffer.write(graph.create_svg()) # type: ignore[attr-defined] + sys.stdout.buffer.write(run_graphviz(dot_source, "svg")) else: out_extension = out.rsplit(".", 1)[1] - graph.write(out, format=out_extension) + if out_extension == "raw": + image = dot_source.encode("utf-8") + else: + image = run_graphviz(dot_source, out_extension) + with open(out, "wb") as f: + f.write(image) def main(argv=None): diff --git a/statemachine/contrib/diagram/formatter.py b/statemachine/contrib/diagram/formatter.py index 2027be31..f95e2abc 100644 --- a/statemachine/contrib/diagram/formatter.py +++ b/statemachine/contrib/diagram/formatter.py @@ -19,6 +19,8 @@ def _render_plantuml(machine): from collections.abc import Callable from typing import TYPE_CHECKING +from .graphviz import run_graphviz + if TYPE_CHECKING: from typing import TypeAlias @@ -108,8 +110,8 @@ def _render_dot(machine_or_class: "MachineRef") -> str: def _render_svg(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram import DotGraphMachine - svg_bytes: bytes = DotGraphMachine(machine_or_class).get_graph().create_svg() # type: ignore[attr-defined] - return svg_bytes.decode("utf-8") + dot_source = DotGraphMachine(machine_or_class).get_graph().to_string() + return run_graphviz(dot_source, "svg").decode("utf-8") @formatter.register_format("mermaid") diff --git a/statemachine/contrib/diagram/graphviz.py b/statemachine/contrib/diagram/graphviz.py new file mode 100644 index 00000000..971607dd --- /dev/null +++ b/statemachine/contrib/diagram/graphviz.py @@ -0,0 +1,52 @@ +"""Run the Graphviz ``dot`` program and report its failures.""" + +import subprocess + +from statemachine.exceptions import StateMachineError + +GRAPHVIZ_DOWNLOAD_URL = "https://graphviz.org/download/" + + +class GraphvizError(StateMachineError): + """Raised when the Graphviz ``dot`` program cannot run or fails to render a graph.""" + + +def run_graphviz(dot_source: str, fmt: str, prog: str = "dot") -> bytes: + """Render ``dot_source`` with Graphviz and return the output bytes. + + Args: + dot_source: Graph in the DOT language. + fmt: Graphviz output format, passed as ``-T`` (``"svg"``, ``"png"``, ...). + prog: Graphviz layout program to run, looked up on ``PATH``. + + Raises: + GraphvizError: If ``prog`` cannot be run or exits with a non-zero code. + The message carries what Graphviz wrote to stderr. + """ + command = [prog, f"-T{fmt}"] + try: + completed = subprocess.run( + command, + input=dot_source.encode("utf-8"), + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except FileNotFoundError as err: + raise GraphvizError( + f"{prog!r} not found in PATH. Install Graphviz from {GRAPHVIZ_DOWNLOAD_URL} " + "to generate diagrams." + ) from err + except OSError as err: + raise GraphvizError(f"{prog!r} could not be run: {err}") from err + + if completed.returncode != 0: + if completed.returncode < 0: + message = f"{' '.join(command)} was terminated by signal {-completed.returncode}." + else: + message = f"{' '.join(command)} failed with exit code {completed.returncode}." + stderr = completed.stderr.decode("utf-8", errors="replace").strip() + if stderr: + message = f"{message}\n{stderr}" + raise GraphvizError(message) + + return completed.stdout diff --git a/tests/scrape_images.py b/tests/scrape_images.py index 2547b536..16ca8d88 100644 --- a/tests/scrape_images.py +++ b/tests/scrape_images.py @@ -1,7 +1,7 @@ import os import re -from statemachine.contrib.diagram import DotGraphMachine +from statemachine.contrib.diagram import formatter from statemachine.factory import StateMachineMetaclass from .helpers import import_module_by_path @@ -31,7 +31,7 @@ def _get_module(self, src_file): def generate_image(self, sm_class, original_path): image_path = self.re_replace_png_extension.sub(".svg", original_path) - svg = DotGraphMachine(sm_class).get_graph().create_svg().decode() + svg = formatter.render(sm_class, "svg") with open(image_path, "w") as f: f.write(svg) return image_path diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index eb63df4a..93e84168 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -1896,3 +1896,95 @@ def test_mermaid_render_failure_returns_warning(self, tmp_path): directive.state_machine.reporter.warning.assert_called_once() call_args = directive.state_machine.reporter.warning.call_args assert "failed to generate mermaid" in call_args[0][0] + + +class TestGraphvizErrors: + """Issue #649: a failing ``dot`` must report what Graphviz wrote to stderr.""" + + _QUALNAME = "tests.examples.traffic_light_machine.TrafficLightMachine" + _MALFORMED_DOT = "digraph { a -> ; }" + + @pytest.fixture() + def malformed_dot(self): + with mock.patch("pydot.Dot.to_string", return_value=self._MALFORMED_DOT): + yield + + def test_run_graphviz_reports_stderr_and_format(self): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram.graphviz import run_graphviz + + with pytest.raises(GraphvizError) as excinfo: + run_graphviz(self._MALFORMED_DOT, "svg") + + message = str(excinfo.value) + assert "dot -Tsvg" in message + assert "exit code 1" in message + assert "syntax error" in message + assert "near ';'" in message + + def test_run_graphviz_succeeds(self): + from statemachine.contrib.diagram.graphviz import run_graphviz + + svg = run_graphviz("digraph { a -> b }", "svg") + assert svg.startswith(b" b }", "png") + assert png.startswith(b"\x89PNG") + + @pytest.mark.usefixtures("malformed_dot") + def test_format_svg_surfaces_stderr(self): + from statemachine.contrib.diagram import GraphvizError + + from tests.examples.traffic_light_machine import TrafficLightMachine + + with pytest.raises(GraphvizError, match="syntax error"): + format(TrafficLightMachine(), "svg") + + @pytest.mark.usefixtures("malformed_dot") + def test_repr_svg_surfaces_stderr(self): + from statemachine.contrib.diagram import GraphvizError + + from tests.examples.traffic_light_machine import TrafficLightMachine + + with pytest.raises(GraphvizError, match="syntax error"): + TrafficLightMachine()._repr_svg_() + + @pytest.mark.usefixtures("malformed_dot") + def test_write_image_to_file_surfaces_stderr(self, tmp_path): + from statemachine.contrib.diagram import GraphvizError + + out = tmp_path / "sm.png" + with pytest.raises(GraphvizError, match=r"(?s)dot -Tpng failed.*syntax error"): + main([self._QUALNAME, str(out)]) + + assert not out.exists() + + @pytest.mark.usefixtures("malformed_dot") + def test_write_image_to_stdout_surfaces_stderr(self, capsysbinary): + from statemachine.contrib.diagram import GraphvizError + + with pytest.raises(GraphvizError, match=r"(?s)dot -Tsvg failed.*syntax error"): + main([self._QUALNAME, "-"]) + + assert capsysbinary.readouterr().out == b"" + + @pytest.mark.usefixtures("malformed_dot") + def test_sphinx_warning_surfaces_stderr(self, tmp_path): + from statemachine.contrib.diagram.sphinx_ext import StateMachineDiagram + + directive = StateMachineDiagram.__new__(StateMachineDiagram) + directive.options = {} + directive.lineno = 1 + directive.state_machine = mock.MagicMock() + directive.state = mock.MagicMock() + directive.state.document.settings.env.app.outdir = str(tmp_path) + directive.content_offset = 0 + directive.arguments = [self._QUALNAME] + + result = directive.run() + + assert len(result) == 1 + directive.state_machine.reporter.warning.assert_called_once() + message = directive.state_machine.reporter.warning.call_args[0][0] + assert "failed to generate diagram" in message + assert "dot -Tsvg failed" in message + assert "syntax error" in message diff --git a/tests/test_contrib_diagram_graphviz.py b/tests/test_contrib_diagram_graphviz.py new file mode 100644 index 00000000..ae35c5ac --- /dev/null +++ b/tests/test_contrib_diagram_graphviz.py @@ -0,0 +1,72 @@ +"""Issue #649: ``run_graphviz`` errors that need no Graphviz installed.""" + +import subprocess +import sys + +import pytest +from statemachine.contrib.diagram import main + + +class TestGraphvizWithoutDot: + _QUALNAME = "tests.examples.traffic_light_machine.TrafficLightMachine" + + def test_run_graphviz_without_stderr_output(self, monkeypatch): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram.graphviz import run_graphviz + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess(args, 3, b"", b""), + ) + + with pytest.raises(GraphvizError, match=r"dot -Tpng failed with exit code 3\.$"): + run_graphviz("digraph {}", "png") + + def test_run_graphviz_terminated_by_signal(self, monkeypatch): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram.graphviz import run_graphviz + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess(args, -9, b"", b"killed"), + ) + + with pytest.raises(GraphvizError, match=r"dot -Tsvg was terminated by signal 9\.\nkilled"): + run_graphviz("digraph {}", "svg") + + def test_run_graphviz_without_dot_in_path(self, monkeypatch, tmp_path): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram.graphviz import run_graphviz + + monkeypatch.setenv("PATH", str(tmp_path)) + + with pytest.raises(GraphvizError, match="'dot' not found in PATH") as excinfo: + run_graphviz("digraph {}", "svg") + + assert "graphviz.org/download" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, FileNotFoundError) + + @pytest.mark.skipif(sys.platform == "win32", reason="file mode bits are POSIX only") + def test_run_graphviz_with_dot_that_cannot_run(self, monkeypatch, tmp_path): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram.graphviz import run_graphviz + + (tmp_path / "dot").write_text("") + monkeypatch.setenv("PATH", str(tmp_path)) + + with pytest.raises(GraphvizError, match="'dot' could not be run: .*Permission denied"): + run_graphviz("digraph {}", "svg") + + def test_graphviz_error_is_a_statemachine_error(self): + from statemachine.contrib.diagram import GraphvizError + from statemachine.exceptions import StateMachineError + + assert issubclass(GraphvizError, StateMachineError) + + def test_write_image_raw_writes_dot_source_without_running_dot(self, tmp_path, monkeypatch): + monkeypatch.setenv("PATH", str(tmp_path)) + out = tmp_path / "sm.raw" + + main([self._QUALNAME, str(out)]) + + assert out.read_text(encoding="utf-8").startswith("digraph TrafficLightMachine {") From 01023d4f70cc12252cfc8f726633bc88b65bb843 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 13 Oct 2026 09:55:07 -0300 Subject: [PATCH 2/2] refactor: split image rendering out of write_image Signed-off-by: Fernando Macedo --- statemachine/contrib/diagram/__init__.py | 23 ++++++++++++----------- tests/test_contrib_diagram.py | 6 ++++-- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/statemachine/contrib/diagram/__init__.py b/statemachine/contrib/diagram/__init__.py index d3b5f56b..4b37b8b0 100644 --- a/statemachine/contrib/diagram/__init__.py +++ b/statemachine/contrib/diagram/__init__.py @@ -209,18 +209,19 @@ def write_image(qualname, out, events=None, fmt=None): else: with open(out, "w") as f: f.write(text) + elif out == "-": + sys.stdout.buffer.write(_render_image(machine, "svg")) else: - dot_source = DotGraphMachine(machine).get_graph().to_string() - if out == "-": - sys.stdout.buffer.write(run_graphviz(dot_source, "svg")) - else: - out_extension = out.rsplit(".", 1)[1] - if out_extension == "raw": - image = dot_source.encode("utf-8") - else: - image = run_graphviz(dot_source, out_extension) - with open(out, "wb") as f: - f.write(image) + image = _render_image(machine, out.rsplit(".", 1)[1]) + with open(out, "wb") as f: + f.write(image) + + +def _render_image(machine, fmt: str) -> bytes: + dot_source: str = DotGraphMachine(machine).get_graph().to_string() + if fmt == "raw": + return dot_source.encode("utf-8") + return run_graphviz(dot_source, fmt) def main(argv=None): diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 93e84168..52ddd737 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -1936,8 +1936,9 @@ def test_format_svg_surfaces_stderr(self): from tests.examples.traffic_light_machine import TrafficLightMachine + sm = TrafficLightMachine() with pytest.raises(GraphvizError, match="syntax error"): - format(TrafficLightMachine(), "svg") + format(sm, "svg") @pytest.mark.usefixtures("malformed_dot") def test_repr_svg_surfaces_stderr(self): @@ -1945,8 +1946,9 @@ def test_repr_svg_surfaces_stderr(self): from tests.examples.traffic_light_machine import TrafficLightMachine + sm = TrafficLightMachine() with pytest.raises(GraphvizError, match="syntax error"): - TrafficLightMachine()._repr_svg_() + sm._repr_svg_() @pytest.mark.usefixtures("malformed_dot") def test_write_image_to_file_surfaces_stderr(self, tmp_path):