diff --git a/docs/diagram.md b/docs/diagram.md index 6c49772c..c6d68265 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -496,11 +496,30 @@ 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. +A missing program raises {class}`~statemachine.contrib.diagram.GraphvizNotFoundError`, a +subclass of both `GraphvizError` and {class}`FileNotFoundError`. Catch `GraphvizError` to +handle every case; code that skips diagrams with `except FileNotFoundError` when Graphviz is +not installed keeps working: + +```py +>>> from statemachine.contrib.diagram import GraphvizError +>>> from statemachine.contrib.diagram import GraphvizNotFoundError + +>>> issubclass(GraphvizNotFoundError, GraphvizError) +True + +>>> issubclass(GraphvizNotFoundError, FileNotFoundError) +True + +``` + 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 + +.. autoexception:: statemachine.contrib.diagram.GraphvizNotFoundError ``` diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 02c26e82..78db526d 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -422,8 +422,9 @@ command line or the Sphinx directive) failed with `"dot" with args [...] returne while the reason, such as `Error: : 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. +Sphinx directive reports the same text in its warning. A missing `dot` says so, with a link +to the Graphviz download page, and raises `GraphvizNotFoundError`, a subclass of both +`GraphvizError` and `FileNotFoundError`, so `except FileNotFoundError` still catches it. Reported by [@barnettwilliam](https://github.com/barnettwilliam). diff --git a/statemachine/contrib/diagram/__init__.py b/statemachine/contrib/diagram/__init__.py index 4b37b8b0..a0f0db30 100644 --- a/statemachine/contrib/diagram/__init__.py +++ b/statemachine/contrib/diagram/__init__.py @@ -6,6 +6,7 @@ from .extract import extract from .formatter import formatter as formatter from .graphviz import GraphvizError as GraphvizError +from .graphviz import GraphvizNotFoundError as GraphvizNotFoundError from .graphviz import run_graphviz if TYPE_CHECKING: diff --git a/statemachine/contrib/diagram/graphviz.py b/statemachine/contrib/diagram/graphviz.py index 971607dd..565f7f65 100644 --- a/statemachine/contrib/diagram/graphviz.py +++ b/statemachine/contrib/diagram/graphviz.py @@ -1,5 +1,6 @@ """Run the Graphviz ``dot`` program and report its failures.""" +import errno import subprocess from statemachine.exceptions import StateMachineError @@ -11,6 +12,18 @@ class GraphvizError(StateMachineError): """Raised when the Graphviz ``dot`` program cannot run or fails to render a graph.""" +class GraphvizNotFoundError(GraphvizError, FileNotFoundError): + """Raised when the Graphviz program is not found in ``PATH``. + + Also a :class:`FileNotFoundError`, so ``except FileNotFoundError`` catches a missing + Graphviz. + """ + + def __init__(self, message: str): + super().__init__(message) + self.errno = errno.ENOENT + + def run_graphviz(dot_source: str, fmt: str, prog: str = "dot") -> bytes: """Render ``dot_source`` with Graphviz and return the output bytes. @@ -20,6 +33,7 @@ def run_graphviz(dot_source: str, fmt: str, prog: str = "dot") -> bytes: prog: Graphviz layout program to run, looked up on ``PATH``. Raises: + GraphvizNotFoundError: If ``prog`` is not found in ``PATH``. GraphvizError: If ``prog`` cannot be run or exits with a non-zero code. The message carries what Graphviz wrote to stderr. """ @@ -32,7 +46,7 @@ def run_graphviz(dot_source: str, fmt: str, prog: str = "dot") -> bytes: creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except FileNotFoundError as err: - raise GraphvizError( + raise GraphvizNotFoundError( f"{prog!r} not found in PATH. Install Graphviz from {GRAPHVIZ_DOWNLOAD_URL} " "to generate diagrams." ) from err diff --git a/tests/test_contrib_diagram_graphviz.py b/tests/test_contrib_diagram_graphviz.py index ae35c5ac..12793e17 100644 --- a/tests/test_contrib_diagram_graphviz.py +++ b/tests/test_contrib_diagram_graphviz.py @@ -1,5 +1,6 @@ """Issue #649: ``run_graphviz`` errors that need no Graphviz installed.""" +import errno import subprocess import sys @@ -46,6 +47,37 @@ def test_run_graphviz_without_dot_in_path(self, monkeypatch, tmp_path): assert "graphviz.org/download" in str(excinfo.value) assert isinstance(excinfo.value.__cause__, FileNotFoundError) + def test_missing_dot_is_still_a_file_not_found_error(self, monkeypatch, tmp_path): + from statemachine.contrib.diagram import GraphvizError + from statemachine.contrib.diagram import GraphvizNotFoundError + from statemachine.contrib.diagram.graphviz import run_graphviz + + monkeypatch.setenv("PATH", str(tmp_path)) + + with pytest.raises(FileNotFoundError) as excinfo: + run_graphviz("digraph {}", "svg") + + error = excinfo.value + assert isinstance(error, GraphvizNotFoundError) + assert isinstance(error, GraphvizError) + assert error.errno == errno.ENOENT + assert str(error) == ( + "'dot' not found in PATH. Install Graphviz from https://graphviz.org/download/ " + "to generate diagrams." + ) + + def test_missing_dot_from_format_svg_is_a_file_not_found_error(self, monkeypatch, tmp_path): + from statemachine.contrib.diagram import GraphvizNotFoundError + + from tests.examples.traffic_light_machine import TrafficLightMachine + + monkeypatch.setenv("PATH", str(tmp_path)) + + with pytest.raises(FileNotFoundError) as excinfo: + format(TrafficLightMachine(), "svg") + + assert isinstance(excinfo.value, GraphvizNotFoundError) + @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 @@ -54,15 +86,33 @@ def test_run_graphviz_with_dot_that_cannot_run(self, monkeypatch, tmp_path): (tmp_path / "dot").write_text("") monkeypatch.setenv("PATH", str(tmp_path)) - with pytest.raises(GraphvizError, match="'dot' could not be run: .*Permission denied"): + with pytest.raises( + GraphvizError, match="'dot' could not be run: .*Permission denied" + ) as excinfo: run_graphviz("digraph {}", "svg") + assert not isinstance(excinfo.value, FileNotFoundError) + 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_graphviz_error_for_a_failing_dot_is_not_a_file_not_found_error(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, 1, b"", b"boom"), + ) + + with pytest.raises(GraphvizError) as excinfo: + run_graphviz("digraph {}", "svg") + + assert not isinstance(excinfo.value, OSError) + 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"