Skip to content

pyreverse: better error messages for unsupported file formats #5951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Release date: TBA
Ref PyCQA/astroid#1360
Closes #4826

* Output better error message if unsupported file formats are used with ``pyreverse``.

Closes #5950

* Fix pyreverse diagrams type hinting for classmethods and staticmethods.

* Fix pyreverse diagrams type hinting for methods returning None.
Expand Down
4 changes: 4 additions & 0 deletions doc/whatsnew/2.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,7 @@ Other Changes
Closes #5333

* Fix type hints in class diagrams generated by pyreverse for class methods and methods returning None.

* Output better error message if unsupported file formats are used with ``pyreverse``.

Closes #5950
3 changes: 1 addition & 2 deletions pylint/pyreverse/dot_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from astroid import nodes

from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer
from pylint.pyreverse.utils import check_graphviz_availability, get_annotation_label
from pylint.pyreverse.utils import get_annotation_label

ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1"))
SHAPES: Dict[NodeType, str] = {
Expand Down Expand Up @@ -152,7 +152,6 @@ def generate(self, outputfile: str) -> None:
with open(dot_sourcepath, "w", encoding="utf8") as outfile:
outfile.writelines(self.lines)
if target not in graphviz_extensions:
check_graphviz_availability()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already check that in the Run class. No need to do it twice.

use_shell = sys.platform == "win32"
subprocess.call(
["dot", "-T", target, dot_sourcepath, "-o", outputfile],
Expand Down
33 changes: 23 additions & 10 deletions pylint/pyreverse/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,20 @@
from pylint.pyreverse import writer
from pylint.pyreverse.diadefslib import DiadefsHandler
from pylint.pyreverse.inspector import Linker, project_from_files
from pylint.pyreverse.utils import check_graphviz_availability, insert_default_options
from pylint.pyreverse.utils import (
check_graphviz_availability,
check_if_graphviz_supports_format,
insert_default_options,
)

DIRECTLY_SUPPORTED_FORMATS = (
"dot",
"vcg",
"puml",
"plantuml",
"mmd",
"html",
)

OPTIONS = (
(
Expand Down Expand Up @@ -139,7 +152,10 @@
action="store",
default="dot",
metavar="<format>",
help="create a *.<format> output file if format available.",
help=(
f"create a *.<format> output file if format is available. Available formats are: {', '.join(DIRECTLY_SUPPORTED_FORMATS)}. "
f"Any other format will be tried to create by means of the 'dot' command line tool, which requires a graphviz installation."
),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did not state the directly supported output formats in the help output previously, I think this also makes it a bit clearer what is going on under the hood.

),
),
(
Expand Down Expand Up @@ -205,15 +221,12 @@ def __init__(self, args: Iterable[str]):
super().__init__(usage=__doc__)
insert_default_options()
args = self.load_command_line_configuration(args)
if self.config.output_format not in (
"dot",
"vcg",
"puml",
"plantuml",
"mmd",
"html",
):
if self.config.output_format not in DIRECTLY_SUPPORTED_FORMATS:
check_graphviz_availability()
print(
f"Format {self.config.output_format} is not supported natively. Pyreverse will try to generate it using Graphviz..."
)
check_if_graphviz_supports_format(self.config.output_format)

sys.exit(self.run(args))

Expand Down
32 changes: 30 additions & 2 deletions pylint/pyreverse/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import os
import re
import shutil
import subprocess
import sys
from typing import Optional, Union

Expand Down Expand Up @@ -292,7 +293,34 @@ def check_graphviz_availability():
if shutil.which("dot") is None:
print(
"The requested output format is currently not available.\n"
"Please install 'Graphviz' to have other output formats "
"than 'dot' or 'vcg'."
"Please install 'Graphviz' to have image output formats."
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message was outdated as we now support other output formats as well. Reiterating the directly supported output formats here is not necessary I think, so I kept it more general.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Please install 'Graphviz' to have image output formats."
"'Graphviz' need to be installed for your chosen output format."

)
sys.exit(32)


def check_if_graphviz_supports_format(output_format: str) -> None:
"""Check if the ``dot`` command supports the requested output format.

This is needed if image output is desired and ``dot`` is used to convert
from *.gv into the final output format.
"""
dot_output = subprocess.run(
["dot", "-T?"], capture_output=True, check=False, encoding="utf-8"
)
match = re.match(
pattern=r".*Use one of: (?P<formats>(\S*\s?)+)",
string=dot_output.stderr.strip(),
)
if not match:
print(
"Unable to determine Graphviz supported output formats. "
"Pyreverse will continue, but subsequent error messages "
"regarding the output format may come from Graphviz directly."
)
return
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mypy forced me to include this. πŸ˜‰
Relying on the textual output of the dot command to determine the supported output formats is probably not the best solution, but I'd argue the tradeoff for more user friendly error messages and ability to fail fast is worth it.
But if you have concerns or feedback please feel free to address it.

supported_formats = match.group("formats")
if output_format not in supported_formats.split():
print(
f"Format {output_format} is not supported by Graphviz. It supports: {supported_formats}"
)
sys.exit(32)
83 changes: 83 additions & 0 deletions tests/pyreverse/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,39 @@
import os
import sys
from typing import Iterator
from unittest import mock

import pytest

from pylint.lint import fix_import_path
from pylint.pyreverse import main

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


@pytest.fixture(name="mock_subprocess")
def mock_utils_subprocess():
with mock.patch("pylint.pyreverse.utils.subprocess") as mock_subprocess:
yield mock_subprocess


@pytest.fixture
def mock_graphviz(mock_subprocess):
mock_subprocess.run.return_value = mock.Mock(
stderr=(
'Format: "XYZ" not recognized. Use one of: '
"bmp canon cgimage cmap cmapx cmapx_np dot dot_json eps exr fig gd "
"gd2 gif gv icns ico imap imap_np ismap jp2 jpe jpeg jpg json json0 "
"mp pct pdf pic pict plain plain-ext png pov ps ps2 psd sgi svg svgz "
"tga tif tiff tk vdx vml vmlz vrml wbmp webp xdot xdot1.2 xdot1.4 xdot_json"
)
)
with mock.patch("pylint.pyreverse.utils.shutil") as mock_shutil:
mock_shutil.which.return_value = "/usr/bin/dot"
yield


@pytest.fixture(params=[PROJECT_ROOT_DIR, TEST_DATA_DIR])
def setup_path(request) -> Iterator:
current_sys_path = list(sys.path)
Expand All @@ -29,3 +53,62 @@ def test_project_root_in_sys_path():
"""
with fix_import_path([TEST_DATA_DIR]):
assert sys.path == [PROJECT_ROOT_DIR]


@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.writer")
@pytest.mark.usefixtures("mock_graphviz")
def test_graphviz_supported_image_format(mock_writer, capsys):
"""Test that Graphviz is used if the image format is supported."""
with pytest.raises(SystemExit):
# we have to catch the SystemExit so the test execution does not stop
main.Run(["-o", "png", TEST_DATA_DIR])
# Check that the right info message is shown to the user
assert (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with pytest.raises(SystemExit):
# we have to catch the SystemExit so the test execution does not stop
main.Run(["-o", "png", TEST_DATA_DIR])
# Check that the right info message is shown to the user
assert (
with pytest.raises(SystemExit) as exc_info:
# we have to catch the SystemExit so the test execution does not stop
main.Run(["-o", "png", TEST_DATA_DIR])
# Check that the right info message is shown to the user
assert exc_info.value.code == 2 # (?)
assert (

I'll let you decide if it's worth it, there's 3 similar assert that could be done if you want to assert that the error code is correct.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I included the check.

"Format png is not supported natively. Pyreverse will try to generate it using Graphviz..."
in capsys.readouterr().out
)
# Check that pyreverse actually made the call to create the diagram
mock_writer.DiagramWriter().write.assert_called_once()


@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.writer")
@pytest.mark.usefixtures("mock_graphviz")
def test_graphviz_cant_determine_supported_formats(
mock_writer, mock_subprocess, capsys
):
"""Test that Graphviz is used if the image format is supported."""
mock_subprocess.run.return_value.stderr = "..."
with pytest.raises(SystemExit):
# we have to catch the SystemExit so the test execution does not stop
main.Run(["-o", "png", TEST_DATA_DIR])
# Check that the right info message is shown to the user
assert (
"Unable to determine Graphviz supported output formats."
in capsys.readouterr().out
)
# Check that pyreverse actually made the call to create the diagram
mock_writer.DiagramWriter().write.assert_called_once()


@mock.patch("pylint.pyreverse.main.Linker", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.DiadefsHandler", new=mock.MagicMock())
@mock.patch("pylint.pyreverse.main.writer", new=mock.MagicMock())
@pytest.mark.usefixtures("mock_graphviz")
def test_graphviz_unsupported_image_format(capsys):
"""Test that Graphviz is used if the image format is supported."""
with pytest.raises(SystemExit) as wrapped_sysexit:
# we have to catch the SystemExit so the test execution does not stop
main.Run(["-o", "somethingElse", TEST_DATA_DIR])
# Check that the right info messages are shown to the user
stdout = capsys.readouterr().out
assert (
"Format somethingElse is not supported natively. Pyreverse will try to generate it using Graphviz..."
in stdout
)
assert "Format somethingElse is not supported by Graphviz. It supports:" in stdout
# Check that we exited with the expected error code
assert wrapped_sysexit.value.code == 32