Skip to content

Commit 9c90db1

Browse files
authored
pyreverse: better error messages for unsupported file formats (#5951)
* Pyreverse: better error messages for unsupported file formats * Apply suggestions from code review.
1 parent 6de7100 commit 9c90db1

File tree

6 files changed

+145
-15
lines changed

6 files changed

+145
-15
lines changed

ChangeLog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Release date: TBA
1717
Ref PyCQA/astroid#1360
1818
Closes #4826
1919

20+
* Output better error message if unsupported file formats are used with ``pyreverse``.
21+
22+
Closes #5950
23+
2024
* Fix pyreverse diagrams type hinting for classmethods and staticmethods.
2125

2226
* Fix pyreverse diagrams type hinting for methods returning None.

doc/whatsnew/2.13.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,3 +515,7 @@ Other Changes
515515
Closes #5333
516516

517517
* Fix type hints in class diagrams generated by pyreverse for class methods and methods returning None.
518+
519+
* Output better error message if unsupported file formats are used with ``pyreverse``.
520+
521+
Closes #5950

pylint/pyreverse/dot_printer.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from astroid import nodes
2020

2121
from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer
22-
from pylint.pyreverse.utils import check_graphviz_availability, get_annotation_label
22+
from pylint.pyreverse.utils import get_annotation_label
2323

2424
ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1"))
2525
SHAPES: Dict[NodeType, str] = {
@@ -152,7 +152,6 @@ def generate(self, outputfile: str) -> None:
152152
with open(dot_sourcepath, "w", encoding="utf8") as outfile:
153153
outfile.writelines(self.lines)
154154
if target not in graphviz_extensions:
155-
check_graphviz_availability()
156155
use_shell = sys.platform == "win32"
157156
subprocess.call(
158157
["dot", "-T", target, dot_sourcepath, "-o", outputfile],

pylint/pyreverse/main.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,20 @@
3232
from pylint.pyreverse import writer
3333
from pylint.pyreverse.diadefslib import DiadefsHandler
3434
from pylint.pyreverse.inspector import Linker, project_from_files
35-
from pylint.pyreverse.utils import check_graphviz_availability, insert_default_options
35+
from pylint.pyreverse.utils import (
36+
check_graphviz_availability,
37+
check_if_graphviz_supports_format,
38+
insert_default_options,
39+
)
40+
41+
DIRECTLY_SUPPORTED_FORMATS = (
42+
"dot",
43+
"vcg",
44+
"puml",
45+
"plantuml",
46+
"mmd",
47+
"html",
48+
)
3649

3750
OPTIONS = (
3851
(
@@ -139,7 +152,10 @@
139152
action="store",
140153
default="dot",
141154
metavar="<format>",
142-
help="create a *.<format> output file if format available.",
155+
help=(
156+
f"create a *.<format> output file if format is available. Available formats are: {', '.join(DIRECTLY_SUPPORTED_FORMATS)}. "
157+
f"Any other format will be tried to create by means of the 'dot' command line tool, which requires a graphviz installation."
158+
),
143159
),
144160
),
145161
(
@@ -205,15 +221,12 @@ def __init__(self, args: Iterable[str]):
205221
super().__init__(usage=__doc__)
206222
insert_default_options()
207223
args = self.load_command_line_configuration(args)
208-
if self.config.output_format not in (
209-
"dot",
210-
"vcg",
211-
"puml",
212-
"plantuml",
213-
"mmd",
214-
"html",
215-
):
224+
if self.config.output_format not in DIRECTLY_SUPPORTED_FORMATS:
216225
check_graphviz_availability()
226+
print(
227+
f"Format {self.config.output_format} is not supported natively. Pyreverse will try to generate it using Graphviz..."
228+
)
229+
check_if_graphviz_supports_format(self.config.output_format)
217230

218231
sys.exit(self.run(args))
219232

pylint/pyreverse/utils.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import os
2424
import re
2525
import shutil
26+
import subprocess
2627
import sys
2728
from typing import Optional, Union
2829

@@ -290,9 +291,33 @@ def check_graphviz_availability():
290291
from *.dot or *.gv into the final output format.
291292
"""
292293
if shutil.which("dot") is None:
294+
print("'Graphviz' needs to be installed for your chosen output format.")
295+
sys.exit(32)
296+
297+
298+
def check_if_graphviz_supports_format(output_format: str) -> None:
299+
"""Check if the ``dot`` command supports the requested output format.
300+
301+
This is needed if image output is desired and ``dot`` is used to convert
302+
from *.gv into the final output format.
303+
"""
304+
dot_output = subprocess.run(
305+
["dot", "-T?"], capture_output=True, check=False, encoding="utf-8"
306+
)
307+
match = re.match(
308+
pattern=r".*Use one of: (?P<formats>(\S*\s?)+)",
309+
string=dot_output.stderr.strip(),
310+
)
311+
if not match:
312+
print(
313+
"Unable to determine Graphviz supported output formats. "
314+
"Pyreverse will continue, but subsequent error messages "
315+
"regarding the output format may come from Graphviz directly."
316+
)
317+
return
318+
supported_formats = match.group("formats")
319+
if output_format not in supported_formats.split():
293320
print(
294-
"The requested output format is currently not available.\n"
295-
"Please install 'Graphviz' to have other output formats "
296-
"than 'dot' or 'vcg'."
321+
f"Format {output_format} is not supported by Graphviz. It supports: {supported_formats}"
297322
)
298323
sys.exit(32)

tests/pyreverse/test_main.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,39 @@
22
import os
33
import sys
44
from typing import Iterator
5+
from unittest import mock
56

67
import pytest
78

89
from pylint.lint import fix_import_path
10+
from pylint.pyreverse import main
911

1012
TEST_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
1113
PROJECT_ROOT_DIR = os.path.abspath(os.path.join(TEST_DATA_DIR, ".."))
1214

1315

16+
@pytest.fixture(name="mock_subprocess")
17+
def mock_utils_subprocess():
18+
with mock.patch("pylint.pyreverse.utils.subprocess") as mock_subprocess:
19+
yield mock_subprocess
20+
21+
22+
@pytest.fixture
23+
def mock_graphviz(mock_subprocess):
24+
mock_subprocess.run.return_value = mock.Mock(
25+
stderr=(
26+
'Format: "XYZ" not recognized. Use one of: '
27+
"bmp canon cgimage cmap cmapx cmapx_np dot dot_json eps exr fig gd "
28+
"gd2 gif gv icns ico imap imap_np ismap jp2 jpe jpeg jpg json json0 "
29+
"mp pct pdf pic pict plain plain-ext png pov ps ps2 psd sgi svg svgz "
30+
"tga tif tiff tk vdx vml vmlz vrml wbmp webp xdot xdot1.2 xdot1.4 xdot_json"
31+
)
32+
)
33+
with mock.patch("pylint.pyreverse.utils.shutil") as mock_shutil:
34+
mock_shutil.which.return_value = "/usr/bin/dot"
35+
yield
36+
37+
1438
@pytest.fixture(params=[PROJECT_ROOT_DIR, TEST_DATA_DIR])
1539
def setup_path(request) -> Iterator:
1640
current_sys_path = list(sys.path)
@@ -29,3 +53,64 @@ def test_project_root_in_sys_path():
2953
"""
3054
with fix_import_path([TEST_DATA_DIR]):
3155
assert sys.path == [PROJECT_ROOT_DIR]
56+
57+
58+
@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
59+
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
60+
@mock.patch("pylint.pyreverse.main.writer")
61+
@pytest.mark.usefixtures("mock_graphviz")
62+
def test_graphviz_supported_image_format(mock_writer, capsys):
63+
"""Test that Graphviz is used if the image format is supported."""
64+
with pytest.raises(SystemExit) as wrapped_sysexit:
65+
# we have to catch the SystemExit so the test execution does not stop
66+
main.Run(["-o", "png", TEST_DATA_DIR])
67+
# Check that the right info message is shown to the user
68+
assert (
69+
"Format png is not supported natively. Pyreverse will try to generate it using Graphviz..."
70+
in capsys.readouterr().out
71+
)
72+
# Check that pyreverse actually made the call to create the diagram and we exit cleanly
73+
mock_writer.DiagramWriter().write.assert_called_once()
74+
assert wrapped_sysexit.value.code == 0
75+
76+
77+
@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
78+
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
79+
@mock.patch("pylint.pyreverse.main.writer")
80+
@pytest.mark.usefixtures("mock_graphviz")
81+
def test_graphviz_cant_determine_supported_formats(
82+
mock_writer, mock_subprocess, capsys
83+
):
84+
"""Test that Graphviz is used if the image format is supported."""
85+
mock_subprocess.run.return_value.stderr = "..."
86+
with pytest.raises(SystemExit) as wrapped_sysexit:
87+
# we have to catch the SystemExit so the test execution does not stop
88+
main.Run(["-o", "png", TEST_DATA_DIR])
89+
# Check that the right info message is shown to the user
90+
assert (
91+
"Unable to determine Graphviz supported output formats."
92+
in capsys.readouterr().out
93+
)
94+
# Check that pyreverse actually made the call to create the diagram and we exit cleanly
95+
mock_writer.DiagramWriter().write.assert_called_once()
96+
assert wrapped_sysexit.value.code == 0
97+
98+
99+
@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
100+
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
101+
@mock.patch("pylint.pyreverse.main.writer", new=mock.MagicMock())
102+
@pytest.mark.usefixtures("mock_graphviz")
103+
def test_graphviz_unsupported_image_format(capsys):
104+
"""Test that Graphviz is used if the image format is supported."""
105+
with pytest.raises(SystemExit) as wrapped_sysexit:
106+
# we have to catch the SystemExit so the test execution does not stop
107+
main.Run(["-o", "somethingElse", TEST_DATA_DIR])
108+
# Check that the right info messages are shown to the user
109+
stdout = capsys.readouterr().out
110+
assert (
111+
"Format somethingElse is not supported natively. Pyreverse will try to generate it using Graphviz..."
112+
in stdout
113+
)
114+
assert "Format somethingElse is not supported by Graphviz. It supports:" in stdout
115+
# Check that we exited with the expected error code
116+
assert wrapped_sysexit.value.code == 32

0 commit comments

Comments
 (0)