Skip to content

Commit a37c643

Browse files
Fix various problems with --import-graph filename parsing (#4259)
Avoids backwards-incompatible changes. - Raise error if graphvis is not installed (instead of reporting success) - Fix tempfile creation bug when outputfile includes directories - Fix bug when using file extension that isn't 3 characters long - Fix confusing help text - Rename deprecated .dot extension to .gv - Default to .png if no extension is specified * Add typing to modified functions (and ignore mypy thinking codecs.open() returns an int) Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent 42bf3b0 commit a37c643

File tree

5 files changed

+65
-34
lines changed

5 files changed

+65
-34
lines changed

CONTRIBUTORS.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,5 @@ contributors:
462462
* Mark Byrne: contributor
463463

464464
* Konstantina Saketou: contributor
465+
466+
* Andrew Howe: contributor

ChangeLog

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ Release date: TBA
8080

8181
* Improve check if class is subscriptable PEP585
8282

83+
* Fix documentation and filename handling of --import-graph
84+
8385
* Fix false-positive for ``unused-import`` on class keyword arguments
8486

8587
Closes #3202

pylint/checkers/imports.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import os
4545
import sys
4646
from distutils import sysconfig
47+
from typing import Dict, List
4748

4849
import astroid
4950

@@ -56,7 +57,7 @@
5657
from pylint.exceptions import EmptyReportError
5758
from pylint.graph import DotBackend, get_cycles
5859
from pylint.interfaces import IAstroidChecker
59-
from pylint.reporters.ureports.nodes import Paragraph, VerbatimText
60+
from pylint.reporters.ureports.nodes import Paragraph, VerbatimText, VNode
6061
from pylint.utils import IsortDriver, get_global_option
6162

6263

@@ -172,10 +173,10 @@ def _repr_tree_defs(data, indent_str=None):
172173
return "\n".join(lines)
173174

174175

175-
def _dependencies_graph(filename, dep_info):
176+
def _dependencies_graph(filename: str, dep_info: Dict[str, List[str]]) -> str:
176177
"""write dependencies as a dot (graphviz) file"""
177178
done = {}
178-
printer = DotBackend(filename[:-4], rankdir="LR")
179+
printer = DotBackend(os.path.splitext(os.path.basename(filename))[0], rankdir="LR")
179180
printer.emit('URL="." node[shape="box"]')
180181
for modname, dependencies in sorted(dep_info.items()):
181182
done[modname] = 1
@@ -187,15 +188,15 @@ def _dependencies_graph(filename, dep_info):
187188
for depmodname, dependencies in sorted(dep_info.items()):
188189
for modname in dependencies:
189190
printer.emit_edge(modname, depmodname)
190-
printer.generate(filename)
191+
return printer.generate(filename)
191192

192193

193-
def _make_graph(filename, dep_info, sect, gtype):
194+
def _make_graph(filename: str, dep_info: Dict[str, List[str]], sect: VNode, gtype: str):
194195
"""generate a dependencies graph and add some information about it in the
195196
report's section
196197
"""
197-
_dependencies_graph(filename, dep_info)
198-
sect.append(Paragraph(f"{gtype}imports graph has been written to {filename}"))
198+
outputfile = _dependencies_graph(filename, dep_info)
199+
sect.append(Paragraph(f"{gtype}imports graph has been written to {outputfile}"))
199200

200201

201202
# the import checker itself ###################################################
@@ -332,9 +333,9 @@ class ImportsChecker(DeprecatedMixin, BaseChecker):
332333
{
333334
"default": "",
334335
"type": "string",
335-
"metavar": "<file.dot>",
336-
"help": "Create a graph of every (i.e. internal and"
337-
" external) dependencies in the given file"
336+
"metavar": "<file.gv>",
337+
"help": "Output a graph (.gv or any supported image format) of"
338+
" all (i.e. internal and external) dependencies to the given file"
338339
" (report RP0402 must not be disabled).",
339340
},
340341
),
@@ -343,19 +344,21 @@ class ImportsChecker(DeprecatedMixin, BaseChecker):
343344
{
344345
"default": "",
345346
"type": "string",
346-
"metavar": "<file.dot>",
347-
"help": "Create a graph of external dependencies in the"
348-
" given file (report RP0402 must not be disabled).",
347+
"metavar": "<file.gv>",
348+
"help": "Output a graph (.gv or any supported image format)"
349+
" of external dependencies to the given file"
350+
" (report RP0402 must not be disabled).",
349351
},
350352
),
351353
(
352354
"int-import-graph",
353355
{
354356
"default": "",
355357
"type": "string",
356-
"metavar": "<file.dot>",
357-
"help": "Create a graph of internal dependencies in the"
358-
" given file (report RP0402 must not be disabled).",
358+
"metavar": "<file.gv>",
359+
"help": "Output a graph (.gv or any supported image format)"
360+
" of internal dependencies to the given file"
361+
" (report RP0402 must not be disabled).",
359362
},
360363
),
361364
(

pylint/graph.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919
import codecs
2020
import os
21+
import shutil
2122
import subprocess
2223
import sys
2324
import tempfile
@@ -28,7 +29,7 @@ def target_info_from_filename(filename):
2829
"""Transforms /some/path/foo.png into ('/some/path', 'foo.png', 'png')."""
2930
basename = osp.basename(filename)
3031
storedir = osp.dirname(osp.abspath(filename))
31-
target = filename.split(".")[-1]
32+
target = osp.splitext(filename)[-1][1:]
3233
return storedir, basename, target
3334

3435

@@ -76,33 +77,44 @@ def get_source(self):
7677

7778
source = property(get_source)
7879

79-
def generate(self, outputfile=None, mapfile=None):
80+
def generate(self, outputfile: str = None, mapfile: str = None) -> str:
8081
"""Generates a graph file.
8182
8283
:param str outputfile: filename and path [defaults to graphname.png]
8384
:param str mapfile: filename and path
8485
8586
:rtype: str
8687
:return: a path to the generated file
88+
:raises RuntimeError: if the executable for rendering was not found
8789
"""
90+
graphviz_extensions = ("dot", "gv")
8891
name = self.graphname
89-
if outputfile is not None:
90-
_, _, target = target_info_from_filename(outputfile)
91-
if target != "dot":
92-
pdot, dot_sourcepath = tempfile.mkstemp(".dot", name)
93-
os.close(pdot)
94-
else:
95-
dot_sourcepath = outputfile
96-
else:
92+
if outputfile is None:
9793
target = "png"
98-
pdot, dot_sourcepath = tempfile.mkstemp(".dot", name)
94+
pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
9995
ppng, outputfile = tempfile.mkstemp(".png", name)
10096
os.close(pdot)
10197
os.close(ppng)
102-
pdot = codecs.open(dot_sourcepath, "w", encoding="utf8")
103-
pdot.write(self.source)
104-
pdot.close()
105-
if target != "dot":
98+
else:
99+
_, _, target = target_info_from_filename(outputfile)
100+
if not target:
101+
target = "png"
102+
outputfile = outputfile + "." + target
103+
if target not in graphviz_extensions:
104+
pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
105+
os.close(pdot)
106+
else:
107+
dot_sourcepath = outputfile
108+
pdot = codecs.open(dot_sourcepath, "w", encoding="utf8") # type: ignore
109+
pdot.write(self.source) # type: ignore
110+
pdot.close() # type: ignore
111+
if target not in graphviz_extensions:
112+
if shutil.which(self.renderer) is None:
113+
raise RuntimeError(
114+
f"Cannot generate `{outputfile}` because '{self.renderer}' "
115+
"executable not found. Install graphviz, or specify a `.gv` "
116+
"outputfile to produce the DOT source code."
117+
)
106118
use_shell = sys.platform == "win32"
107119
if mapfile:
108120
subprocess.call(

tests/test_import_graph.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626

2727

2828
@pytest.fixture
29-
def dest():
30-
dest = "dependencies_graph.dot"
29+
def dest(request):
30+
dest = request.param
3131
yield dest
3232
try:
3333
os.remove(dest)
@@ -36,13 +36,18 @@ def dest():
3636
pass
3737

3838

39+
POSSIBLE_DOT_FILENAMES = ["foo.dot", "foo.gv", "tests/regrtest_data/foo.dot"]
40+
41+
42+
@pytest.mark.parametrize("dest", POSSIBLE_DOT_FILENAMES, indirect=True)
3943
def test_dependencies_graph(dest):
44+
"""DOC files are correctly generated, and the graphname is the basename"""
4045
imports._dependencies_graph(dest, {"labas": ["hoho", "yep"], "hoho": ["yep"]})
4146
with open(dest) as stream:
4247
assert (
4348
stream.read().strip()
4449
== """
45-
digraph "dependencies_graph" {
50+
digraph "foo" {
4651
rankdir=LR
4752
charset="utf-8"
4853
URL="." node[shape="box"]
@@ -57,6 +62,13 @@ def test_dependencies_graph(dest):
5762
)
5863

5964

65+
@pytest.mark.parametrize("filename", ["graph.png", "graph"])
66+
def test_missing_graphviz(filename):
67+
"""Raises if graphviz is not installed, and defaults to png if no extension given"""
68+
with pytest.raises(RuntimeError, match=r"Cannot generate `graph\.png`.*"):
69+
imports._dependencies_graph(filename, {"a": ["b", "c"], "b": ["c"]})
70+
71+
6072
@pytest.fixture
6173
def linter():
6274
pylinter = PyLinter(reporter=testutils.GenericTestReporter())

0 commit comments

Comments
 (0)