Skip to content

Commit 7bb5043

Browse files
authored
Create common Printer base class for pyreverse and improve typing. (#4731)
* Create common ``Printer`` base class for Pyreverse, and improve typing. * Use ``abc.ABC`` as metaclass for ``Printer`` instead of raising ``NotImplementedError`` * Rename ``vcgutils.py`` to ``vcg_printer.py``
1 parent 9ae559d commit 7bb5043

12 files changed

+554
-172
lines changed

pylint/pyreverse/diagrams.py

+13
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ def __init__(self, title="No name", node=None):
4545
self.node = node
4646

4747

48+
class PackageEntity(DiagramEntity):
49+
"""A diagram object representing a package"""
50+
51+
52+
class ClassEntity(DiagramEntity):
53+
"""A diagram object representing a class"""
54+
55+
def __init__(self, title, node):
56+
super().__init__(title=title, node=node)
57+
self.attrs = None
58+
self.methods = None
59+
60+
4861
class ClassDiagram(Figure, FilterMixIn):
4962
"""main class diagram handling"""
5063

pylint/pyreverse/dot_printer.py

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Copyright (c) 2021 Andreas Finkler <[email protected]>
2+
3+
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
4+
# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
5+
6+
"""
7+
Class to generate files in dot format and image formats supported by Graphviz.
8+
"""
9+
import os
10+
import subprocess
11+
import sys
12+
import tempfile
13+
from pathlib import Path
14+
from typing import Dict, FrozenSet, Optional
15+
16+
from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer
17+
from pylint.pyreverse.utils import check_graphviz_availability
18+
19+
ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1"))
20+
SHAPES: Dict[NodeType, str] = {
21+
NodeType.PACKAGE: "box",
22+
NodeType.INTERFACE: "record",
23+
NodeType.CLASS: "record",
24+
}
25+
ARROWS: Dict[EdgeType, Dict] = {
26+
EdgeType.INHERITS: dict(arrowtail="none", arrowhead="empty"),
27+
EdgeType.IMPLEMENTS: dict(arrowtail="node", arrowhead="empty", style="dashed"),
28+
EdgeType.ASSOCIATION: dict(
29+
fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid"
30+
),
31+
EdgeType.USES: dict(arrowtail="none", arrowhead="open"),
32+
}
33+
34+
35+
class DotPrinter(Printer):
36+
def __init__(
37+
self,
38+
title: str,
39+
layout: Optional[Layout] = None,
40+
use_automatic_namespace: Optional[bool] = None,
41+
):
42+
self.charset = "utf-8"
43+
self.node_style = "solid"
44+
super().__init__(title, layout, use_automatic_namespace)
45+
46+
def _open_graph(self) -> None:
47+
"""Emit the header lines"""
48+
self.emit(f'digraph "{self.title}" {{')
49+
if self.layout:
50+
self.emit(f"rankdir={self.layout.value}")
51+
if self.charset:
52+
assert (
53+
self.charset.lower() in ALLOWED_CHARSETS
54+
), f"unsupported charset {self.charset}"
55+
self.emit(f'charset="{self.charset}"')
56+
57+
def emit_node(
58+
self,
59+
name: str,
60+
type_: NodeType,
61+
properties: Optional[NodeProperties] = None,
62+
) -> None:
63+
"""Create a new node. Nodes can be classes, packages, participants etc."""
64+
if properties is None:
65+
properties = NodeProperties(label=name)
66+
shape = SHAPES[type_]
67+
color = properties.color if properties.color is not None else "black"
68+
label = properties.label
69+
if label:
70+
if type_ is NodeType.INTERFACE:
71+
label = "<<interface>>\\n" + label
72+
label_part = f', label="{label}"'
73+
else:
74+
label_part = ""
75+
fontcolor_part = (
76+
f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else ""
77+
)
78+
self.emit(
79+
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];'
80+
)
81+
82+
def emit_edge(
83+
self,
84+
from_node: str,
85+
to_node: str,
86+
type_: EdgeType,
87+
label: Optional[str] = None,
88+
) -> None:
89+
"""Create an edge from one node to another to display relationships."""
90+
arrowstyle = ARROWS[type_]
91+
attrs = [f'{prop}="{value}"' for prop, value in arrowstyle.items()]
92+
if label:
93+
attrs.append(f'label="{label}"')
94+
self.emit(f'"{from_node}" -> "{to_node}" [{", ".join(sorted(attrs))}];')
95+
96+
def generate(self, outputfile: str) -> None:
97+
self._close_graph()
98+
graphviz_extensions = ("dot", "gv")
99+
name = self.title
100+
if outputfile is None:
101+
target = "png"
102+
pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
103+
ppng, outputfile = tempfile.mkstemp(".png", name)
104+
os.close(pdot)
105+
os.close(ppng)
106+
else:
107+
target = Path(outputfile).suffix.lstrip(".")
108+
if not target:
109+
target = "png"
110+
outputfile = outputfile + "." + target
111+
if target not in graphviz_extensions:
112+
pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
113+
os.close(pdot)
114+
else:
115+
dot_sourcepath = outputfile
116+
with open(dot_sourcepath, "w", encoding="utf8") as outfile:
117+
outfile.writelines(self.lines)
118+
if target not in graphviz_extensions:
119+
check_graphviz_availability()
120+
use_shell = sys.platform == "win32"
121+
subprocess.call(
122+
["dot", "-T", target, dot_sourcepath, "-o", outputfile],
123+
shell=use_shell,
124+
)
125+
os.unlink(dot_sourcepath)
126+
127+
def _close_graph(self) -> None:
128+
"""Emit the lines needed to properly close the graph."""
129+
self.emit("}\n")

pylint/pyreverse/main.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,14 @@
2121
create UML diagrams for classes and modules in <packages>
2222
"""
2323
import os
24-
import subprocess
2524
import sys
2625
from typing import Iterable
2726

2827
from pylint.config import ConfigurationMixIn
2928
from pylint.pyreverse import writer
3029
from pylint.pyreverse.diadefslib import DiadefsHandler
3130
from pylint.pyreverse.inspector import Linker, project_from_files
32-
from pylint.pyreverse.utils import insert_default_options
31+
from pylint.pyreverse.utils import check_graphviz_availability, insert_default_options
3332

3433
OPTIONS = (
3534
(
@@ -175,19 +174,6 @@
175174
)
176175

177176

178-
def _check_graphviz_available(output_format):
179-
"""check if we need graphviz for different output format"""
180-
try:
181-
subprocess.call(["dot", "-V"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
182-
except OSError:
183-
print(
184-
"The output format '%s' is currently not available.\n"
185-
"Please install 'Graphviz' to have other output formats "
186-
"than 'dot' or 'vcg'." % output_format
187-
)
188-
sys.exit(32)
189-
190-
191177
class Run(ConfigurationMixIn):
192178
"""base class providing common behaviour for pyreverse commands"""
193179

@@ -198,7 +184,7 @@ def __init__(self, args: Iterable[str]):
198184
insert_default_options()
199185
args = self.load_command_line_configuration(args)
200186
if self.config.output_format not in ("dot", "vcg"):
201-
_check_graphviz_available(self.config.output_format)
187+
check_graphviz_availability()
202188

203189
sys.exit(self.run(args))
204190

pylint/pyreverse/printer.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright (c) 2021 Andreas Finkler <[email protected]>
2+
3+
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
4+
# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
5+
6+
"""
7+
Base class defining the interface for a printer.
8+
"""
9+
from abc import ABC, abstractmethod
10+
from enum import Enum
11+
from typing import List, NamedTuple, Optional
12+
13+
14+
class NodeType(Enum):
15+
CLASS = "class"
16+
INTERFACE = "interface"
17+
PACKAGE = "package"
18+
19+
20+
class EdgeType(Enum):
21+
INHERITS = "inherits"
22+
IMPLEMENTS = "implements"
23+
ASSOCIATION = "association"
24+
USES = "uses"
25+
26+
27+
class Layout(Enum):
28+
LEFT_TO_RIGHT = "LR"
29+
RIGHT_TO_LEFT = "RL"
30+
TOP_TO_BOTTOM = "TB"
31+
BOTTOM_TO_TOP = "BT"
32+
33+
34+
class NodeProperties(NamedTuple):
35+
label: str
36+
color: Optional[str] = None
37+
fontcolor: Optional[str] = None
38+
body: Optional[str] = None
39+
40+
41+
class Printer(ABC):
42+
"""Base class defining the interface for a printer"""
43+
44+
def __init__(
45+
self,
46+
title: str,
47+
layout: Optional[Layout] = None,
48+
use_automatic_namespace: Optional[bool] = None,
49+
):
50+
self.title: str = title
51+
self.layout = layout
52+
self.use_automatic_namespace = use_automatic_namespace
53+
self.lines: List[str] = []
54+
self._open_graph()
55+
56+
@abstractmethod
57+
def _open_graph(self) -> None:
58+
"""Emit the header lines, i.e. all boilerplate code that defines things like layout etc."""
59+
60+
def emit(self, line: str, force_newline: Optional[bool] = True) -> None:
61+
if force_newline and not line.endswith("\n"):
62+
line += "\n"
63+
self.lines.append(line)
64+
65+
@abstractmethod
66+
def emit_node(
67+
self,
68+
name: str,
69+
type_: NodeType,
70+
properties: Optional[NodeProperties] = None,
71+
) -> None:
72+
"""Create a new node. Nodes can be classes, packages, participants etc."""
73+
74+
@abstractmethod
75+
def emit_edge(
76+
self,
77+
from_node: str,
78+
to_node: str,
79+
type_: EdgeType,
80+
label: Optional[str] = None,
81+
) -> None:
82+
"""Create an edge from one node to another to display relationships."""
83+
84+
def generate(self, outputfile: str) -> None:
85+
"""Generate and save the final outputfile."""
86+
self._close_graph()
87+
with open(outputfile, "w", encoding="utf-8") as outfile:
88+
outfile.writelines(self.lines)
89+
90+
@abstractmethod
91+
def _close_graph(self) -> None:
92+
"""Emit the lines needed to properly close the graph."""

pylint/pyreverse/utils.py

+14
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""Generic classes/functions for pyreverse core/extensions. """
2121
import os
2222
import re
23+
import shutil
2324
import sys
2425
from typing import Optional, Union
2526

@@ -275,3 +276,16 @@ def infer_node(node: Union[astroid.AssignAttr, astroid.AssignName]) -> set:
275276
return set(node.infer())
276277
except astroid.InferenceError:
277278
return {ann} if ann else set()
279+
280+
281+
def check_graphviz_availability():
282+
"""Check if the ``dot`` command is available on the machine.
283+
This is needed if image output is desired and ``dot`` is used to convert
284+
from *.dot or *.gv into the final output format."""
285+
if shutil.which("dot") is None:
286+
print(
287+
"The requested output format is currently not available.\n"
288+
"Please install 'Graphviz' to have other output formats "
289+
"than 'dot' or 'vcg'."
290+
)
291+
sys.exit(32)

0 commit comments

Comments
 (0)