Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/diagram.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,11 +496,30 @@ what Graphviz wrote to stderr, such as
`Error: <stdin>: 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
```


Expand Down
5 changes: 3 additions & 2 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,9 @@ command line or the Sphinx directive) failed with `"dot" with args [...] returne
while the reason, such as `Error: <stdin>: 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).

Expand Down
1 change: 1 addition & 0 deletions statemachine/contrib/diagram/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion statemachine/contrib/diagram/graphviz.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Run the Graphviz ``dot`` program and report its failures."""

import errno
import subprocess

from statemachine.exceptions import StateMachineError
Expand All @@ -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.

Expand All @@ -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.
"""
Expand All @@ -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
Expand Down
52 changes: 51 additions & 1 deletion tests/test_contrib_diagram_graphviz.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Issue #649: ``run_graphviz`` errors that need no Graphviz installed."""

import errno
import subprocess
import sys

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading