From 649b6e7a8bb48a66ddc5631214c6f5ff36704545 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 20 Jul 2021 21:48:59 +0200 Subject: [PATCH 1/7] Create common ``Printer`` base class for Pyreverse, and improve typing. --- pylint/pyreverse/diagrams.py | 13 + pylint/pyreverse/printer.py | 490 +++++++++++++++++++++++++++++ pylint/pyreverse/utils.py | 12 + pylint/pyreverse/vcgutils.py | 225 ------------- pylint/pyreverse/writer.py | 177 ++++++----- tests/data/classes_No_Name.dot | 18 +- tests/data/classes_No_Name.vcg | 38 +++ tests/data/packages_No_Name.dot | 10 +- tests/data/packages_No_Name.vcg | 20 ++ tests/unittest_pyreverse_writer.py | 44 ++- 10 files changed, 715 insertions(+), 332 deletions(-) create mode 100644 pylint/pyreverse/printer.py delete mode 100644 pylint/pyreverse/vcgutils.py create mode 100644 tests/data/classes_No_Name.vcg create mode 100644 tests/data/packages_No_Name.vcg diff --git a/pylint/pyreverse/diagrams.py b/pylint/pyreverse/diagrams.py index 5b854ed232..1c3d4c7af1 100644 --- a/pylint/pyreverse/diagrams.py +++ b/pylint/pyreverse/diagrams.py @@ -45,6 +45,19 @@ def __init__(self, title="No name", node=None): self.node = node +class PackageEntity(DiagramEntity): + """A diagram object representing a package""" + + +class ClassEntity(DiagramEntity): + """A diagram object representing a class""" + + def __init__(self, title, node): + super().__init__(title=title, node=node) + self.attrs = None + self.methods = None + + class ClassDiagram(Figure, FilterMixIn): """main class diagram handling""" diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py new file mode 100644 index 0000000000..cc5576f3dd --- /dev/null +++ b/pylint/pyreverse/printer.py @@ -0,0 +1,490 @@ +# Copyright (c) 2015-2018, 2020 Claudiu Popa +# Copyright (c) 2015 Florian Bruhin +# Copyright (c) 2018 ssolanki +# Copyright (c) 2020-2021 Pierre Sassoulas +# Copyright (c) 2020 hippo91 +# Copyright (c) 2020 Ram Rachum +# Copyright (c) 2020 谭九鼎 <109224573@qq.com> +# Copyright (c) 2020 Anthony Sottile +# Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> +# Copyright (c) 2021 Andreas Finkler + +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE + +""" +Collection of printer classes for diagrams. +Printers are responsible for generating files that can be understood by tools like +Graphviz or PlantUML for example. +""" +import os +import shutil +import subprocess +import sys +import tempfile +from enum import Enum +from typing import Any, Dict, FrozenSet, List, Mapping, NamedTuple, Optional, Tuple + +from pylint.pyreverse.utils import get_file_extension + + +class NodeType(Enum): + CLASS = "class" + INTERFACE = "interface" + PACKAGE = "package" + + +class EdgeType(Enum): + INHERITS = "inherits" + IMPLEMENTS = "implements" + ASSOCIATION = "association" + USES = "uses" + + +class Layout(Enum): + LEFT_TO_RIGHT = "LR" + RIGHT_TO_LEFT = "RL" + TOP_TO_BOTTOM = "TB" + BOTTOM_TO_TOP = "BT" + + +class NodeProperties(NamedTuple): + label: str + color: Optional[str] = None + fontcolor: Optional[str] = None + body: Optional[str] = None + + +class Printer: + """Base class defining the interface for a printer""" + + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.title: str = title + self.layout = layout + self.use_automatic_namespace = use_automatic_namespace + self.lines: List[str] = [] + self._open_graph() + + def _open_graph(self) -> None: + """Emit the header lines""" + raise NotImplementedError + + def emit(self, line: str, force_newline: Optional[bool] = True) -> None: + if force_newline and not line.endswith("\n"): + line += "\n" + self.lines.append(line) + + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + raise NotImplementedError + + def emit_edge( + self, + from_node: str, + to_node: str, + type_: EdgeType, + label: Optional[str] = None, + ) -> None: + """Create an edge from one node to another to display relationships.""" + raise NotImplementedError + + def generate(self, outputfile: str) -> None: + """Generate and save the final outputfile.""" + self._close_graph() + with open(outputfile, "w", encoding="utf-8") as outfile: + outfile.writelines(self.lines) + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + raise NotImplementedError + + +class VCGPrinter(Printer): + SHAPES: Dict[NodeType, str] = { + NodeType.PACKAGE: "box", + NodeType.CLASS: "box", + NodeType.INTERFACE: "ellipse", + } + ARROWS: Dict[EdgeType, Dict] = { + EdgeType.USES: dict(arrowstyle="solid", backarrowstyle="none", backarrowsize=0), + EdgeType.INHERITS: dict( + arrowstyle="solid", backarrowstyle="none", backarrowsize=10 + ), + EdgeType.IMPLEMENTS: dict( + arrowstyle="solid", + backarrowstyle="none", + linestyle="dotted", + backarrowsize=10, + ), + EdgeType.ASSOCIATION: dict( + arrowstyle="solid", backarrowstyle="none", textcolor="green" + ), + } + ORIENTATION: Dict[Layout, str] = { + Layout.LEFT_TO_RIGHT: "left_to_right", + Layout.RIGHT_TO_LEFT: "right_to_left", + Layout.TOP_TO_BOTTOM: "top_to_bottom", + Layout.BOTTOM_TO_TOP: "bottom_to_top", + } + ATTRS_VAL: Dict[str, Tuple] = { + "algos": ( + "dfs", + "tree", + "minbackward", + "left_to_right", + "right_to_left", + "top_to_bottom", + "bottom_to_top", + "maxdepth", + "maxdepthslow", + "mindepth", + "mindepthslow", + "mindegree", + "minindegree", + "minoutdegree", + "maxdegree", + "maxindegree", + "maxoutdegree", + ), + "booleans": ("yes", "no"), + "colors": ( + "black", + "white", + "blue", + "red", + "green", + "yellow", + "magenta", + "lightgrey", + "cyan", + "darkgrey", + "darkblue", + "darkred", + "darkgreen", + "darkyellow", + "darkmagenta", + "darkcyan", + "gold", + "lightblue", + "lightred", + "lightgreen", + "lightyellow", + "lightmagenta", + "lightcyan", + "lilac", + "turquoise", + "aquamarine", + "khaki", + "purple", + "yellowgreen", + "pink", + "orange", + "orchid", + ), + "shapes": ("box", "ellipse", "rhomb", "triangle"), + "textmodes": ("center", "left_justify", "right_justify"), + "arrowstyles": ("solid", "line", "none"), + "linestyles": ("continuous", "dashed", "dotted", "invisible"), + } + + # meaning of possible values: + # O -> string + # 1 -> int + # list -> value in list + GRAPH_ATTRS: Dict[str, Any] = { + "title": 0, + "label": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "bordercolor": ATTRS_VAL["colors"], + "width": 1, + "height": 1, + "borderwidth": 1, + "textmode": ATTRS_VAL["textmodes"], + "shape": ATTRS_VAL["shapes"], + "shrink": 1, + "stretch": 1, + "orientation": ATTRS_VAL["algos"], + "vertical_order": 1, + "horizontal_order": 1, + "xspace": 1, + "yspace": 1, + "layoutalgorithm": ATTRS_VAL["algos"], + "late_edge_labels": ATTRS_VAL["booleans"], + "display_edge_labels": ATTRS_VAL["booleans"], + "dirty_edge_labels": ATTRS_VAL["booleans"], + "finetuning": ATTRS_VAL["booleans"], + "manhattan_edges": ATTRS_VAL["booleans"], + "smanhattan_edges": ATTRS_VAL["booleans"], + "port_sharing": ATTRS_VAL["booleans"], + "edges": ATTRS_VAL["booleans"], + "nodes": ATTRS_VAL["booleans"], + "splines": ATTRS_VAL["booleans"], + } + NODE_ATTRS: Dict[str, Any] = { + "title": 0, + "label": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "bordercolor": ATTRS_VAL["colors"], + "width": 1, + "height": 1, + "borderwidth": 1, + "textmode": ATTRS_VAL["textmodes"], + "shape": ATTRS_VAL["shapes"], + "shrink": 1, + "stretch": 1, + "vertical_order": 1, + "horizontal_order": 1, + } + EDGE_ATTRS: Dict[str, Any] = { + "sourcename": 0, + "targetname": 0, + "label": 0, + "linestyle": ATTRS_VAL["linestyles"], + "class": 1, + "thickness": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "arrowcolor": ATTRS_VAL["colors"], + "backarrowcolor": ATTRS_VAL["colors"], + "arrowsize": 1, + "backarrowsize": 1, + "arrowstyle": ATTRS_VAL["arrowstyles"], + "backarrowstyle": ATTRS_VAL["arrowstyles"], + "textmode": ATTRS_VAL["textmodes"], + "priority": 1, + "anchor": 1, + "horizontal_order": 1, + } + + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self._indent = "" + super().__init__(title, layout, use_automatic_namespace) + + def _inc_indent(self) -> None: + self._indent += " " + + def _dec_indent(self) -> None: + self._indent = self._indent[:-2] + + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit(f"{self._indent}graph:{{\n") + self._inc_indent() + self._write_attributes( + self.GRAPH_ATTRS, + title=self.title, + layoutalgorithm="dfs", + late_edge_labels="yes", + port_sharing="no", + manhattan_edges="yes", + ) + if self.layout: + self._write_attributes( + self.GRAPH_ATTRS, orientation=self.ORIENTATION[self.layout] + ) + + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + if properties is None: + properties = NodeProperties(label=name) + self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False) + label = properties.label if properties.label is not None else name + self._write_attributes( + self.NODE_ATTRS, + label=label, + shape=self.SHAPES[type_], + ) + self.emit("}") + + def emit_edge( + self, + from_node: str, + to_node: str, + type_: EdgeType, + label: Optional[str] = None, + ) -> None: + """Create an edge from one node to another to display relationships.""" + self.emit( + f'{self._indent}edge: {{sourcename:"{from_node}" targetname:"{to_node}"', + force_newline=False, + ) + attributes = self.ARROWS[type_] + if label: + attributes["label"] = label + self._write_attributes( + self.EDGE_ATTRS, + **attributes, + ) + self.emit("}") + + def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None: + """write graph, node or edge attributes""" + for key, value in args.items(): + try: + _type = attributes_dict[key] + except KeyError as e: + raise Exception( + f"no such attribute {key}\npossible attributes are {attributes_dict.keys()}" + ) from e + + if not _type: + self.emit(f'{self._indent}{key}:"{value}"\n') + elif _type == 1: + self.emit(f"{self._indent}{key}:{int(value)}\n") + elif value in _type: + self.emit(f"{self._indent}{key}:{value}\n") + else: + raise Exception( + f"value {value} isn't correct for attribute {key} correct values are {type}" + ) + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self._dec_indent() + self.emit(f"{self._indent}}}") + + +class DotPrinter(Printer): + ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1")) + SHAPES: Dict[NodeType, str] = { + NodeType.PACKAGE: "box", + NodeType.INTERFACE: "record", + NodeType.CLASS: "record", + } + ARROWS: Dict[EdgeType, Dict] = { + EdgeType.INHERITS: dict(arrowtail="none", arrowhead="empty"), + EdgeType.IMPLEMENTS: dict(arrowtail="node", arrowhead="empty", style="dashed"), + EdgeType.ASSOCIATION: dict( + fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid" + ), + EdgeType.USES: dict(arrowtail="none", arrowhead="open"), + } + RANKDIR: Dict[Layout, str] = { + Layout.LEFT_TO_RIGHT: "LR", + Layout.RIGHT_TO_LEFT: "RL", + Layout.TOP_TO_BOTTOM: "TB", + Layout.BOTTOM_TO_TOP: "BT", + } + + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.charset = "utf-8" + self.node_style = "solid" + super().__init__(title, layout, use_automatic_namespace) + + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit(f'digraph "{self.title}" {{') + if self.layout: + self.emit(f"rankdir={self.RANKDIR[self.layout]}") + if self.charset: + assert ( + self.charset.lower() in self.ALLOWED_CHARSETS + ), f"unsupported charset {self.charset}" + self.emit(f'charset="{self.charset}"') + + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + if properties is None: + properties = NodeProperties(label=name) + shape = self.SHAPES[type_] + color = properties.color if properties.color is not None else "black" + label = properties.label + if label: + if type_ is NodeType.INTERFACE: + label = "<>\\n" + label + label_part = f', label="{label}"' + else: + label_part = "" + fontcolor_part = ( + f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else "" + ) + self.emit( + f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];' + ) + + def emit_edge( + self, + from_node: str, + to_node: str, + type_: EdgeType, + label: Optional[str] = None, + ) -> None: + """Create an edge from one node to another to display relationships.""" + arrowstyle = self.ARROWS[type_] + attrs = [f'{prop}="{value}"' for prop, value in arrowstyle.items()] + if label: + attrs.append(f'label="{label}"') + self.emit(f'"{from_node}" -> "{to_node}" [{", ".join(sorted(attrs))}];') + + def generate(self, outputfile: str) -> None: + self._close_graph() + graphviz_extensions = ("dot", "gv") + name = self.title + if outputfile is None: + target = "png" + pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) + ppng, outputfile = tempfile.mkstemp(".png", name) + os.close(pdot) + os.close(ppng) + else: + target = get_file_extension(outputfile) + if not target: + target = "png" + outputfile = outputfile + "." + target + if target not in graphviz_extensions: + pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) + os.close(pdot) + else: + dot_sourcepath = outputfile + with open(dot_sourcepath, "w", encoding="utf8") as outfile: + outfile.writelines(self.lines) + if target not in graphviz_extensions: + if shutil.which("dot") is None: + raise RuntimeError( + f"Cannot generate `{outputfile}` because 'dot' " + "executable not found. Install graphviz, or specify a `.gv` " + "outputfile to produce the DOT source code." + ) + use_shell = sys.platform == "win32" + subprocess.call( + ["dot", "-T", target, dot_sourcepath, "-o", outputfile], + shell=use_shell, + ) + os.unlink(dot_sourcepath) + # return outputfile TODO should we return this? + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self.emit("}\n") diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index fea9bda792..4b16a59e46 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -275,3 +275,15 @@ def infer_node(node: Union[astroid.AssignAttr, astroid.AssignName]) -> set: return set(node.infer()) except astroid.InferenceError: return set() + + +def get_file_extension(filename_or_extension: str) -> str: + """ + Get either the file extension if the input string is a filename (i.e. contains a dot), + or return the string as it is assuming it already is just the file extension itself. + """ + basename = os.path.basename(filename_or_extension) + if basename.find(".") > 0: + return os.path.splitext(basename)[-1][1:] + # no real filename, so we assume the input string is just the name of the extension + return filename_or_extension diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py deleted file mode 100644 index 3c97191478..0000000000 --- a/pylint/pyreverse/vcgutils.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright (c) 2015-2018, 2020 Claudiu Popa -# Copyright (c) 2015 Florian Bruhin -# Copyright (c) 2018 ssolanki -# Copyright (c) 2020-2021 Pierre Sassoulas -# Copyright (c) 2020 hippo91 -# Copyright (c) 2020 Ram Rachum -# Copyright (c) 2020 谭九鼎 <109224573@qq.com> -# Copyright (c) 2020 Anthony Sottile -# Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> - -# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE - -"""Functions to generate files readable with Georg Sander's vcg -(Visualization of Compiler Graphs). - -You can download vcg at https://rw4.cs.uni-sb.de/~sander/html/gshome.html -Note that vcg exists as a debian package. - -See vcg's documentation for explanation about the different values that -maybe used for the functions parameters. -""" - -ATTRS_VAL = { - "algos": ( - "dfs", - "tree", - "minbackward", - "left_to_right", - "right_to_left", - "top_to_bottom", - "bottom_to_top", - "maxdepth", - "maxdepthslow", - "mindepth", - "mindepthslow", - "mindegree", - "minindegree", - "minoutdegree", - "maxdegree", - "maxindegree", - "maxoutdegree", - ), - "booleans": ("yes", "no"), - "colors": ( - "black", - "white", - "blue", - "red", - "green", - "yellow", - "magenta", - "lightgrey", - "cyan", - "darkgrey", - "darkblue", - "darkred", - "darkgreen", - "darkyellow", - "darkmagenta", - "darkcyan", - "gold", - "lightblue", - "lightred", - "lightgreen", - "lightyellow", - "lightmagenta", - "lightcyan", - "lilac", - "turquoise", - "aquamarine", - "khaki", - "purple", - "yellowgreen", - "pink", - "orange", - "orchid", - ), - "shapes": ("box", "ellipse", "rhomb", "triangle"), - "textmodes": ("center", "left_justify", "right_justify"), - "arrowstyles": ("solid", "line", "none"), - "linestyles": ("continuous", "dashed", "dotted", "invisible"), -} - -# meaning of possible values: -# O -> string -# 1 -> int -# list -> value in list -GRAPH_ATTRS = { - "title": 0, - "label": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "bordercolor": ATTRS_VAL["colors"], - "width": 1, - "height": 1, - "borderwidth": 1, - "textmode": ATTRS_VAL["textmodes"], - "shape": ATTRS_VAL["shapes"], - "shrink": 1, - "stretch": 1, - "orientation": ATTRS_VAL["algos"], - "vertical_order": 1, - "horizontal_order": 1, - "xspace": 1, - "yspace": 1, - "layoutalgorithm": ATTRS_VAL["algos"], - "late_edge_labels": ATTRS_VAL["booleans"], - "display_edge_labels": ATTRS_VAL["booleans"], - "dirty_edge_labels": ATTRS_VAL["booleans"], - "finetuning": ATTRS_VAL["booleans"], - "manhattan_edges": ATTRS_VAL["booleans"], - "smanhattan_edges": ATTRS_VAL["booleans"], - "port_sharing": ATTRS_VAL["booleans"], - "edges": ATTRS_VAL["booleans"], - "nodes": ATTRS_VAL["booleans"], - "splines": ATTRS_VAL["booleans"], -} -NODE_ATTRS = { - "title": 0, - "label": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "bordercolor": ATTRS_VAL["colors"], - "width": 1, - "height": 1, - "borderwidth": 1, - "textmode": ATTRS_VAL["textmodes"], - "shape": ATTRS_VAL["shapes"], - "shrink": 1, - "stretch": 1, - "vertical_order": 1, - "horizontal_order": 1, -} -EDGE_ATTRS = { - "sourcename": 0, - "targetname": 0, - "label": 0, - "linestyle": ATTRS_VAL["linestyles"], - "class": 1, - "thickness": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "arrowcolor": ATTRS_VAL["colors"], - "backarrowcolor": ATTRS_VAL["colors"], - "arrowsize": 1, - "backarrowsize": 1, - "arrowstyle": ATTRS_VAL["arrowstyles"], - "backarrowstyle": ATTRS_VAL["arrowstyles"], - "textmode": ATTRS_VAL["textmodes"], - "priority": 1, - "anchor": 1, - "horizontal_order": 1, -} - - -# Misc utilities ############################################################### - - -class VCGPrinter: - """A vcg graph writer.""" - - def __init__(self, output_stream): - self._stream = output_stream - self._indent = "" - - def open_graph(self, **args): - """open a vcg graph""" - self._stream.write("%sgraph:{\n" % self._indent) - self._inc_indent() - self._write_attributes(GRAPH_ATTRS, **args) - - def close_graph(self): - """close a vcg graph""" - self._dec_indent() - self._stream.write("%s}\n" % self._indent) - - def node(self, title, **args): - """draw a node""" - self._stream.write(f'{self._indent}node: {{title:"{title}"') - self._write_attributes(NODE_ATTRS, **args) - self._stream.write("}\n") - - def edge(self, from_node, to_node, edge_type="", **args): - """draw an edge from a node to another.""" - self._stream.write( - '%s%sedge: {sourcename:"%s" targetname:"%s"' - % (self._indent, edge_type, from_node, to_node) - ) - self._write_attributes(EDGE_ATTRS, **args) - self._stream.write("}\n") - - # private ################################################################## - - def _write_attributes(self, attributes_dict, **args): - """write graph, node or edge attributes""" - for key, value in args.items(): - try: - _type = attributes_dict[key] - except KeyError as e: - raise Exception( - """no such attribute %s -possible attributes are %s""" - % (key, attributes_dict.keys()) - ) from e - - if not _type: - self._stream.write(f'{self._indent}{key}:"{value}"\n') - elif _type == 1: - self._stream.write(f"{self._indent}{key}:{int(value)}\n") - elif value in _type: - self._stream.write(f"{self._indent}{key}:{value}\n") - else: - raise Exception( - f"""value {value} isn't correct for attribute {key} -correct values are {type}""" - ) - - def _inc_indent(self): - """increment indentation""" - self._indent = " %s" % self._indent - - def _dec_indent(self): - """decrement indentation""" - self._indent = self._indent[:-2] diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index db54c3f0bd..f868e9badf 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -19,17 +19,29 @@ import os -from pylint.graph import DotBackend +from pylint.pyreverse.diagrams import ( + ClassDiagram, + ClassEntity, + DiagramEntity, + PackageDiagram, + PackageEntity, +) +from pylint.pyreverse.printer import ( + DotPrinter, + EdgeType, + Layout, + NodeProperties, + NodeType, + VCGPrinter, +) from pylint.pyreverse.utils import get_annotation_label, is_exception -from pylint.pyreverse.vcgutils import VCGPrinter class DiagramWriter: """base class for writing project diagrams""" - def __init__(self, config, styles): + def __init__(self, config): self.config = config - self.pkg_edges, self.inh_edges, self.imp_edges, self.association_edges = styles self.printer = None # defined in set_printer def write(self, diadefs): @@ -44,35 +56,48 @@ def write(self, diadefs): self.write_classes(diagram) else: self.write_packages(diagram) - self.close_graph() + self.save() - def write_packages(self, diagram): + def write_packages(self, diagram: PackageDiagram) -> None: """write a package diagram""" # sorted to get predictable (hence testable) results - for i, obj in enumerate(sorted(diagram.modules(), key=lambda x: x.title)): - self.printer.emit_node(i, label=self.get_title(obj), shape="box") - obj.fig_id = i + for obj in sorted(diagram.modules(), key=lambda x: x.title): + obj.fig_id = obj.node.qname() + self.printer.emit_node( + obj.fig_id, + type_=NodeType.PACKAGE, + properties=self.get_package_properties(obj), + ) # package dependencies for rel in diagram.get_relationships("depends"): self.printer.emit_edge( - rel.from_object.fig_id, rel.to_object.fig_id, **self.pkg_edges + rel.from_object.fig_id, + rel.to_object.fig_id, + type_=EdgeType.USES, ) - def write_classes(self, diagram): + def write_classes(self, diagram: ClassDiagram) -> None: """write a class diagram""" # sorted to get predictable (hence testable) results - for i, obj in enumerate(sorted(diagram.objects, key=lambda x: x.title)): - self.printer.emit_node(i, **self.get_values(obj)) - obj.fig_id = i + for obj in sorted(diagram.objects, key=lambda x: x.title): + obj.fig_id = obj.node.qname() + type_ = NodeType.INTERFACE if obj.shape == "interface" else NodeType.CLASS + self.printer.emit_node( + obj.fig_id, type_=type_, properties=self.get_class_properties(obj) + ) # inheritance links for rel in diagram.get_relationships("specialization"): self.printer.emit_edge( - rel.from_object.fig_id, rel.to_object.fig_id, **self.inh_edges + rel.from_object.fig_id, + rel.to_object.fig_id, + type_=EdgeType.INHERITS, ) # implementation links for rel in diagram.get_relationships("implements"): self.printer.emit_edge( - rel.from_object.fig_id, rel.to_object.fig_id, **self.imp_edges + rel.from_object.fig_id, + rel.to_object.fig_id, + type_=EdgeType.IMPLEMENTS, ) # generate associations for rel in diagram.get_relationships("association"): @@ -80,58 +105,55 @@ def write_classes(self, diagram): rel.from_object.fig_id, rel.to_object.fig_id, label=rel.name, - **self.association_edges, + type_=EdgeType.ASSOCIATION, ) - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """set printer""" raise NotImplementedError - def get_title(self, obj): + def get_title(self, obj: DiagramEntity) -> str: """get project title""" raise NotImplementedError - def get_values(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + raise NotImplementedError + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes.""" raise NotImplementedError - def close_graph(self): - """finalize the graph""" + def save(self) -> None: + """write to disk""" raise NotImplementedError class DotWriter(DiagramWriter): """write dot graphs from a diagram definition and a project""" - def __init__(self, config): - styles = [ - dict(arrowtail="none", arrowhead="open"), - dict(arrowtail="none", arrowhead="empty"), - dict(arrowtail="node", arrowhead="empty", style="dashed"), - dict( - fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid" - ), - ] - DiagramWriter.__init__(self, config, styles) - - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """initialize DotWriter and add options for layout.""" - layout = dict(rankdir="BT") - self.printer = DotBackend(basename, additional_param=layout) + self.printer = DotPrinter(basename, layout=Layout.BOTTOM_TO_TOP) self.file_name = file_name - def get_title(self, obj): + def get_title(self, obj: DiagramEntity) -> str: """get project title""" return obj.title - def get_values(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + return NodeProperties( + label=self.get_title(obj), + color="black", + ) + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. The label contains all attributes and methods """ label = obj.title - if obj.shape == "interface": - label = "«interface»\\n%s" % label if not self.config.only_classnames: label = r"{}|{}\l|".format(label, r"\l".join(obj.attrs)) for func in obj.methods: @@ -140,12 +162,14 @@ def get_values(self, obj): ) if func.args.args: - args = [arg for arg in func.args.args if arg.name != "self"] + argument_list = [ + arg for arg in func.args.args if arg.name != "self" + ] else: - args = [] + argument_list = [] - annotations = dict(zip(args, func.args.annotations[1:])) - for arg in args: + annotations = dict(zip(argument_list, func.args.annotations[1:])) + for arg in argument_list: annotation_label = "" ann = annotations.get(arg) if ann: @@ -159,51 +183,37 @@ def get_values(self, obj): label = fr"{label}{func.name}({args}){return_type}\l" label = "{%s}" % label - if is_exception(obj.node): - return dict(fontcolor="red", label=label, shape="record") - return dict(label=label, shape="record") + properties = NodeProperties( + label=label, + fontcolor="red" if is_exception(obj.node) else "black", + color="black", + ) + return properties - def close_graph(self): - """print the dot graph into """ + def save(self) -> None: + """write to disk""" self.printer.generate(self.file_name) class VCGWriter(DiagramWriter): """write vcg graphs from a diagram definition and a project""" - def __init__(self, config): - styles = [ - dict(arrowstyle="solid", backarrowstyle="none", backarrowsize=0), - dict(arrowstyle="solid", backarrowstyle="none", backarrowsize=10), - dict( - arrowstyle="solid", - backarrowstyle="none", - linestyle="dotted", - backarrowsize=10, - ), - dict(arrowstyle="solid", backarrowstyle="none", textcolor="green"), - ] - DiagramWriter.__init__(self, config, styles) - - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """initialize VCGWriter for a UML graph""" - self.graph_file = open(file_name, "w+") # pylint: disable=consider-using-with - self.printer = VCGPrinter(self.graph_file) - self.printer.open_graph( - title=basename, - layoutalgorithm="dfs", - late_edge_labels="yes", - port_sharing="no", - manhattan_edges="yes", - ) - self.printer.emit_node = self.printer.node - self.printer.emit_edge = self.printer.edge + self.file_name = file_name + self.printer = VCGPrinter(basename) - def get_title(self, obj): + def get_title(self, obj: DiagramEntity) -> str: """get project title in vcg format""" return r"\fb%s\fn" % obj.title - def get_values(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + return NodeProperties( + label=self.get_title(obj), + ) + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. The label contains all attributes and methods @@ -212,10 +222,6 @@ def get_values(self, obj): label = r"\fb\f09%s\fn" % obj.title else: label = r"\fb%s\fn" % obj.title - if obj.shape == "interface": - shape = "ellipse" - else: - shape = "box" if not self.config.only_classnames: attrs = obj.attrs methods = [func.name for func in obj.methods] @@ -229,9 +235,8 @@ def get_values(self, obj): label = fr"{label}\n\f{line}" for func in methods: label = fr"{label}\n\f10{func}()" - return dict(label=label, shape=shape) + return NodeProperties(label=label) - def close_graph(self): - """close graph and file""" - self.printer.close_graph() - self.graph_file.close() + def save(self) -> None: + """write to disk""" + self.printer.generate(self.file_name) diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index 8867b4e416..d2c10d6a64 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -1,12 +1,12 @@ digraph "classes_No_Name" { -charset="utf-8" rankdir=BT -"0" [label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record"]; -"1" [label="{DoNothing|\l|}", shape="record"]; -"2" [label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record"]; -"3" [label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record"]; -"3" -> "0" [arrowhead="empty", arrowtail="none"]; -"0" -> "2" [arrowhead="empty", arrowtail="node", style="dashed"]; -"1" -> "0" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="cls_member", style="solid"]; -"1" -> "3" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; +charset="utf-8" +"data.clientmodule_test.Ancestor" [color="black", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; +"data.suppliermodule_test.DoNothing" [color="black", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="solid"]; +"data.suppliermodule_test.Interface" [color="black", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; +"data.clientmodule_test.Specialization" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; +"data.clientmodule_test.Specialization" -> "data.clientmodule_test.Ancestor" [arrowhead="empty", arrowtail="none"]; +"data.clientmodule_test.Ancestor" -> "data.suppliermodule_test.Interface" [arrowhead="empty", arrowtail="node", style="dashed"]; +"data.suppliermodule_test.DoNothing" -> "data.clientmodule_test.Ancestor" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="cls_member", style="solid"]; +"data.suppliermodule_test.DoNothing" -> "data.clientmodule_test.Specialization" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; } diff --git a/tests/data/classes_No_Name.vcg b/tests/data/classes_No_Name.vcg new file mode 100644 index 0000000000..79abe64e04 --- /dev/null +++ b/tests/data/classes_No_Name.vcg @@ -0,0 +1,38 @@ +graph:{ + title:"classes_No_Name" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + node: {title:"data.clientmodule_test.Ancestor" label:"\fbAncestor\fn\n\f____________\n\f08attr : str\n\f08cls_member\n\f____________\n\f10get_value()\n\f10set_value()" + shape:box +} + node: {title:"data.suppliermodule_test.DoNothing" label:"\fbDoNothing\fn\n\f___________" + shape:box +} + node: {title:"data.suppliermodule_test.Interface" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" + shape:box +} + node: {title:"data.clientmodule_test.Specialization" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" + shape:box +} + edge: {sourcename:"data.clientmodule_test.Specialization" targetname:"data.clientmodule_test.Ancestor" arrowstyle:solid + backarrowstyle:none + backarrowsize:10 +} + edge: {sourcename:"data.clientmodule_test.Ancestor" targetname:"data.suppliermodule_test.Interface" arrowstyle:solid + backarrowstyle:none + linestyle:dotted + backarrowsize:10 +} + edge: {sourcename:"data.suppliermodule_test.DoNothing" targetname:"data.clientmodule_test.Ancestor" arrowstyle:solid + backarrowstyle:none + textcolor:green + label:"cls_member" +} + edge: {sourcename:"data.suppliermodule_test.DoNothing" targetname:"data.clientmodule_test.Specialization" arrowstyle:solid + backarrowstyle:none + textcolor:green + label:"relation" +} +} diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot index 1ceeb72427..7b145dc910 100644 --- a/tests/data/packages_No_Name.dot +++ b/tests/data/packages_No_Name.dot @@ -1,8 +1,8 @@ digraph "packages_No_Name" { -charset="utf-8" rankdir=BT -"0" [label="data", shape="box"]; -"1" [label="data.clientmodule_test", shape="box"]; -"2" [label="data.suppliermodule_test", shape="box"]; -"1" -> "2" [arrowhead="open", arrowtail="none"]; +charset="utf-8" +"data" [color="black", label="data", shape="box", style="solid"]; +"data.clientmodule_test" [color="black", label="data.clientmodule_test", shape="box", style="solid"]; +"data.suppliermodule_test" [color="black", label="data.suppliermodule_test", shape="box", style="solid"]; +"data.clientmodule_test" -> "data.suppliermodule_test" [arrowhead="open", arrowtail="none"]; } diff --git a/tests/data/packages_No_Name.vcg b/tests/data/packages_No_Name.vcg new file mode 100644 index 0000000000..03f4e2481a --- /dev/null +++ b/tests/data/packages_No_Name.vcg @@ -0,0 +1,20 @@ +graph:{ + title:"packages_No_Name" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + node: {title:"data" label:"\fbdata\fn" + shape:box +} + node: {title:"data.clientmodule_test" label:"\fbdata.clientmodule_test\fn" + shape:box +} + node: {title:"data.suppliermodule_test" label:"\fbdata.suppliermodule_test\fn" + shape:box +} + edge: {sourcename:"data.clientmodule_test" targetname:"data.suppliermodule_test" arrowstyle:solid + backarrowstyle:none + backarrowsize:0 +} +} diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index 9e6cb755bd..6dee381e81 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -11,6 +11,7 @@ # Copyright (c) 2020 Anthony Sottile # Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> # Copyright (c) 2021 Mark Byrne <31762852+mbyrnepr2@users.noreply.github.com> +# Copyright (c) 2021 Andreas Finkler # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/PyCQA/pylint/blob/main/LICENSE @@ -31,7 +32,7 @@ from pylint.pyreverse.diadefslib import DefaultDiadefGenerator, DiadefsHandler from pylint.pyreverse.inspector import Linker, project_from_files from pylint.pyreverse.utils import get_annotation, get_visibility, infer_node -from pylint.pyreverse.writer import DotWriter +from pylint.pyreverse.writer import DotWriter, VCGWriter _DEFAULTS = { "all_ancestors": None, @@ -82,28 +83,41 @@ def _astroid_wrapper(func, modname): DOT_FILES = ["packages_No_Name.dot", "classes_No_Name.dot"] +VCG_FILES = ["packages_No_Name.vcg", "classes_No_Name.vcg"] @pytest.fixture(scope="module") -def setup(): +def setup_dot(): + config = Config() + writer = DotWriter(config) + yield from _setup(config, writer) + + +@pytest.fixture(scope="module") +def setup_vcg(): + config = Config() + config.output_format = "vcg" + writer = VCGWriter(config) + yield from _setup(config, writer) + + +def _setup(config, writer): project = get_project(os.path.join(os.path.dirname(__file__), "data")) linker = Linker(project) - CONFIG = Config() - handler = DiadefsHandler(CONFIG) + handler = DiadefsHandler(config) dd = DefaultDiadefGenerator(linker, handler).visit(project) for diagram in dd: diagram.extract_relationships() - writer = DotWriter(CONFIG) writer.write(dd) yield - for fname in DOT_FILES: + for fname in DOT_FILES + VCG_FILES: try: os.remove(fname) except FileNotFoundError: continue -@pytest.mark.usefixtures("setup") +@pytest.mark.usefixtures("setup_dot") @pytest.mark.parametrize("generated_file", DOT_FILES) def test_dot_files(generated_file): expected_file = os.path.join(os.path.dirname(__file__), "data", generated_file) @@ -119,6 +133,22 @@ def test_dot_files(generated_file): os.remove(generated_file) +@pytest.mark.usefixtures("setup_vcg") +@pytest.mark.parametrize("generated_file", VCG_FILES) +def test_vcg_files(generated_file): + expected_file = os.path.join(os.path.dirname(__file__), "data", generated_file) + generated = _file_lines(generated_file) + expected = _file_lines(expected_file) + generated = "\n".join(generated) + expected = "\n".join(expected) + files = f"\n *** expected : {expected_file}, generated : {generated_file} \n" + diff = "\n".join( + line for line in unified_diff(expected.splitlines(), generated.splitlines()) + ) + assert expected == generated, f"{files}{diff}" + os.remove(generated_file) + + @pytest.mark.parametrize( "names, expected", [ From 30a1c25e23d3cc257c6bb1b37ccff2f031b4c1f4 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Wed, 21 Jul 2021 04:50:19 +0200 Subject: [PATCH 2/7] Implement review suggestion - replace ``get_file_extension`` with ``pathlib.Path().suffix`` --- pylint/pyreverse/printer.py | 5 ++--- pylint/pyreverse/utils.py | 12 ------------ pylint/pyreverse/writer.py | 11 ++--------- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py index cc5576f3dd..b8c2eef420 100644 --- a/pylint/pyreverse/printer.py +++ b/pylint/pyreverse/printer.py @@ -23,10 +23,9 @@ import sys import tempfile from enum import Enum +from pathlib import Path from typing import Any, Dict, FrozenSet, List, Mapping, NamedTuple, Optional, Tuple -from pylint.pyreverse.utils import get_file_extension - class NodeType(Enum): CLASS = "class" @@ -459,7 +458,7 @@ def generate(self, outputfile: str) -> None: os.close(pdot) os.close(ppng) else: - target = get_file_extension(outputfile) + target = Path(outputfile).suffix.lstrip(".") if not target: target = "png" outputfile = outputfile + "." + target diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 4b16a59e46..fea9bda792 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -275,15 +275,3 @@ def infer_node(node: Union[astroid.AssignAttr, astroid.AssignName]) -> set: return set(node.infer()) except astroid.InferenceError: return set() - - -def get_file_extension(filename_or_extension: str) -> str: - """ - Get either the file extension if the input string is a filename (i.e. contains a dot), - or return the string as it is assuming it already is just the file extension itself. - """ - basename = os.path.basename(filename_or_extension) - if basename.find(".") > 0: - return os.path.splitext(basename)[-1][1:] - # no real filename, so we assume the input string is just the name of the extension - return filename_or_extension diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index f868e9badf..9a03d89bfd 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -43,6 +43,7 @@ class DiagramWriter: def __init__(self, config): self.config = config self.printer = None # defined in set_printer + self.file_name = "" # defined in set_printer def write(self, diadefs): """write files for according to """ @@ -126,7 +127,7 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties: def save(self) -> None: """write to disk""" - raise NotImplementedError + self.printer.generate(self.file_name) class DotWriter(DiagramWriter): @@ -190,10 +191,6 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties: ) return properties - def save(self) -> None: - """write to disk""" - self.printer.generate(self.file_name) - class VCGWriter(DiagramWriter): """write vcg graphs from a diagram definition and a project""" @@ -236,7 +233,3 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties: for func in methods: label = fr"{label}\n\f10{func}()" return NodeProperties(label=label) - - def save(self) -> None: - """write to disk""" - self.printer.generate(self.file_name) From d223220c0db03786d1073ca27cb5c8118f19e429 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 1 Aug 2021 14:59:03 +0200 Subject: [PATCH 3/7] Create separate file for ``Printer`` base class and ``DotPrinter``, and move ``VCGPrinter`` back into original ``vcgutils.py`` for easier diffing --- pylint/pyreverse/dot_printer.py | 141 +++++++++++ pylint/pyreverse/printer.py | 402 +------------------------------- pylint/pyreverse/vcgutils.py | 281 ++++++++++++++++++++++ pylint/pyreverse/writer.py | 11 +- 4 files changed, 427 insertions(+), 408 deletions(-) create mode 100644 pylint/pyreverse/dot_printer.py create mode 100644 pylint/pyreverse/vcgutils.py diff --git a/pylint/pyreverse/dot_printer.py b/pylint/pyreverse/dot_printer.py new file mode 100644 index 0000000000..b4e3aa5210 --- /dev/null +++ b/pylint/pyreverse/dot_printer.py @@ -0,0 +1,141 @@ +# Copyright (c) 2021 Andreas Finkler + +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE + +""" +Class to generate files in dot format and image formats supported by Graphviz. +""" +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Dict, FrozenSet, Optional + +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer + +ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1")) +SHAPES: Dict[NodeType, str] = { + NodeType.PACKAGE: "box", + NodeType.INTERFACE: "record", + NodeType.CLASS: "record", +} +ARROWS: Dict[EdgeType, Dict] = { + EdgeType.INHERITS: dict(arrowtail="none", arrowhead="empty"), + EdgeType.IMPLEMENTS: dict(arrowtail="node", arrowhead="empty", style="dashed"), + EdgeType.ASSOCIATION: dict( + fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid" + ), + EdgeType.USES: dict(arrowtail="none", arrowhead="open"), +} +RANKDIR: Dict[Layout, str] = { + Layout.LEFT_TO_RIGHT: "LR", + Layout.RIGHT_TO_LEFT: "RL", + Layout.TOP_TO_BOTTOM: "TB", + Layout.BOTTOM_TO_TOP: "BT", +} + + +class DotPrinter(Printer): + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.charset = "utf-8" + self.node_style = "solid" + super().__init__(title, layout, use_automatic_namespace) + + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit(f'digraph "{self.title}" {{') + if self.layout: + self.emit(f"rankdir={RANKDIR[self.layout]}") + if self.charset: + assert ( + self.charset.lower() in ALLOWED_CHARSETS + ), f"unsupported charset {self.charset}" + self.emit(f'charset="{self.charset}"') + + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + if properties is None: + properties = NodeProperties(label=name) + shape = SHAPES[type_] + color = properties.color if properties.color is not None else "black" + label = properties.label + if label: + if type_ is NodeType.INTERFACE: + label = "<>\\n" + label + label_part = f', label="{label}"' + else: + label_part = "" + fontcolor_part = ( + f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else "" + ) + self.emit( + f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];' + ) + + def emit_edge( + self, + from_node: str, + to_node: str, + type_: EdgeType, + label: Optional[str] = None, + ) -> None: + """Create an edge from one node to another to display relationships.""" + arrowstyle = ARROWS[type_] + attrs = [f'{prop}="{value}"' for prop, value in arrowstyle.items()] + if label: + attrs.append(f'label="{label}"') + self.emit(f'"{from_node}" -> "{to_node}" [{", ".join(sorted(attrs))}];') + + def generate(self, outputfile: str) -> None: + self._close_graph() + graphviz_extensions = ("dot", "gv") + name = self.title + if outputfile is None: + target = "png" + pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) + ppng, outputfile = tempfile.mkstemp(".png", name) + os.close(pdot) + os.close(ppng) + else: + target = Path(outputfile).suffix.lstrip(".") + if not target: + target = "png" + outputfile = outputfile + "." + target + if target not in graphviz_extensions: + pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) + os.close(pdot) + else: + dot_sourcepath = outputfile + with open(dot_sourcepath, "w", encoding="utf8") as outfile: + outfile.writelines(self.lines) + if target not in graphviz_extensions: + if shutil.which("dot") is None: + raise RuntimeError( + f"Cannot generate `{outputfile}` because 'dot' " + "executable not found. Install graphviz, or specify a `.gv` " + "outputfile to produce the DOT source code." + ) + use_shell = sys.platform == "win32" + subprocess.call( + ["dot", "-T", target, dot_sourcepath, "-o", outputfile], + shell=use_shell, + ) + os.unlink(dot_sourcepath) + # return outputfile TODO should we return this? + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self.emit("}\n") diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py index b8c2eef420..ef2866fbf8 100644 --- a/pylint/pyreverse/printer.py +++ b/pylint/pyreverse/printer.py @@ -1,30 +1,13 @@ -# Copyright (c) 2015-2018, 2020 Claudiu Popa -# Copyright (c) 2015 Florian Bruhin -# Copyright (c) 2018 ssolanki -# Copyright (c) 2020-2021 Pierre Sassoulas -# Copyright (c) 2020 hippo91 -# Copyright (c) 2020 Ram Rachum -# Copyright (c) 2020 谭九鼎 <109224573@qq.com> -# Copyright (c) 2020 Anthony Sottile -# Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> # Copyright (c) 2021 Andreas Finkler # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/PyCQA/pylint/blob/main/LICENSE """ -Collection of printer classes for diagrams. -Printers are responsible for generating files that can be understood by tools like -Graphviz or PlantUML for example. +Base class defining the interface for a printer. """ -import os -import shutil -import subprocess -import sys -import tempfile from enum import Enum -from pathlib import Path -from typing import Any, Dict, FrozenSet, List, Mapping, NamedTuple, Optional, Tuple +from typing import List, NamedTuple, Optional class NodeType(Enum): @@ -106,384 +89,3 @@ def generate(self, outputfile: str) -> None: def _close_graph(self) -> None: """Emit the lines needed to properly close the graph.""" raise NotImplementedError - - -class VCGPrinter(Printer): - SHAPES: Dict[NodeType, str] = { - NodeType.PACKAGE: "box", - NodeType.CLASS: "box", - NodeType.INTERFACE: "ellipse", - } - ARROWS: Dict[EdgeType, Dict] = { - EdgeType.USES: dict(arrowstyle="solid", backarrowstyle="none", backarrowsize=0), - EdgeType.INHERITS: dict( - arrowstyle="solid", backarrowstyle="none", backarrowsize=10 - ), - EdgeType.IMPLEMENTS: dict( - arrowstyle="solid", - backarrowstyle="none", - linestyle="dotted", - backarrowsize=10, - ), - EdgeType.ASSOCIATION: dict( - arrowstyle="solid", backarrowstyle="none", textcolor="green" - ), - } - ORIENTATION: Dict[Layout, str] = { - Layout.LEFT_TO_RIGHT: "left_to_right", - Layout.RIGHT_TO_LEFT: "right_to_left", - Layout.TOP_TO_BOTTOM: "top_to_bottom", - Layout.BOTTOM_TO_TOP: "bottom_to_top", - } - ATTRS_VAL: Dict[str, Tuple] = { - "algos": ( - "dfs", - "tree", - "minbackward", - "left_to_right", - "right_to_left", - "top_to_bottom", - "bottom_to_top", - "maxdepth", - "maxdepthslow", - "mindepth", - "mindepthslow", - "mindegree", - "minindegree", - "minoutdegree", - "maxdegree", - "maxindegree", - "maxoutdegree", - ), - "booleans": ("yes", "no"), - "colors": ( - "black", - "white", - "blue", - "red", - "green", - "yellow", - "magenta", - "lightgrey", - "cyan", - "darkgrey", - "darkblue", - "darkred", - "darkgreen", - "darkyellow", - "darkmagenta", - "darkcyan", - "gold", - "lightblue", - "lightred", - "lightgreen", - "lightyellow", - "lightmagenta", - "lightcyan", - "lilac", - "turquoise", - "aquamarine", - "khaki", - "purple", - "yellowgreen", - "pink", - "orange", - "orchid", - ), - "shapes": ("box", "ellipse", "rhomb", "triangle"), - "textmodes": ("center", "left_justify", "right_justify"), - "arrowstyles": ("solid", "line", "none"), - "linestyles": ("continuous", "dashed", "dotted", "invisible"), - } - - # meaning of possible values: - # O -> string - # 1 -> int - # list -> value in list - GRAPH_ATTRS: Dict[str, Any] = { - "title": 0, - "label": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "bordercolor": ATTRS_VAL["colors"], - "width": 1, - "height": 1, - "borderwidth": 1, - "textmode": ATTRS_VAL["textmodes"], - "shape": ATTRS_VAL["shapes"], - "shrink": 1, - "stretch": 1, - "orientation": ATTRS_VAL["algos"], - "vertical_order": 1, - "horizontal_order": 1, - "xspace": 1, - "yspace": 1, - "layoutalgorithm": ATTRS_VAL["algos"], - "late_edge_labels": ATTRS_VAL["booleans"], - "display_edge_labels": ATTRS_VAL["booleans"], - "dirty_edge_labels": ATTRS_VAL["booleans"], - "finetuning": ATTRS_VAL["booleans"], - "manhattan_edges": ATTRS_VAL["booleans"], - "smanhattan_edges": ATTRS_VAL["booleans"], - "port_sharing": ATTRS_VAL["booleans"], - "edges": ATTRS_VAL["booleans"], - "nodes": ATTRS_VAL["booleans"], - "splines": ATTRS_VAL["booleans"], - } - NODE_ATTRS: Dict[str, Any] = { - "title": 0, - "label": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "bordercolor": ATTRS_VAL["colors"], - "width": 1, - "height": 1, - "borderwidth": 1, - "textmode": ATTRS_VAL["textmodes"], - "shape": ATTRS_VAL["shapes"], - "shrink": 1, - "stretch": 1, - "vertical_order": 1, - "horizontal_order": 1, - } - EDGE_ATTRS: Dict[str, Any] = { - "sourcename": 0, - "targetname": 0, - "label": 0, - "linestyle": ATTRS_VAL["linestyles"], - "class": 1, - "thickness": 0, - "color": ATTRS_VAL["colors"], - "textcolor": ATTRS_VAL["colors"], - "arrowcolor": ATTRS_VAL["colors"], - "backarrowcolor": ATTRS_VAL["colors"], - "arrowsize": 1, - "backarrowsize": 1, - "arrowstyle": ATTRS_VAL["arrowstyles"], - "backarrowstyle": ATTRS_VAL["arrowstyles"], - "textmode": ATTRS_VAL["textmodes"], - "priority": 1, - "anchor": 1, - "horizontal_order": 1, - } - - def __init__( - self, - title: str, - layout: Optional[Layout] = None, - use_automatic_namespace: Optional[bool] = None, - ): - self._indent = "" - super().__init__(title, layout, use_automatic_namespace) - - def _inc_indent(self) -> None: - self._indent += " " - - def _dec_indent(self) -> None: - self._indent = self._indent[:-2] - - def _open_graph(self) -> None: - """Emit the header lines""" - self.emit(f"{self._indent}graph:{{\n") - self._inc_indent() - self._write_attributes( - self.GRAPH_ATTRS, - title=self.title, - layoutalgorithm="dfs", - late_edge_labels="yes", - port_sharing="no", - manhattan_edges="yes", - ) - if self.layout: - self._write_attributes( - self.GRAPH_ATTRS, orientation=self.ORIENTATION[self.layout] - ) - - def emit_node( - self, - name: str, - type_: NodeType, - properties: Optional[NodeProperties] = None, - ) -> None: - """Create a new node. Nodes can be classes, packages, participants etc.""" - if properties is None: - properties = NodeProperties(label=name) - self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False) - label = properties.label if properties.label is not None else name - self._write_attributes( - self.NODE_ATTRS, - label=label, - shape=self.SHAPES[type_], - ) - self.emit("}") - - def emit_edge( - self, - from_node: str, - to_node: str, - type_: EdgeType, - label: Optional[str] = None, - ) -> None: - """Create an edge from one node to another to display relationships.""" - self.emit( - f'{self._indent}edge: {{sourcename:"{from_node}" targetname:"{to_node}"', - force_newline=False, - ) - attributes = self.ARROWS[type_] - if label: - attributes["label"] = label - self._write_attributes( - self.EDGE_ATTRS, - **attributes, - ) - self.emit("}") - - def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None: - """write graph, node or edge attributes""" - for key, value in args.items(): - try: - _type = attributes_dict[key] - except KeyError as e: - raise Exception( - f"no such attribute {key}\npossible attributes are {attributes_dict.keys()}" - ) from e - - if not _type: - self.emit(f'{self._indent}{key}:"{value}"\n') - elif _type == 1: - self.emit(f"{self._indent}{key}:{int(value)}\n") - elif value in _type: - self.emit(f"{self._indent}{key}:{value}\n") - else: - raise Exception( - f"value {value} isn't correct for attribute {key} correct values are {type}" - ) - - def _close_graph(self) -> None: - """Emit the lines needed to properly close the graph.""" - self._dec_indent() - self.emit(f"{self._indent}}}") - - -class DotPrinter(Printer): - ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1")) - SHAPES: Dict[NodeType, str] = { - NodeType.PACKAGE: "box", - NodeType.INTERFACE: "record", - NodeType.CLASS: "record", - } - ARROWS: Dict[EdgeType, Dict] = { - EdgeType.INHERITS: dict(arrowtail="none", arrowhead="empty"), - EdgeType.IMPLEMENTS: dict(arrowtail="node", arrowhead="empty", style="dashed"), - EdgeType.ASSOCIATION: dict( - fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid" - ), - EdgeType.USES: dict(arrowtail="none", arrowhead="open"), - } - RANKDIR: Dict[Layout, str] = { - Layout.LEFT_TO_RIGHT: "LR", - Layout.RIGHT_TO_LEFT: "RL", - Layout.TOP_TO_BOTTOM: "TB", - Layout.BOTTOM_TO_TOP: "BT", - } - - def __init__( - self, - title: str, - layout: Optional[Layout] = None, - use_automatic_namespace: Optional[bool] = None, - ): - self.charset = "utf-8" - self.node_style = "solid" - super().__init__(title, layout, use_automatic_namespace) - - def _open_graph(self) -> None: - """Emit the header lines""" - self.emit(f'digraph "{self.title}" {{') - if self.layout: - self.emit(f"rankdir={self.RANKDIR[self.layout]}") - if self.charset: - assert ( - self.charset.lower() in self.ALLOWED_CHARSETS - ), f"unsupported charset {self.charset}" - self.emit(f'charset="{self.charset}"') - - def emit_node( - self, - name: str, - type_: NodeType, - properties: Optional[NodeProperties] = None, - ) -> None: - """Create a new node. Nodes can be classes, packages, participants etc.""" - if properties is None: - properties = NodeProperties(label=name) - shape = self.SHAPES[type_] - color = properties.color if properties.color is not None else "black" - label = properties.label - if label: - if type_ is NodeType.INTERFACE: - label = "<>\\n" + label - label_part = f', label="{label}"' - else: - label_part = "" - fontcolor_part = ( - f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else "" - ) - self.emit( - f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];' - ) - - def emit_edge( - self, - from_node: str, - to_node: str, - type_: EdgeType, - label: Optional[str] = None, - ) -> None: - """Create an edge from one node to another to display relationships.""" - arrowstyle = self.ARROWS[type_] - attrs = [f'{prop}="{value}"' for prop, value in arrowstyle.items()] - if label: - attrs.append(f'label="{label}"') - self.emit(f'"{from_node}" -> "{to_node}" [{", ".join(sorted(attrs))}];') - - def generate(self, outputfile: str) -> None: - self._close_graph() - graphviz_extensions = ("dot", "gv") - name = self.title - if outputfile is None: - target = "png" - pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) - ppng, outputfile = tempfile.mkstemp(".png", name) - os.close(pdot) - os.close(ppng) - else: - target = Path(outputfile).suffix.lstrip(".") - if not target: - target = "png" - outputfile = outputfile + "." + target - if target not in graphviz_extensions: - pdot, dot_sourcepath = tempfile.mkstemp(".gv", name) - os.close(pdot) - else: - dot_sourcepath = outputfile - with open(dot_sourcepath, "w", encoding="utf8") as outfile: - outfile.writelines(self.lines) - if target not in graphviz_extensions: - if shutil.which("dot") is None: - raise RuntimeError( - f"Cannot generate `{outputfile}` because 'dot' " - "executable not found. Install graphviz, or specify a `.gv` " - "outputfile to produce the DOT source code." - ) - use_shell = sys.platform == "win32" - subprocess.call( - ["dot", "-T", target, dot_sourcepath, "-o", outputfile], - shell=use_shell, - ) - os.unlink(dot_sourcepath) - # return outputfile TODO should we return this? - - def _close_graph(self) -> None: - """Emit the lines needed to properly close the graph.""" - self.emit("}\n") diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py new file mode 100644 index 0000000000..166c0b6551 --- /dev/null +++ b/pylint/pyreverse/vcgutils.py @@ -0,0 +1,281 @@ +# Copyright (c) 2015-2018, 2020 Claudiu Popa +# Copyright (c) 2015 Florian Bruhin +# Copyright (c) 2018 ssolanki +# Copyright (c) 2020-2021 Pierre Sassoulas +# Copyright (c) 2020 hippo91 +# Copyright (c) 2020 Ram Rachum +# Copyright (c) 2020 谭九鼎 <109224573@qq.com> +# Copyright (c) 2020 Anthony Sottile +# Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> +# Copyright (c) 2021 Andreas Finkler + +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE + +"""Functions to generate files readable with Georg Sander's vcg +(Visualization of Compiler Graphs). +You can download vcg at https://rw4.cs.uni-sb.de/~sander/html/gshome.html +Note that vcg exists as a debian package. +See vcg's documentation for explanation about the different values that +maybe used for the functions parameters. +""" +from typing import Any, Dict, Mapping, Optional + +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer + +ATTRS_VAL = { + "algos": ( + "dfs", + "tree", + "minbackward", + "left_to_right", + "right_to_left", + "top_to_bottom", + "bottom_to_top", + "maxdepth", + "maxdepthslow", + "mindepth", + "mindepthslow", + "mindegree", + "minindegree", + "minoutdegree", + "maxdegree", + "maxindegree", + "maxoutdegree", + ), + "booleans": ("yes", "no"), + "colors": ( + "black", + "white", + "blue", + "red", + "green", + "yellow", + "magenta", + "lightgrey", + "cyan", + "darkgrey", + "darkblue", + "darkred", + "darkgreen", + "darkyellow", + "darkmagenta", + "darkcyan", + "gold", + "lightblue", + "lightred", + "lightgreen", + "lightyellow", + "lightmagenta", + "lightcyan", + "lilac", + "turquoise", + "aquamarine", + "khaki", + "purple", + "yellowgreen", + "pink", + "orange", + "orchid", + ), + "shapes": ("box", "ellipse", "rhomb", "triangle"), + "textmodes": ("center", "left_justify", "right_justify"), + "arrowstyles": ("solid", "line", "none"), + "linestyles": ("continuous", "dashed", "dotted", "invisible"), +} + +# meaning of possible values: +# O -> string +# 1 -> int +# list -> value in list +GRAPH_ATTRS = { + "title": 0, + "label": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "bordercolor": ATTRS_VAL["colors"], + "width": 1, + "height": 1, + "borderwidth": 1, + "textmode": ATTRS_VAL["textmodes"], + "shape": ATTRS_VAL["shapes"], + "shrink": 1, + "stretch": 1, + "orientation": ATTRS_VAL["algos"], + "vertical_order": 1, + "horizontal_order": 1, + "xspace": 1, + "yspace": 1, + "layoutalgorithm": ATTRS_VAL["algos"], + "late_edge_labels": ATTRS_VAL["booleans"], + "display_edge_labels": ATTRS_VAL["booleans"], + "dirty_edge_labels": ATTRS_VAL["booleans"], + "finetuning": ATTRS_VAL["booleans"], + "manhattan_edges": ATTRS_VAL["booleans"], + "smanhattan_edges": ATTRS_VAL["booleans"], + "port_sharing": ATTRS_VAL["booleans"], + "edges": ATTRS_VAL["booleans"], + "nodes": ATTRS_VAL["booleans"], + "splines": ATTRS_VAL["booleans"], +} +NODE_ATTRS = { + "title": 0, + "label": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "bordercolor": ATTRS_VAL["colors"], + "width": 1, + "height": 1, + "borderwidth": 1, + "textmode": ATTRS_VAL["textmodes"], + "shape": ATTRS_VAL["shapes"], + "shrink": 1, + "stretch": 1, + "vertical_order": 1, + "horizontal_order": 1, +} +EDGE_ATTRS = { + "sourcename": 0, + "targetname": 0, + "label": 0, + "linestyle": ATTRS_VAL["linestyles"], + "class": 1, + "thickness": 0, + "color": ATTRS_VAL["colors"], + "textcolor": ATTRS_VAL["colors"], + "arrowcolor": ATTRS_VAL["colors"], + "backarrowcolor": ATTRS_VAL["colors"], + "arrowsize": 1, + "backarrowsize": 1, + "arrowstyle": ATTRS_VAL["arrowstyles"], + "backarrowstyle": ATTRS_VAL["arrowstyles"], + "textmode": ATTRS_VAL["textmodes"], + "priority": 1, + "anchor": 1, + "horizontal_order": 1, +} +SHAPES: Dict[NodeType, str] = { + NodeType.PACKAGE: "box", + NodeType.CLASS: "box", + NodeType.INTERFACE: "ellipse", +} +ARROWS: Dict[EdgeType, Dict] = { + EdgeType.USES: dict(arrowstyle="solid", backarrowstyle="none", backarrowsize=0), + EdgeType.INHERITS: dict( + arrowstyle="solid", backarrowstyle="none", backarrowsize=10 + ), + EdgeType.IMPLEMENTS: dict( + arrowstyle="solid", + backarrowstyle="none", + linestyle="dotted", + backarrowsize=10, + ), + EdgeType.ASSOCIATION: dict( + arrowstyle="solid", backarrowstyle="none", textcolor="green" + ), +} +ORIENTATION: Dict[Layout, str] = { + Layout.LEFT_TO_RIGHT: "left_to_right", + Layout.RIGHT_TO_LEFT: "right_to_left", + Layout.TOP_TO_BOTTOM: "top_to_bottom", + Layout.BOTTOM_TO_TOP: "bottom_to_top", +} + +# Misc utilities ############################################################### + + +class VCGPrinter(Printer): + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self._indent = "" + super().__init__(title, layout, use_automatic_namespace) + + def _inc_indent(self) -> None: + self._indent += " " + + def _dec_indent(self) -> None: + self._indent = self._indent[:-2] + + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit(f"{self._indent}graph:{{\n") + self._inc_indent() + self._write_attributes( + GRAPH_ATTRS, + title=self.title, + layoutalgorithm="dfs", + late_edge_labels="yes", + port_sharing="no", + manhattan_edges="yes", + ) + if self.layout: + self._write_attributes(GRAPH_ATTRS, orientation=ORIENTATION[self.layout]) + + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + if properties is None: + properties = NodeProperties(label=name) + self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False) + label = properties.label if properties.label is not None else name + self._write_attributes( + NODE_ATTRS, + label=label, + shape=SHAPES[type_], + ) + self.emit("}") + + def emit_edge( + self, + from_node: str, + to_node: str, + type_: EdgeType, + label: Optional[str] = None, + ) -> None: + """Create an edge from one node to another to display relationships.""" + self.emit( + f'{self._indent}edge: {{sourcename:"{from_node}" targetname:"{to_node}"', + force_newline=False, + ) + attributes = ARROWS[type_] + if label: + attributes["label"] = label + self._write_attributes( + EDGE_ATTRS, + **attributes, + ) + self.emit("}") + + def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None: + """write graph, node or edge attributes""" + for key, value in args.items(): + try: + _type = attributes_dict[key] + except KeyError as e: + raise Exception( + f"no such attribute {key}\npossible attributes are {attributes_dict.keys()}" + ) from e + + if not _type: + self.emit(f'{self._indent}{key}:"{value}"\n') + elif _type == 1: + self.emit(f"{self._indent}{key}:{int(value)}\n") + elif value in _type: + self.emit(f"{self._indent}{key}:{value}\n") + else: + raise Exception( + f"value {value} isn't correct for attribute {key} correct values are {type}" + ) + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self._dec_indent() + self.emit(f"{self._indent}}}") diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 9a03d89bfd..ac7d80dc96 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -26,15 +26,10 @@ PackageDiagram, PackageEntity, ) -from pylint.pyreverse.printer import ( - DotPrinter, - EdgeType, - Layout, - NodeProperties, - NodeType, - VCGPrinter, -) +from pylint.pyreverse.dot_printer import DotPrinter +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType from pylint.pyreverse.utils import get_annotation_label, is_exception +from pylint.pyreverse.vcgutils import VCGPrinter class DiagramWriter: From 7fff7030390d4977a42ecca5c91558f5077f72fb Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 1 Aug 2021 15:07:30 +0200 Subject: [PATCH 4/7] Use ``abc.ABC`` as metaclass for ``Printer`` instead of raising ``NotImplementedError`` --- pylint/pyreverse/printer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py index ef2866fbf8..66700c2d62 100644 --- a/pylint/pyreverse/printer.py +++ b/pylint/pyreverse/printer.py @@ -6,6 +6,7 @@ """ Base class defining the interface for a printer. """ +from abc import ABC, abstractmethod from enum import Enum from typing import List, NamedTuple, Optional @@ -37,7 +38,7 @@ class NodeProperties(NamedTuple): body: Optional[str] = None -class Printer: +class Printer(ABC): """Base class defining the interface for a printer""" def __init__( @@ -52,15 +53,16 @@ def __init__( self.lines: List[str] = [] self._open_graph() + @abstractmethod def _open_graph(self) -> None: - """Emit the header lines""" - raise NotImplementedError + """Emit the header lines, i.e. all boilerplate code that defines things like layout etc.""" def emit(self, line: str, force_newline: Optional[bool] = True) -> None: if force_newline and not line.endswith("\n"): line += "\n" self.lines.append(line) + @abstractmethod def emit_node( self, name: str, @@ -68,8 +70,8 @@ def emit_node( properties: Optional[NodeProperties] = None, ) -> None: """Create a new node. Nodes can be classes, packages, participants etc.""" - raise NotImplementedError + @abstractmethod def emit_edge( self, from_node: str, @@ -78,7 +80,6 @@ def emit_edge( label: Optional[str] = None, ) -> None: """Create an edge from one node to another to display relationships.""" - raise NotImplementedError def generate(self, outputfile: str) -> None: """Generate and save the final outputfile.""" @@ -86,6 +87,6 @@ def generate(self, outputfile: str) -> None: with open(outputfile, "w", encoding="utf-8") as outfile: outfile.writelines(self.lines) + @abstractmethod def _close_graph(self) -> None: """Emit the lines needed to properly close the graph.""" - raise NotImplementedError From 1672353b48d50754eaf5d89095a3f93f2012dc73 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 1 Aug 2021 15:15:23 +0200 Subject: [PATCH 5/7] Move some methods back to original position to make diffing easier --- pylint/pyreverse/vcgutils.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py index 166c0b6551..86df2f6c06 100644 --- a/pylint/pyreverse/vcgutils.py +++ b/pylint/pyreverse/vcgutils.py @@ -194,12 +194,6 @@ def __init__( self._indent = "" super().__init__(title, layout, use_automatic_namespace) - def _inc_indent(self) -> None: - self._indent += " " - - def _dec_indent(self) -> None: - self._indent = self._indent[:-2] - def _open_graph(self) -> None: """Emit the header lines""" self.emit(f"{self._indent}graph:{{\n") @@ -215,6 +209,11 @@ def _open_graph(self) -> None: if self.layout: self._write_attributes(GRAPH_ATTRS, orientation=ORIENTATION[self.layout]) + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self._dec_indent() + self.emit(f"{self._indent}}}") + def emit_node( self, name: str, @@ -275,7 +274,10 @@ def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None: f"value {value} isn't correct for attribute {key} correct values are {type}" ) - def _close_graph(self) -> None: - """Emit the lines needed to properly close the graph.""" - self._dec_indent() - self.emit(f"{self._indent}}}") + def _inc_indent(self): + """increment indentation""" + self._indent += " " + + def _dec_indent(self): + """decrement indentation""" + self._indent = self._indent[:-2] From e299c6453461ca0b24f9945beb0a9781b9f8dd48 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 1 Aug 2021 21:10:43 +0200 Subject: [PATCH 6/7] Implement suggested changes from review. --- pylint/pyreverse/dot_printer.py | 18 +++--------------- pylint/pyreverse/main.py | 18 ++---------------- pylint/pyreverse/utils.py | 14 ++++++++++++++ pylint/pyreverse/writer.py | 19 +++++++++++-------- 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/pylint/pyreverse/dot_printer.py b/pylint/pyreverse/dot_printer.py index b4e3aa5210..af90e4a537 100644 --- a/pylint/pyreverse/dot_printer.py +++ b/pylint/pyreverse/dot_printer.py @@ -7,7 +7,6 @@ Class to generate files in dot format and image formats supported by Graphviz. """ import os -import shutil import subprocess import sys import tempfile @@ -15,6 +14,7 @@ from typing import Dict, FrozenSet, Optional from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer +from pylint.pyreverse.utils import check_graphviz_availability ALLOWED_CHARSETS: FrozenSet[str] = frozenset(("utf-8", "iso-8859-1", "latin1")) SHAPES: Dict[NodeType, str] = { @@ -30,12 +30,6 @@ ), EdgeType.USES: dict(arrowtail="none", arrowhead="open"), } -RANKDIR: Dict[Layout, str] = { - Layout.LEFT_TO_RIGHT: "LR", - Layout.RIGHT_TO_LEFT: "RL", - Layout.TOP_TO_BOTTOM: "TB", - Layout.BOTTOM_TO_TOP: "BT", -} class DotPrinter(Printer): @@ -53,7 +47,7 @@ def _open_graph(self) -> None: """Emit the header lines""" self.emit(f'digraph "{self.title}" {{') if self.layout: - self.emit(f"rankdir={RANKDIR[self.layout]}") + self.emit(f"rankdir={self.layout.value}") if self.charset: assert ( self.charset.lower() in ALLOWED_CHARSETS @@ -122,19 +116,13 @@ 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: - if shutil.which("dot") is None: - raise RuntimeError( - f"Cannot generate `{outputfile}` because 'dot' " - "executable not found. Install graphviz, or specify a `.gv` " - "outputfile to produce the DOT source code." - ) + check_graphviz_availability() use_shell = sys.platform == "win32" subprocess.call( ["dot", "-T", target, dot_sourcepath, "-o", outputfile], shell=use_shell, ) os.unlink(dot_sourcepath) - # return outputfile TODO should we return this? def _close_graph(self) -> None: """Emit the lines needed to properly close the graph.""" diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index bfa0eb783d..5e5dc1b057 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -21,7 +21,6 @@ create UML diagrams for classes and modules in """ import os -import subprocess import sys from typing import Iterable @@ -29,7 +28,7 @@ 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 insert_default_options +from pylint.pyreverse.utils import check_graphviz_availability, insert_default_options OPTIONS = ( ( @@ -175,19 +174,6 @@ ) -def _check_graphviz_available(output_format): - """check if we need graphviz for different output format""" - try: - subprocess.call(["dot", "-V"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except OSError: - print( - "The output format '%s' is currently not available.\n" - "Please install 'Graphviz' to have other output formats " - "than 'dot' or 'vcg'." % output_format - ) - sys.exit(32) - - class Run(ConfigurationMixIn): """base class providing common behaviour for pyreverse commands""" @@ -198,7 +184,7 @@ def __init__(self, args: Iterable[str]): insert_default_options() args = self.load_command_line_configuration(args) if self.config.output_format not in ("dot", "vcg"): - _check_graphviz_available(self.config.output_format) + check_graphviz_availability() sys.exit(self.run(args)) diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 6eb9d43ccf..7ca4501f1f 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -20,6 +20,7 @@ """Generic classes/functions for pyreverse core/extensions. """ import os import re +import shutil import sys from typing import Optional, Union @@ -275,3 +276,16 @@ def infer_node(node: Union[astroid.AssignAttr, astroid.AssignName]) -> set: return set(node.infer()) except astroid.InferenceError: return {ann} if ann else set() + + +def check_graphviz_availability(): + """Check if the ``dot`` command is available on the machine. + This is needed if image output is desired and ``dot`` is used to convert + from *.dot or *.gv into the final output format.""" + 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'." + ) + sys.exit(32) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index ac7d80dc96..f3987d7744 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -18,6 +18,9 @@ """Utilities for creating VCG and Dot diagrams""" import os +from typing import List + +import astroid from pylint.pyreverse.diagrams import ( ClassDiagram, @@ -57,12 +60,12 @@ def write(self, diadefs): def write_packages(self, diagram: PackageDiagram) -> None: """write a package diagram""" # sorted to get predictable (hence testable) results - for obj in sorted(diagram.modules(), key=lambda x: x.title): - obj.fig_id = obj.node.qname() + for module in sorted(diagram.modules(), key=lambda x: x.title): + module.fig_id = module.node.qname() self.printer.emit_node( - obj.fig_id, + module.fig_id, type_=NodeType.PACKAGE, - properties=self.get_package_properties(obj), + properties=self.get_package_properties(module), ) # package dependencies for rel in diagram.get_relationships("depends"): @@ -158,14 +161,14 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties: ) if func.args.args: - argument_list = [ + arguments: List[astroid.AssignName] = [ arg for arg in func.args.args if arg.name != "self" ] else: - argument_list = [] + arguments = [] - annotations = dict(zip(argument_list, func.args.annotations[1:])) - for arg in argument_list: + annotations = dict(zip(arguments, func.args.annotations[1:])) + for arg in arguments: annotation_label = "" ann = annotations.get(arg) if ann: From 5f624b7b193c65b7be83ad5ea80830b645d1df10 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 3 Aug 2021 06:30:49 +0200 Subject: [PATCH 7/7] Rename ``vcgutils.py`` to ``vcg_printer.py`` --- pylint/pyreverse/{vcgutils.py => vcg_printer.py} | 0 pylint/pyreverse/writer.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename pylint/pyreverse/{vcgutils.py => vcg_printer.py} (100%) diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcg_printer.py similarity index 100% rename from pylint/pyreverse/vcgutils.py rename to pylint/pyreverse/vcg_printer.py diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index f3987d7744..ee8a922b92 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -32,7 +32,7 @@ from pylint.pyreverse.dot_printer import DotPrinter from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType from pylint.pyreverse.utils import get_annotation_label, is_exception -from pylint.pyreverse.vcgutils import VCGPrinter +from pylint.pyreverse.vcg_printer import VCGPrinter class DiagramWriter: