diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 94b2906f2e..496be0424b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: rev: v1.4 hooks: - id: autoflake - exclude: &fixtures tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|tests/data/ + exclude: &fixtures tests/functional/|tests/input|tests(/.*)*/data|tests/regrtest_data/ args: - --in-place - --remove-all-unused-imports @@ -60,7 +60,7 @@ repos: "--load-plugins=pylint.extensions.docparams", ] # disabled plugins: pylint.extensions.mccabe - exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|tests/data/|doc/ + exclude: tests/functional/|tests/input|tests/regrtest_data/|tests(/.*)*/data/|doc/ - id: fix-documentation name: Fix documentation entry: python3 -m script.fix_documentation @@ -78,7 +78,7 @@ repos: args: [] require_serial: true additional_dependencies: ["types-pkg_resources==0.1.3", "types-toml==0.1.3"] - exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|tests/data/|doc/|bin/ + exclude: tests/functional/|tests/input|tests(/.*)*/data|tests/regrtest_data/|tests/data/|tests(/.*)+/conftest.py|doc/|bin/ - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.3.1 hooks: diff --git a/ChangeLog b/ChangeLog index a0f12a7a88..82ea89af1e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -9,6 +9,14 @@ Release date: TBA .. Put new features and bugfixes here and also in 'doc/whatsnew/2.9.rst' +* Add PlantUML as possible output format for ``pyreverse`` and the ``--import-graph`` option + + Closes #4498 + +* Added ``--colorized`` option to ``pyreverse`` to visualize modules/packages of the same parent package. + + Closes #4488 + * Add type annotations to pyreverse dot files Closes #1548 diff --git a/doc/whatsnew/2.9.rst b/doc/whatsnew/2.9.rst index 514dcb3ee5..2a84570c23 100644 --- a/doc/whatsnew/2.9.rst +++ b/doc/whatsnew/2.9.rst @@ -49,6 +49,10 @@ New checkers Other Changes ============= +* Add support for PlantUML output to pyreverse + +* Add colorized output for pyreverse + * Add type annotations to pyreverse dot files * Pylint's tags are now the standard form ``vX.Y.Z`` and not ``pylint-X.Y.Z`` anymore. @@ -73,6 +77,8 @@ Other Changes * ``ignore-paths`` configuration directive has been added. Defined regex patterns are matched against file path. +* ``pyreverse`` now supports automatic coloring of package and class diagrams for .dot files by passing the ``--colorized`` option. + * Added handling of floating point values when parsing configuration from pyproject.toml * Fix false positive ``useless-type-doc`` on ignored argument using ``pylint.extensions.docparams`` when a function diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py index ceadaef90d..45c6a9c3d3 100644 --- a/pylint/checkers/imports.py +++ b/pylint/checkers/imports.py @@ -37,6 +37,7 @@ # Copyright (c) 2021 yushao2 <36848472+yushao2@users.noreply.github.com> # Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> # Copyright (c) 2021 Matus Valo +# Copyright (c) 2021 Andreas Finkler # Copyright (c) 2021 Andrew Howe # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html @@ -60,8 +61,13 @@ node_ignores_exception, ) from pylint.exceptions import EmptyReportError -from pylint.graph import DotBackend, get_cycles from pylint.interfaces import IAstroidChecker +from pylint.pyreverse.printer import ( + EdgeType, + Layout, + NodeType, + get_printer_for_filetype, +) from pylint.reporters.ureports.nodes import Paragraph, VerbatimText, VNode from pylint.utils import IsortDriver, get_global_option @@ -191,30 +197,74 @@ def _repr_tree_defs(data, indent_str=None): return "\n".join(lines) -def _dependencies_graph(filename: str, dep_info: Dict[str, List[str]]) -> str: +def _dependencies_graph(filename: str, dep_info: Dict[str, List[str]]) -> None: """write dependencies as a dot (graphviz) file""" done = {} - printer = DotBackend(os.path.splitext(os.path.basename(filename))[0], rankdir="LR") - printer.emit('URL="." node[shape="box"]') + printer = get_printer_for_filetype(filename)( + os.path.splitext(os.path.basename(filename))[0], layout=Layout.LEFT_TO_RIGHT + ) for modname, dependencies in sorted(dep_info.items()): done[modname] = 1 - printer.emit_node(modname) + printer.emit_node(modname, type_=NodeType.PACKAGE) for depmodname in dependencies: if depmodname not in done: done[depmodname] = 1 - printer.emit_node(depmodname) + printer.emit_node(depmodname, type_=NodeType.PACKAGE) for depmodname, dependencies in sorted(dep_info.items()): for modname in dependencies: - printer.emit_edge(modname, depmodname) - return printer.generate(filename) + printer.emit_edge(modname, depmodname, type_=EdgeType.USES) + printer.generate(filename) def _make_graph(filename: str, dep_info: Dict[str, List[str]], sect: VNode, gtype: str): """generate a dependencies graph and add some information about it in the report's section """ - outputfile = _dependencies_graph(filename, dep_info) - sect.append(Paragraph(f"{gtype}imports graph has been written to {outputfile}")) + _dependencies_graph(filename, dep_info) + sect.append(Paragraph(f"{gtype}imports graph has been written to {filename}")) + + +def get_cycles(graph_dict, vertices=None): + """given a dictionary representing an ordered graph (i.e. key are vertices + and values is a list of destination vertices representing edges), return a + list of detected cycles + """ + if not graph_dict: + return () + result = [] + if vertices is None: + vertices = graph_dict.keys() + for vertice in vertices: + _get_cycles(graph_dict, [], set(), result, vertice) + return result + + +def _get_cycles(graph_dict, path, visited, result, vertice): + """recursive function doing the real work for get_cycles""" + if vertice in path: + cycle = [vertice] + for node in path[::-1]: + if node == vertice: + break + cycle.insert(0, node) + # make a canonical representation + start_from = min(cycle) + index = cycle.index(start_from) + cycle = cycle[index:] + cycle[0:index] + # append it to result if not already in + if cycle not in result: + result.append(cycle) + return + path.append(vertice) + try: + for node in graph_dict[vertice]: + # don't check already visited nodes again + if node not in visited: + _get_cycles(graph_dict, path, visited, result, node) + visited.add(node) + except KeyError: + pass + path.pop() # the import checker itself ################################################### diff --git a/pylint/graph.py b/pylint/graph.py deleted file mode 100644 index 366206a376..0000000000 --- a/pylint/graph.py +++ /dev/null @@ -1,207 +0,0 @@ -# Copyright (c) 2015-2018, 2020 Claudiu Popa -# Copyright (c) 2015 Florian Bruhin -# Copyright (c) 2016 Ashley Whetter -# Copyright (c) 2018 ssolanki -# Copyright (c) 2019, 2021 Pierre Sassoulas -# Copyright (c) 2019 Nick Drozd -# Copyright (c) 2020 hippo91 -# Copyright (c) 2020 Damien Baty -# Copyright (c) 2020 谭九鼎 <109224573@qq.com> -# Copyright (c) 2020 Benjamin Graham -# Copyright (c) 2021 Andreas Finkler -# Copyright (c) 2021 Andrew Howe - -# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -# For details: https://github.com/PyCQA/pylint/blob/master/LICENSE - -"""Graph manipulation utilities. - -(dot generation adapted from pypy/translator/tool/make_dot.py) -""" -import codecs -import os -import shutil -import subprocess -import sys -import tempfile - - -def target_info_from_filename(filename): - """Transforms /some/path/foo.png into ('/some/path', 'foo.png', 'png').""" - basename = os.path.basename(filename) - storedir = os.path.dirname(os.path.abspath(filename)) - target = os.path.splitext(filename)[-1][1:] - return storedir, basename, target - - -class DotBackend: - """Dot File backend.""" - - def __init__( - self, - graphname, - rankdir=None, - size=None, - ratio=None, - charset="utf-8", - renderer="dot", - additional_param=None, - ): - if additional_param is None: - additional_param = {} - self.graphname = graphname - self.renderer = renderer - self.lines = [] - self._source = None - self.emit("digraph %s {" % normalize_node_id(graphname)) - if rankdir: - self.emit("rankdir=%s" % rankdir) - if ratio: - self.emit("ratio=%s" % ratio) - if size: - self.emit('size="%s"' % size) - if charset: - assert charset.lower() in ("utf-8", "iso-8859-1", "latin1"), ( - "unsupported charset %s" % charset - ) - self.emit('charset="%s"' % charset) - for param in additional_param.items(): - self.emit("=".join(param)) - - def get_source(self): - """returns self._source""" - if self._source is None: - self.emit("}\n") - self._source = "\n".join(self.lines) - del self.lines - return self._source - - source = property(get_source) - - def generate(self, outputfile: str = None, mapfile: str = None) -> str: - """Generates a graph file. - - :param str outputfile: filename and path [defaults to graphname.png] - :param str mapfile: filename and path - - :rtype: str - :return: a path to the generated file - :raises RuntimeError: if the executable for rendering was not found - """ - graphviz_extensions = ("dot", "gv") - name = self.graphname - 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 = target_info_from_filename(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 codecs.open(dot_sourcepath, "w", encoding="utf8") as pdot: # type: ignore - pdot.write(self.source) # type: ignore - if target not in graphviz_extensions: - if shutil.which(self.renderer) is None: - raise RuntimeError( - f"Cannot generate `{outputfile}` because '{self.renderer}' " - "executable not found. Install graphviz, or specify a `.gv` " - "outputfile to produce the DOT source code." - ) - use_shell = sys.platform == "win32" - if mapfile: - subprocess.call( - [ - self.renderer, - "-Tcmapx", - "-o", - mapfile, - "-T", - target, - dot_sourcepath, - "-o", - outputfile, - ], - shell=use_shell, - ) - else: - subprocess.call( - [self.renderer, "-T", target, dot_sourcepath, "-o", outputfile], - shell=use_shell, - ) - os.unlink(dot_sourcepath) - return outputfile - - def emit(self, line): - """Adds to final output.""" - self.lines.append(line) - - def emit_edge(self, name1, name2, **props): - """emit an edge from to . - edge properties: see https://www.graphviz.org/doc/info/attrs.html - """ - attrs = [f'{prop}="{value}"' for prop, value in props.items()] - n_from, n_to = normalize_node_id(name1), normalize_node_id(name2) - self.emit("{} -> {} [{}];".format(n_from, n_to, ", ".join(sorted(attrs)))) - - def emit_node(self, name, **props): - """emit a node with given properties. - node properties: see https://www.graphviz.org/doc/info/attrs.html - """ - attrs = [f'{prop}="{value}"' for prop, value in props.items()] - self.emit("{} [{}];".format(normalize_node_id(name), ", ".join(sorted(attrs)))) - - -def normalize_node_id(nid): - """Returns a suitable DOT node id for `nid`.""" - return '"%s"' % nid - - -def get_cycles(graph_dict, vertices=None): - """given a dictionary representing an ordered graph (i.e. key are vertices - and values is a list of destination vertices representing edges), return a - list of detected cycles - """ - if not graph_dict: - return () - result = [] - if vertices is None: - vertices = graph_dict.keys() - for vertice in vertices: - _get_cycles(graph_dict, [], set(), result, vertice) - return result - - -def _get_cycles(graph_dict, path, visited, result, vertice): - """recursive function doing the real work for get_cycles""" - if vertice in path: - cycle = [vertice] - for node in path[::-1]: - if node == vertice: - break - cycle.insert(0, node) - # make a canonical representation - start_from = min(cycle) - index = cycle.index(start_from) - cycle = cycle[index:] + cycle[0:index] - # append it to result if not already in - if cycle not in result: - result.append(cycle) - return - path.append(vertice) - try: - for node in graph_dict[vertice]: - # don't check already visited nodes again - if node not in visited: - _get_cycles(graph_dict, path, visited, result, node) - visited.add(node) - except KeyError: - pass - path.pop() diff --git a/pylint/pyreverse/diagrams.py b/pylint/pyreverse/diagrams.py index 4c391c7fa8..8613c4b3b2 100644 --- a/pylint/pyreverse/diagrams.py +++ b/pylint/pyreverse/diagrams.py @@ -43,6 +43,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""" @@ -111,7 +124,7 @@ def get_methods(self, node): def add_object(self, title, node): """create a diagram object""" assert node not in self._nodes - ent = DiagramEntity(title, node) + ent = ClassEntity(title, node) self._nodes[node] = ent self.objects.append(ent) @@ -227,6 +240,13 @@ def get_module(self, name, node): return mod raise KeyError(name) + def add_object(self, title, node): + """create a diagram object""" + assert node not in self._nodes + ent = PackageEntity(title, node) + self._nodes[node] = ent + self.objects.append(ent) + def add_from_depend(self, node, from_module): """add dependencies created by from-imports""" mod_name = node.root().name diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index a62a1c2e7f..9efb0eb6b4 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -10,6 +10,7 @@ # Copyright (c) 2020 Peter Kolbus # Copyright (c) 2020 hippo91 # Copyright (c) 2021 Mark Byrne +# 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/master/LICENSE @@ -24,10 +25,10 @@ import sys from pylint.config import ConfigurationMixIn -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.writer import get_writer_for_filetype OPTIONS = ( ( @@ -123,8 +124,7 @@ short="k", action="store_true", default=False, - help="don't show attributes and methods in the class boxes; \ -this disables -f values", + help="don't show attributes and methods in the class boxes; this disables -f values", ), ), ( @@ -138,37 +138,56 @@ help="create a *. output file if format available.", ), ), + ( + "colorized", + dict( + dest="colorized", + action="store_true", + default=False, + help="Use colored output. Classes/modules of the same package get the same color.", + ), + ), + ( + "max-color-depth", + dict( + dest="max_color_depth", + action="store", + default=2, + metavar="", + type="int", + help="Use separate colors up to package depth of ", + ), + ), ( "ignore", - { - "type": "csv", - "metavar": "", - "dest": "ignore_list", - "default": ("CVS",), - "help": "Files or directories to be skipped. They " - "should be base names, not paths.", - }, + dict( + type="csv", + metavar="", + dest="ignore_list", + default=("CVS",), + help="Files or directories to be skipped. They should be base names, not paths.", + ), ), ( "project", - { - "default": "", - "type": "string", - "short": "p", - "metavar": "", - "help": "set the project name.", - }, + dict( + default="", + type="string", + short="p", + metavar="", + help="set the project name.", + ), ), ( "output-directory", - { - "default": "", - "type": "string", - "short": "d", - "action": "store", - "metavar": "", - "help": "set the output directory path.", - }, + dict( + default="", + type="string", + short="d", + action="store", + metavar="", + help="set the output directory path.", + ), ), ) @@ -181,7 +200,7 @@ def _check_graphviz_available(output_format): print( "The output format '%s' is currently not available.\n" "Please install 'Graphviz' to have other output formats " - "than 'dot' or 'vcg'." % output_format + "than 'dot', 'vcg' or 'puml'." % output_format ) sys.exit(32) @@ -220,10 +239,8 @@ def run(self, args): finally: sys.path.pop(0) - if self.config.output_format == "vcg": - writer.VCGWriter(self.config).write(diadefs) - else: - writer.DotWriter(self.config).write(diadefs) + writer = get_writer_for_filetype(self.config.output_format) + writer(self.config).write(diadefs) return 0 diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py new file mode 100644 index 0000000000..6337fd1e21 --- /dev/null +++ b/pylint/pyreverse/printer.py @@ -0,0 +1,572 @@ +# 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/master/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, + Type, +) + +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, + colorized: Optional[bool] = False, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.title: str = title + self.colorized = colorized + 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 PlantUmlPrinter(Printer): + """Printer for PlantUML diagrams""" + + NODES: Dict[NodeType, str] = { + NodeType.CLASS: "class", + NodeType.INTERFACE: "class", + NodeType.PACKAGE: "package", + } + ARROWS: Dict[EdgeType, str] = { + EdgeType.INHERITS: "--|>", + EdgeType.IMPLEMENTS: "..|>", + EdgeType.ASSOCIATION: "--*", + EdgeType.USES: "-->", + } + + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit("@startuml " + self.title) + if not self.use_automatic_namespace: + self.emit("set namespaceSeparator none") + if self.layout: + if self.layout is Layout.LEFT_TO_RIGHT: + self.emit("left to right direction") + elif self.layout is Layout.TOP_TO_BOTTOM: + self.emit("top to bottom direction") + else: + raise ValueError( + f"Unsupported layout {self.layout}. PlantUmlPrinter only supports left to right and top to bottom 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) + stereotype = " << interface >>" if type_ is NodeType.INTERFACE else "" + nodetype = self.NODES[type_] + color = f" #{properties.color}" if properties.color else "" + body = properties.body if properties.body else "" + label = properties.label if properties.label is not None else name + if properties.fontcolor: + label = f"{label}" + self.emit(f'{nodetype} "{label}" as {name}{stereotype}{color} {{\n{body}\n}}') + + 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.""" + edge = f"{from_node} {self.ARROWS[type_]} {to_node}" + if label: + edge += f" : {label}" + self.emit(edge) + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self.emit("@enduml") + + +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, + colorized: Optional[bool] = False, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self._indent = "" + super().__init__(title, colorized, 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, + colorized: Optional[bool] = False, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.charset = "utf-8" + self.node_style = "filled" if colorized else "solid" + super().__init__(title, colorized, 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") + + +def get_printer_for_filetype(filename_or_extension: str) -> Type[Printer]: + """Factory function to get a suitable Printer class based on a files extension.""" + extension = get_file_extension(filename_or_extension) + printer_mapping = { + "dot": DotPrinter, + "vcg": VCGPrinter, + "puml": PlantUmlPrinter, + } + return printer_mapping.get(extension, DotPrinter) diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 1f4a65e5be..7e4e906560 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -273,3 +273,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 38193b3254..0000000000 --- a/pylint/pyreverse/vcgutils.py +++ /dev/null @@ -1,224 +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 - -# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -# For details: https://github.com/PyCQA/pylint/blob/master/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 baafc6904f..05b37962ca 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -16,19 +16,41 @@ """Utilities for creating VCG and Dot diagrams""" +import itertools import os - -from pylint.graph import DotBackend -from pylint.pyreverse.utils import get_annotation_label, is_exception -from pylint.pyreverse.vcgutils import VCGPrinter +from typing import Type + +import astroid +from astroid import modutils + +from pylint.pyreverse.diagrams import ( + ClassDiagram, + ClassEntity, + DiagramEntity, + PackageDiagram, + PackageEntity, +) +from pylint.pyreverse.printer import ( + DotPrinter, + EdgeType, + Layout, + NodeProperties, + NodeType, + PlantUmlPrinter, + VCGPrinter, +) +from pylint.pyreverse.utils import ( + get_annotation_label, + get_file_extension, + is_exception, +) 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): @@ -43,35 +65,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"): @@ -79,58 +114,106 @@ 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): +class ColorMixin: + """provide methods to apply colors to objects""" + + def __init__(self, depth): + self.depth = depth + self.available_colors = itertools.cycle( + [ + "aliceblue", + "antiquewhite", + "aquamarine", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cyan", + "darkgoldenrod", + "darkseagreen", + "dodgerblue", + "forestgreen", + "gold", + "hotpink", + "mediumspringgreen", + ] + ) + self.used_colors = {} + + def get_color(self, obj: DiagramEntity) -> str: + """get shape color""" + qualified_name = obj.node.qname() + if modutils.is_standard_module(qualified_name.split(".", maxsplit=1)[0]): + return "grey" + if isinstance(obj.node, astroid.ClassDef): + package = qualified_name.rsplit(".", maxsplit=2)[0] + elif obj.node.package: + package = qualified_name + else: + package = qualified_name.rsplit(".", maxsplit=1)[0] + base_name = ".".join(package.split(".", self.depth)[: self.depth]) + if base_name not in self.used_colors: + self.used_colors[base_name] = next(self.available_colors) + return self.used_colors[base_name] + + +class DotWriter(DiagramWriter, ColorMixin): """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): + DiagramWriter.__init__(self, config) + ColorMixin.__init__(self, self.config.max_color_depth) + + 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, colorized=self.config.colorized + ) 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=self.get_color(obj) if self.config.colorized else "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: @@ -139,12 +222,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: @@ -158,51 +243,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=self.get_color(obj) if self.config.colorized else "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 @@ -211,10 +282,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] @@ -228,9 +295,65 @@ 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 save(self) -> None: + """write to disk""" + self.printer.generate(self.file_name) + + +class PlantUmlWriter(DiagramWriter, ColorMixin): + """write PlantUML graphs from a diagram definition and a project""" + + def __init__(self, config): + self.file_name = None + DiagramWriter.__init__(self, config) + ColorMixin.__init__(self, self.config.max_color_depth) + + def set_printer(self, file_name: str, basename: str) -> None: + """set printer""" + self.file_name = file_name + self.printer = PlantUmlPrinter(basename) + + def get_title(self, obj: DiagramEntity) -> str: + """get project title""" + return obj.title + + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + return NodeProperties( + label=obj.title, + color=self.get_color(obj) if self.config.colorized else None, + ) + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: + """get label and shape for classes.""" + body = "" + if not self.config.only_classnames: + items = obj.attrs[:] + for func in obj.methods: + if func.args.args: + args = [arg.name for arg in func.args.args if arg.name != "self"] + else: + args = [] + items.append(f'{func.name}({", ".join(args)})') + body = "\n".join(items) + return NodeProperties( + label=obj.title, + body=body, + color=self.get_color(obj) if self.config.colorized else None, + ) + + def save(self) -> None: + """write to disk""" + self.printer.generate(self.file_name) + - def close_graph(self): - """close graph and file""" - self.printer.close_graph() - self.graph_file.close() +def get_writer_for_filetype(filename_or_extension: str) -> Type[DiagramWriter]: + extension = get_file_extension(filename_or_extension) + printer_mapping = { + "dot": DotWriter, + "vcg": VCGWriter, + "puml": PlantUmlWriter, + } + return printer_mapping.get(extension, DotWriter) diff --git a/pylint/testutils/pyreverse.py b/pylint/testutils/pyreverse.py new file mode 100644 index 0000000000..8fb5d38193 --- /dev/null +++ b/pylint/testutils/pyreverse.py @@ -0,0 +1,16 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/master/LICENSE + + +from typing import Optional + +from pylint.pyreverse.inspector import Project, project_from_files + + +def get_project(module: str, name: Optional[str] = "No Name") -> Project: + """return an astroid project representation""" + + def _astroid_wrapper(func, modname): + return func(modname) + + return project_from_files([module], _astroid_wrapper, project_name=name) diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot deleted file mode 100644 index 8867b4e416..0000000000 --- a/tests/data/classes_No_Name.dot +++ /dev/null @@ -1,12 +0,0 @@ -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"]; -} diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot deleted file mode 100644 index 1ceeb72427..0000000000 --- a/tests/data/packages_No_Name.dot +++ /dev/null @@ -1,8 +0,0 @@ -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"]; -} diff --git a/tests/pyreverse/conftest.py b/tests/pyreverse/conftest.py new file mode 100644 index 0000000000..80e3c513da --- /dev/null +++ b/tests/pyreverse/conftest.py @@ -0,0 +1,54 @@ +from typing import List + +import pytest +from attr import dataclass + + +@dataclass +class PyreverseConfig: # pylint: disable=too-many-instance-attributes + mode: str = "PUB_ONLY" + classes: List = [] + show_ancestors: int = None + all_ancestors: bool = None + show_associated: int = None + all_associated: bool = None + show_builtin: bool = False + module_names: bool = None + only_classnames: bool = False + output_format: str = "dot" + colorized: bool = False + max_color_depth: int = 2 + ignore_list: List = [] + project: str = "" + output_directory: str = "" + + +@pytest.fixture() +def default_config(): + return PyreverseConfig() + + +@pytest.fixture() +def colorized_dot_config(): + return PyreverseConfig( + colorized=True, + ) + + +@pytest.fixture() +def black_and_white_vcg_config(): + return PyreverseConfig( + output_format="vcg", + ) + + +@pytest.fixture() +def standard_puml_config(): + return PyreverseConfig( + output_format="puml", + ) + + +@pytest.fixture() +def colorized_puml_config(): + return PyreverseConfig(output_format="puml", colorized=True) diff --git a/tests/data/__init__.py b/tests/pyreverse/data/__init__.py similarity index 100% rename from tests/data/__init__.py rename to tests/pyreverse/data/__init__.py diff --git a/tests/pyreverse/data/classes_No_Name.dot b/tests/pyreverse/data/classes_No_Name.dot new file mode 100644 index 0000000000..d2c10d6a64 --- /dev/null +++ b/tests/pyreverse/data/classes_No_Name.dot @@ -0,0 +1,12 @@ +digraph "classes_No_Name" { +rankdir=BT +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/pyreverse/data/classes_colorized.dot b/tests/pyreverse/data/classes_colorized.dot new file mode 100644 index 0000000000..550d127c31 --- /dev/null +++ b/tests/pyreverse/data/classes_colorized.dot @@ -0,0 +1,12 @@ +digraph "classes_colorized" { +rankdir=BT +charset="utf-8" +"data.clientmodule_test.Ancestor" [color="aliceblue", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"data.suppliermodule_test.DoNothing" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; +"data.suppliermodule_test.Interface" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"data.clientmodule_test.Specialization" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; +"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/pyreverse/data/classes_plantuml.puml b/tests/pyreverse/data/classes_plantuml.puml new file mode 100644 index 0000000000..b3e77562d6 --- /dev/null +++ b/tests/pyreverse/data/classes_plantuml.puml @@ -0,0 +1,25 @@ +@startuml classes_plantuml +set namespaceSeparator none +class "Ancestor" as data.clientmodule_test.Ancestor { +attr : str +cls_member +get_value() +set_value(value) +} +class "DoNothing" as data.suppliermodule_test.DoNothing { + +} +class "Interface" as data.suppliermodule_test.Interface { +get_value() +set_value(value) +} +class "Specialization" as data.clientmodule_test.Specialization { +TYPE : str +relation +top : str +} +data.clientmodule_test.Specialization --|> data.clientmodule_test.Ancestor +data.clientmodule_test.Ancestor ..|> data.suppliermodule_test.Interface +data.suppliermodule_test.DoNothing --* data.clientmodule_test.Ancestor : cls_member +data.suppliermodule_test.DoNothing --* data.clientmodule_test.Specialization : relation +@enduml diff --git a/tests/pyreverse/data/classes_puml_colorized.puml b/tests/pyreverse/data/classes_puml_colorized.puml new file mode 100644 index 0000000000..2ce4eb3239 --- /dev/null +++ b/tests/pyreverse/data/classes_puml_colorized.puml @@ -0,0 +1,25 @@ +@startuml classes_puml_colorized +set namespaceSeparator none +class "Ancestor" as data.clientmodule_test.Ancestor #aliceblue { +attr : str +cls_member +get_value() +set_value(value) +} +class "DoNothing" as data.suppliermodule_test.DoNothing #aliceblue { + +} +class "Interface" as data.suppliermodule_test.Interface #aliceblue { +get_value() +set_value(value) +} +class "Specialization" as data.clientmodule_test.Specialization #aliceblue { +TYPE : str +relation +top : str +} +data.clientmodule_test.Specialization --|> data.clientmodule_test.Ancestor +data.clientmodule_test.Ancestor ..|> data.suppliermodule_test.Interface +data.suppliermodule_test.DoNothing --* data.clientmodule_test.Ancestor : cls_member +data.suppliermodule_test.DoNothing --* data.clientmodule_test.Specialization : relation +@enduml diff --git a/tests/pyreverse/data/classes_vcg.vcg b/tests/pyreverse/data/classes_vcg.vcg new file mode 100644 index 0000000000..d063a85ac4 --- /dev/null +++ b/tests/pyreverse/data/classes_vcg.vcg @@ -0,0 +1,38 @@ +graph:{ + title:"classes_vcg" + 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/clientmodule_test.py b/tests/pyreverse/data/clientmodule_test.py similarity index 63% rename from tests/data/clientmodule_test.py rename to tests/pyreverse/data/clientmodule_test.py index 40db2e77ef..5e7de79b38 100644 --- a/tests/data/clientmodule_test.py +++ b/tests/pyreverse/data/clientmodule_test.py @@ -1,27 +1,30 @@ """ docstring for file clientmodule.py """ -from data.suppliermodule_test import Interface, DoNothing +from data.suppliermodule_test import DoNothing, Interface + class Ancestor: - """ Ancestor method """ + """Ancestor method""" + __implements__ = (Interface,) cls_member = DoNothing() def __init__(self, value): local_variable = 0 - self.attr = 'this method shouldn\'t have a docstring' + self.attr = "this method shouldn't have a docstring" self.__value = value def get_value(self): - """ nice docstring ;-) """ + """nice docstring ;-)""" return self.__value def set_value(self, value): self.__value = value - return 'this method shouldn\'t have a docstring' + return "this method shouldn't have a docstring" + class Specialization(Ancestor): - TYPE = 'final class' - top = 'class' + TYPE = "final class" + top = "class" def __init__(self, value, _id): Ancestor.__init__(self, value) diff --git a/tests/pyreverse/data/packages_No_Name.dot b/tests/pyreverse/data/packages_No_Name.dot new file mode 100644 index 0000000000..7b145dc910 --- /dev/null +++ b/tests/pyreverse/data/packages_No_Name.dot @@ -0,0 +1,8 @@ +digraph "packages_No_Name" { +rankdir=BT +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/pyreverse/data/packages_colorized.dot b/tests/pyreverse/data/packages_colorized.dot new file mode 100644 index 0000000000..917fa86f5c --- /dev/null +++ b/tests/pyreverse/data/packages_colorized.dot @@ -0,0 +1,8 @@ +digraph "packages_colorized" { +rankdir=BT +charset="utf-8" +"data" [color="aliceblue", label="data", shape="box", style="filled"]; +"data.clientmodule_test" [color="aliceblue", label="data.clientmodule_test", shape="box", style="filled"]; +"data.suppliermodule_test" [color="aliceblue", label="data.suppliermodule_test", shape="box", style="filled"]; +"data.clientmodule_test" -> "data.suppliermodule_test" [arrowhead="open", arrowtail="none"]; +} diff --git a/tests/pyreverse/data/packages_plantuml.puml b/tests/pyreverse/data/packages_plantuml.puml new file mode 100644 index 0000000000..e6f950f7b0 --- /dev/null +++ b/tests/pyreverse/data/packages_plantuml.puml @@ -0,0 +1,13 @@ +@startuml packages_plantuml +set namespaceSeparator none +package "data" as data { + +} +package "data.clientmodule_test" as data.clientmodule_test { + +} +package "data.suppliermodule_test" as data.suppliermodule_test { + +} +data.clientmodule_test --> data.suppliermodule_test +@enduml diff --git a/tests/pyreverse/data/packages_puml_colorized.puml b/tests/pyreverse/data/packages_puml_colorized.puml new file mode 100644 index 0000000000..826db4ca68 --- /dev/null +++ b/tests/pyreverse/data/packages_puml_colorized.puml @@ -0,0 +1,13 @@ +@startuml packages_puml_colorized +set namespaceSeparator none +package "data" as data #aliceblue { + +} +package "data.clientmodule_test" as data.clientmodule_test #aliceblue { + +} +package "data.suppliermodule_test" as data.suppliermodule_test #aliceblue { + +} +data.clientmodule_test --> data.suppliermodule_test +@enduml diff --git a/tests/pyreverse/data/packages_vcg.vcg b/tests/pyreverse/data/packages_vcg.vcg new file mode 100644 index 0000000000..3d3b18acc5 --- /dev/null +++ b/tests/pyreverse/data/packages_vcg.vcg @@ -0,0 +1,20 @@ +graph:{ + title:"packages_vcg" + 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/data/suppliermodule_test.py b/tests/pyreverse/data/suppliermodule_test.py similarity index 86% rename from tests/data/suppliermodule_test.py rename to tests/pyreverse/data/suppliermodule_test.py index 24dc9a02fe..a75fa3ab8c 100644 --- a/tests/data/suppliermodule_test.py +++ b/tests/pyreverse/data/suppliermodule_test.py @@ -1,5 +1,6 @@ """ file suppliermodule.py """ + class Interface: def get_value(self): raise NotImplementedError @@ -7,4 +8,6 @@ def get_value(self): def set_value(self, value): raise NotImplementedError -class DoNothing: pass + +class DoNothing: + pass diff --git a/tests/unittest_pyreverse_diadefs.py b/tests/pyreverse/test_diadefs.py similarity index 92% rename from tests/unittest_pyreverse_diadefs.py rename to tests/pyreverse/test_diadefs.py index beb1f7566f..94ad5c3ff8 100644 --- a/tests/unittest_pyreverse_diadefs.py +++ b/tests/pyreverse/test_diadefs.py @@ -10,20 +10,20 @@ # Copyright (c) 2020 hippo91 # Copyright (c) 2020 Damien Baty # Copyright (c) 2020 Anthony Sottile +# Copyright (c) 2021 Andreas Finkler # Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> # Copyright (c) 2021 bot # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/PyCQA/pylint/blob/master/LICENSE -"""Unit test for the extensions.diadefslib modules""" +"""Unit test for the pyreverse.diadefslib modules""" # pylint: disable=redefined-outer-name import sys from pathlib import Path import astroid import pytest -from unittest_pyreverse_writer import Config, get_project from pylint.pyreverse.diadefslib import ( ClassDiadefGenerator, @@ -32,6 +32,7 @@ DiadefsHandler, ) from pylint.pyreverse.inspector import Linker +from pylint.testutils.pyreverse import get_project def _process_classes(classes): @@ -50,8 +51,8 @@ def _process_relations(relations): @pytest.fixture -def HANDLER(): - return DiadefsHandler(Config()) +def HANDLER(default_config): + return DiadefsHandler(default_config) @pytest.fixture(scope="module") @@ -59,10 +60,10 @@ def PROJECT(): return get_project("data") -def test_option_values(HANDLER, PROJECT): +def test_option_values(default_config, HANDLER, PROJECT): """test for ancestor, associated and module options""" df_h = DiaDefGenerator(Linker(PROJECT), HANDLER) - cl_config = Config() + cl_config = default_config cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) assert df_h._get_levels() == (0, 0) @@ -76,9 +77,9 @@ def test_option_values(HANDLER, PROJECT): hndl._set_default_options() assert hndl._get_levels() == (-1, -1) assert hndl.module_names - handler = DiadefsHandler(Config()) + handler = DiadefsHandler(default_config) df_h = DiaDefGenerator(Linker(PROJECT), handler) - cl_config = Config() + cl_config = default_config cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) for hndl in (df_h, cl_h): @@ -110,13 +111,13 @@ def test_exctract_relations(self, HANDLER, PROJECT): relations = _process_relations(cd.relationships) assert relations == self._should_rels - def test_functional_relation_extraction(self): + def test_functional_relation_extraction(self, default_config): """functional test of relations extraction; different classes possibly in different modules""" # XXX should be catching pyreverse environnement problem but doesn't # pyreverse doesn't extracts the relations but this test ok project = get_project("data") - handler = DiadefsHandler(Config()) + handler = DiadefsHandler(default_config) diadefs = handler.get_diadefs(project, Linker(project, tag=True)) cd = diadefs[1] relations = _process_relations(cd.relationships) diff --git a/tests/unittest_pyreverse_inspector.py b/tests/pyreverse/test_inspector.py similarity index 96% rename from tests/unittest_pyreverse_inspector.py rename to tests/pyreverse/test_inspector.py index e1ff6378ee..f1ca6d8aad 100644 --- a/tests/unittest_pyreverse_inspector.py +++ b/tests/pyreverse/test_inspector.py @@ -17,9 +17,9 @@ import astroid import pytest -from unittest_pyreverse_writer import get_project from pylint.pyreverse import inspector +from pylint.testutils.pyreverse import get_project @pytest.fixture @@ -120,7 +120,7 @@ class Concrete23(Concrete1): pass def test_from_directory(project): - expected = os.path.join("tests", "data", "__init__.py") + expected = os.path.join("tests", "pyreverse", "data", "__init__.py") assert project.name == "data" assert project.path.endswith(expected) diff --git a/tests/pyreverse/test_printer.py b/tests/pyreverse/test_printer.py new file mode 100644 index 0000000000..22218b8a85 --- /dev/null +++ b/tests/pyreverse/test_printer.py @@ -0,0 +1,67 @@ +# 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/master/LICENSE + +from typing import Type + +import pytest + +from pylint.pyreverse.printer import ( + DotPrinter, + Layout, + PlantUmlPrinter, + Printer, + VCGPrinter, + get_printer_for_filetype, +) + + +@pytest.mark.parametrize( + "filename, expected_printer_class", + [ + ("dot", DotPrinter), + ("png", DotPrinter), + ("vcg", VCGPrinter), + ("puml", PlantUmlPrinter), + ], +) +def test_get_printer_for_filetype( + filename: str, expected_printer_class: Type[Printer] +) -> None: + returned_printer_class = get_printer_for_filetype(filename) + assert returned_printer_class == expected_printer_class + + +@pytest.mark.parametrize( + "layout, printer_class, expected_content, line_index", + [ + (Layout.TOP_TO_BOTTOM, DotPrinter, "rankdir=TB", -2), + (Layout.BOTTOM_TO_TOP, DotPrinter, "rankdir=BT", -2), + (Layout.LEFT_TO_RIGHT, DotPrinter, "rankdir=LR", -2), + (Layout.RIGHT_TO_LEFT, DotPrinter, "rankdir=RL", -2), + (Layout.TOP_TO_BOTTOM, VCGPrinter, "orientation:top_to_bottom", -1), + (Layout.BOTTOM_TO_TOP, VCGPrinter, "orientation:bottom_to_top", -1), + (Layout.LEFT_TO_RIGHT, VCGPrinter, "orientation:left_to_right", -1), + (Layout.RIGHT_TO_LEFT, VCGPrinter, "orientation:right_to_left", -1), + (Layout.TOP_TO_BOTTOM, PlantUmlPrinter, "top to bottom direction", -1), + (Layout.LEFT_TO_RIGHT, PlantUmlPrinter, "left to right direction", -1), + ], +) +def test_explicit_layout( + layout: Layout, printer_class: Type[Printer], expected_content: str, line_index: int +) -> None: + printer = printer_class(title="unittest", layout=layout) + assert printer.lines[line_index].strip() == expected_content + + +@pytest.mark.parametrize( + "layout, printer_class", + [ + (Layout.BOTTOM_TO_TOP, PlantUmlPrinter), + (Layout.RIGHT_TO_LEFT, PlantUmlPrinter), + ], +) +def test_unsupported_layouts(layout, printer_class): + with pytest.raises(ValueError): + printer_class(title="unittest", layout=layout) diff --git a/tests/pyreverse/test_utils.py b/tests/pyreverse/test_utils.py new file mode 100644 index 0000000000..1e77b7a796 --- /dev/null +++ b/tests/pyreverse/test_utils.py @@ -0,0 +1,118 @@ +# Copyright (c) 2021 Mark Byrne +# 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/master/LICENSE + +from unittest.mock import patch + +import astroid +import pytest + +from pylint.pyreverse.utils import ( + get_annotation, + get_file_extension, + get_visibility, + infer_node, +) + + +@pytest.mark.parametrize( + "names, expected", + [ + (["__reduce_ex__", "__setattr__"], "special"), + (["__g_", "____dsf", "__23_9"], "private"), + (["simple"], "public"), + ( + ["_", "__", "___", "____", "_____", "___e__", "_nextsimple", "_filter_it_"], + "protected", + ), + ], +) +def test_get_visibility(names, expected): + for name in names: + got = get_visibility(name) + assert got == expected, f"got {got} instead of {expected} for value {name}" + + +@pytest.mark.parametrize( + "assign, label", + [ + ("a: str = None", "Optional[str]"), + ("a: str = 'mystr'", "str"), + ("a: Optional[str] = 'str'", "Optional[str]"), + ("a: Optional[str] = None", "Optional[str]"), + ], +) +def test_get_annotation_annassign(assign, label): + """AnnAssign""" + node = astroid.extract_node(assign) + got = get_annotation(node.value).name + assert isinstance(node, astroid.AnnAssign) + assert got == label, f"got {got} instead of {label} for value {node}" + + +@pytest.mark.parametrize( + "init_method, label", + [ + ("def __init__(self, x: str): self.x = x", "str"), + ("def __init__(self, x: str = 'str'): self.x = x", "str"), + ("def __init__(self, x: str = None): self.x = x", "Optional[str]"), + ("def __init__(self, x: Optional[str]): self.x = x", "Optional[str]"), + ("def __init__(self, x: Optional[str] = None): self.x = x", "Optional[str]"), + ("def __init__(self, x: Optional[str] = 'str'): self.x = x", "Optional[str]"), + ], +) +def test_get_annotation_assignattr(init_method, label): + """AssignAttr""" + assign = rf""" + class A: + {init_method} + """ + node = astroid.extract_node(assign) + instance_attrs = node.instance_attrs + for _, assign_attrs in instance_attrs.items(): + for assign_attr in assign_attrs: + got = get_annotation(assign_attr).name + assert isinstance(assign_attr, astroid.AssignAttr) + assert got == label, f"got {got} instead of {label} for value {node}" + + +@patch("pylint.pyreverse.utils.get_annotation") +@patch("astroid.node_classes.NodeNG.infer", side_effect=astroid.InferenceError) +def test_infer_node_1(mock_infer, mock_get_annotation): + """Return set() when astroid.InferenceError is raised and an annotation has + not been returned + """ + mock_get_annotation.return_value = None + node = astroid.extract_node("a: str = 'mystr'") + mock_infer.return_value = "x" + assert infer_node(node) == set() + assert mock_infer.called + + +@patch("pylint.pyreverse.utils.get_annotation") +@patch("astroid.node_classes.NodeNG.infer") +def test_infer_node_2(mock_infer, mock_get_annotation): + """Return set(node.infer()) when InferenceError is not raised and an + annotation has not been returned + """ + mock_get_annotation.return_value = None + node = astroid.extract_node("a: str = 'mystr'") + mock_infer.return_value = "x" + assert infer_node(node) == set("x") + assert mock_infer.called + + +@pytest.mark.parametrize( + "input_string, expected_result", + [ + ("test.png", "png"), + ("/this/is/a/test.vcg", "vcg"), + ("dot", "dot"), + ("test.puml", "puml"), + ], +) +def test_get_file_extension(input_string, expected_result): + actual_result = get_file_extension(input_string) + assert actual_result == expected_result diff --git a/tests/pyreverse/test_writer.py b/tests/pyreverse/test_writer.py new file mode 100644 index 0000000000..f859f5f0b9 --- /dev/null +++ b/tests/pyreverse/test_writer.py @@ -0,0 +1,143 @@ +# Copyright (c) 2008, 2010, 2013 LOGILAB S.A. (Paris, FRANCE) +# Copyright (c) 2014-2018, 2020 Claudiu Popa +# Copyright (c) 2014 Google, Inc. +# Copyright (c) 2014 Arun Persaud +# Copyright (c) 2015 Ionel Cristian Maries +# Copyright (c) 2016 Derek Gustafson +# Copyright (c) 2018 Ville Skyttä +# Copyright (c) 2019-2021 Pierre Sassoulas +# Copyright (c) 2019 Ashley Whetter +# Copyright (c) 2020 hippo91 +# Copyright (c) 2020 Anthony Sottile +# Copyright (c) 2021 Mark Byrne +# 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/master/LICENSE + +""" +unit test for pyreverse writer classes +""" + + +import codecs +import os +from difflib import unified_diff + +import pytest + +from pylint.pyreverse.diadefslib import DefaultDiadefGenerator, DiadefsHandler +from pylint.pyreverse.inspector import Linker +from pylint.pyreverse.writer import ( + DotWriter, + PlantUmlWriter, + VCGWriter, + get_writer_for_filetype, +) +from pylint.testutils.pyreverse import get_project + + +def _file_lines(path): + # we don't care about the actual encoding, but python3 forces us to pick one + with codecs.open(path, encoding="latin1") as stream: + lines = [ + line.strip() + for line in stream.readlines() + if ( + line.find("squeleton generated by ") == -1 + and not line.startswith('__revision__ = "$Id:') + ) + ] + return [line for line in lines if line] + + +DOT_FILES = ["packages_No_Name.dot", "classes_No_Name.dot"] +COLORIZED_DOT_FILES = ["packages_colorized.dot", "classes_colorized.dot"] +VCG_FILES = ["packages_vcg.vcg", "classes_vcg.vcg"] +PUML_FILES = ["packages_plantuml.puml", "classes_plantuml.puml"] +COLORIZED_PUML_FILES = ["packages_puml_colorized.puml", "classes_puml_colorized.puml"] + + +def _create_files(config, name="No Name"): + project = get_project(os.path.join(os.path.dirname(__file__), "data"), name) + linker = Linker(project) + handler = DiadefsHandler(config) + dd = DefaultDiadefGenerator(linker, handler).visit(project) + for diagram in dd: + diagram.extract_relationships() + if config.output_format == "vcg": + writer = VCGWriter(config) + elif config.output_format == "puml": + writer = PlantUmlWriter(config) + else: + writer = DotWriter(config) + writer.write(dd) + + +@pytest.fixture(scope="module", autouse=True) +def cleanup(): + yield + for fname in ( + DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES + PUML_FILES + COLORIZED_PUML_FILES + ): + try: + os.remove(fname) + except FileNotFoundError: + continue + + +@pytest.mark.parametrize("generated_file", DOT_FILES) +def test_black_and_white_dot_output(default_config, generated_file): + _create_files(default_config, "No Name") + _check_file(generated_file) + + +@pytest.mark.parametrize("generated_file", COLORIZED_DOT_FILES) +def test_colorized_dot_output(colorized_dot_config, generated_file): + _create_files(colorized_dot_config, "colorized") + _check_file(generated_file) + + +@pytest.mark.parametrize("generated_file", VCG_FILES) +def test_black_and_white_vcg_output(black_and_white_vcg_config, generated_file): + _create_files(black_and_white_vcg_config, "vcg") + _check_file(generated_file) + + +@pytest.mark.parametrize("generated_file", PUML_FILES) +def test_standard_puml_output(standard_puml_config, generated_file): + _create_files(standard_puml_config, "plantuml") + _check_file(generated_file) + + +@pytest.mark.parametrize("generated_file", COLORIZED_PUML_FILES) +def test_colorized_puml_output(colorized_puml_config, generated_file): + _create_files(colorized_puml_config, "puml_colorized") + _check_file(generated_file) + + +def _check_file(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}" + + +@pytest.mark.parametrize( + "filename, expected_writer_class", + [ + ("dot", DotWriter), + ("png", DotWriter), + ("vcg", VCGWriter), + ("puml", PlantUmlWriter), + ], +) +def test_get_writer_for_filetype(filename, expected_writer_class): + returned_writer_class = get_writer_for_filetype(filename) + assert returned_writer_class == expected_writer_class diff --git a/tests/test_import_graph.py b/tests/test_import_graph.py index 7cbaea2e83..4d7130f8ee 100644 --- a/tests/test_import_graph.py +++ b/tests/test_import_graph.py @@ -19,6 +19,7 @@ import os import shutil +from itertools import product from os.path import exists import pytest @@ -53,13 +54,12 @@ def test_dependencies_graph(dest): digraph "foo" { rankdir=LR charset="utf-8" -URL="." node[shape="box"] -"hoho" []; -"yep" []; -"labas" []; -"yep" -> "hoho" []; -"hoho" -> "labas" []; -"yep" -> "labas" []; +"hoho" [color="black", label="hoho", shape="box", style="solid"]; +"yep" [color="black", label="yep", shape="box", style="solid"]; +"labas" [color="black", label="labas", shape="box", style="solid"]; +"yep" -> "hoho" [arrowhead="open", arrowtail="none"]; +"hoho" -> "labas" [arrowhead="open", arrowtail="none"]; +"yep" -> "labas" [arrowhead="open", arrowtail="none"]; } """.strip() ) @@ -71,7 +71,7 @@ def test_dependencies_graph(dest): ) def test_missing_graphviz(filename): """Raises if graphviz is not installed, and defaults to png if no extension given""" - with pytest.raises(RuntimeError, match=r"Cannot generate `graph\.png`.*"): + with pytest.raises(RuntimeError, match=fr"Cannot generate `{filename}`.*"): imports._dependencies_graph(filename, {"a": ["b", "c"], "b": ["c"]}) @@ -85,26 +85,29 @@ def linter(): @pytest.fixture def remove_files(): yield - for fname in ("import.dot", "ext_import.dot", "int_import.dot"): + names = ("import", "ext_import", "int_import") + extensions = ("dot", "vcg", "puml") + for name, extension in product(names, extensions): try: - os.remove(fname) + os.remove(f"{name}.{extension}") except FileNotFoundError: pass @pytest.mark.usefixtures("remove_files") -def test_checker_dep_graphs(linter): +@pytest.mark.parametrize("extension", ["dot", "vcg", "puml"]) +def test_checker_dep_graphs(linter, extension): linter.global_set_option("persistent", False) linter.global_set_option("reports", True) linter.global_set_option("enable", "imports") - linter.global_set_option("import-graph", "import.dot") - linter.global_set_option("ext-import-graph", "ext_import.dot") - linter.global_set_option("int-import-graph", "int_import.dot") - linter.global_set_option("int-import-graph", "int_import.dot") + linter.global_set_option("import-graph", "import." + extension) + linter.global_set_option("ext-import-graph", "ext_import." + extension) + linter.global_set_option("int-import-graph", "int_import." + extension) + linter.global_set_option("int-import-graph", "int_import." + extension) # ignore this file causing spurious MemoryError w/ some python version (>=2.3?) linter.global_set_option("ignore", ("func_unknown_encoding.py",)) linter.check("input") linter.generate_reports() - assert exists("import.dot") - assert exists("ext_import.dot") - assert exists("int_import.dot") + assert exists("import." + extension) + assert exists("ext_import." + extension) + assert exists("int_import." + extension) diff --git a/tests/test_pylint_runners.py b/tests/test_pylint_runners.py index 1f3739eed3..7d636dfa01 100644 --- a/tests/test_pylint_runners.py +++ b/tests/test_pylint_runners.py @@ -8,6 +8,17 @@ from pylint import run_epylint, run_pylint, run_pyreverse, run_symilar +@pytest.fixture(scope="module") +def cleanup(): + yield + for fname in ("classes.dot",): + try: + os.remove(fname) + except FileNotFoundError: + continue + + +@pytest.mark.usefixtures("cleanup") @pytest.mark.parametrize( "runner", [run_pylint, run_epylint, run_pyreverse, run_symilar] ) diff --git a/tests/test_self.py b/tests/test_self.py index b47e174533..6ebd946ec6 100644 --- a/tests/test_self.py +++ b/tests/test_self.py @@ -319,13 +319,13 @@ def test_abbreviations_are_not_supported(self): self._test_output([".", "--load-plugin"], expected_output=expected) def test_enable_all_works(self): - module = join(HERE, "data", "clientmodule_test.py") + module = join(HERE, "pyreverse", "data", "clientmodule_test.py") expected = textwrap.dedent( f""" ************* Module data.clientmodule_test - {module}:10:8: W0612: Unused variable 'local_variable' (unused-variable) - {module}:18:4: C0116: Missing function or method docstring (missing-function-docstring) - {module}:22:0: C0115: Missing class docstring (missing-class-docstring) + {module}:12:8: W0612: Unused variable 'local_variable' (unused-variable) + {module}:20:4: C0116: Missing function or method docstring (missing-function-docstring) + {module}:25:0: C0115: Missing class docstring (missing-class-docstring) """ ) self._test_output( diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py deleted file mode 100644 index 95b1b20e05..0000000000 --- a/tests/unittest_pyreverse_writer.py +++ /dev/null @@ -1,205 +0,0 @@ -# Copyright (c) 2008, 2010, 2013 LOGILAB S.A. (Paris, FRANCE) -# Copyright (c) 2014-2018, 2020 Claudiu Popa -# Copyright (c) 2014 Google, Inc. -# Copyright (c) 2014 Arun Persaud -# Copyright (c) 2015 Ionel Cristian Maries -# Copyright (c) 2016 Derek Gustafson -# Copyright (c) 2018 Ville Skyttä -# Copyright (c) 2019-2021 Pierre Sassoulas -# Copyright (c) 2019 Ashley Whetter -# Copyright (c) 2020 hippo91 -# Copyright (c) 2020 Anthony Sottile -# Copyright (c) 2021 Mark Byrne - -# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -# For details: https://github.com/PyCQA/pylint/blob/master/LICENSE - -""" -unit test for visitors.diadefs and extensions.diadefslib modules -""" - - -import codecs -import os -from difflib import unified_diff -from unittest.mock import patch - -import astroid -import pytest - -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 - -_DEFAULTS = { - "all_ancestors": None, - "show_associated": None, - "module_names": None, - "output_format": "dot", - "diadefs_file": None, - "quiet": 0, - "show_ancestors": None, - "classes": (), - "all_associated": None, - "mode": "PUB_ONLY", - "show_builtin": False, - "only_classnames": False, - "output_directory": "", -} - - -class Config: - """config object for tests""" - - def __init__(self): - for attr, value in _DEFAULTS.items(): - setattr(self, attr, value) - - -def _file_lines(path): - # we don't care about the actual encoding, but python3 forces us to pick one - with codecs.open(path, encoding="latin1") as stream: - lines = [ - line.strip() - for line in stream.readlines() - if ( - line.find("squeleton generated by ") == -1 - and not line.startswith('__revision__ = "$Id:') - ) - ] - return [line for line in lines if line] - - -def get_project(module, name="No Name"): - """return an astroid project representation""" - - def _astroid_wrapper(func, modname): - return func(modname) - - return project_from_files([module], _astroid_wrapper, project_name=name) - - -DOT_FILES = ["packages_No_Name.dot", "classes_No_Name.dot"] - - -@pytest.fixture(scope="module") -def setup(): - project = get_project(os.path.join(os.path.dirname(__file__), "data")) - linker = Linker(project) - CONFIG = 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: - try: - os.remove(fname) - except FileNotFoundError: - continue - - -@pytest.mark.usefixtures("setup") -@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) - 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", - [ - (["__reduce_ex__", "__setattr__"], "special"), - (["__g_", "____dsf", "__23_9"], "private"), - (["simple"], "public"), - ( - ["_", "__", "___", "____", "_____", "___e__", "_nextsimple", "_filter_it_"], - "protected", - ), - ], -) -def test_get_visibility(names, expected): - for name in names: - got = get_visibility(name) - assert got == expected, f"got {got} instead of {expected} for value {name}" - - -@pytest.mark.parametrize( - "assign, label", - [ - ("a: str = None", "Optional[str]"), - ("a: str = 'mystr'", "str"), - ("a: Optional[str] = 'str'", "Optional[str]"), - ("a: Optional[str] = None", "Optional[str]"), - ], -) -def test_get_annotation_annassign(assign, label): - """AnnAssign""" - node = astroid.extract_node(assign) - got = get_annotation(node.value).name - assert isinstance(node, astroid.AnnAssign) - assert got == label, f"got {got} instead of {label} for value {node}" - - -@pytest.mark.parametrize( - "init_method, label", - [ - ("def __init__(self, x: str): self.x = x", "str"), - ("def __init__(self, x: str = 'str'): self.x = x", "str"), - ("def __init__(self, x: str = None): self.x = x", "Optional[str]"), - ("def __init__(self, x: Optional[str]): self.x = x", "Optional[str]"), - ("def __init__(self, x: Optional[str] = None): self.x = x", "Optional[str]"), - ("def __init__(self, x: Optional[str] = 'str'): self.x = x", "Optional[str]"), - ], -) -def test_get_annotation_assignattr(init_method, label): - """AssignAttr""" - assign = rf""" - class A: - {init_method} - """ - node = astroid.extract_node(assign) - instance_attrs = node.instance_attrs - for _, assign_attrs in instance_attrs.items(): - for assign_attr in assign_attrs: - got = get_annotation(assign_attr).name - assert isinstance(assign_attr, astroid.AssignAttr) - assert got == label, f"got {got} instead of {label} for value {node}" - - -@patch("pylint.pyreverse.utils.get_annotation") -@patch("astroid.node_classes.NodeNG.infer", side_effect=astroid.InferenceError) -def test_infer_node_1(mock_infer, mock_get_annotation): - """Return set() when astroid.InferenceError is raised and an annotation has - not been returned - """ - mock_get_annotation.return_value = None - node = astroid.extract_node("a: str = 'mystr'") - mock_infer.return_value = "x" - assert infer_node(node) == set() - assert mock_infer.called - - -@patch("pylint.pyreverse.utils.get_annotation") -@patch("astroid.node_classes.NodeNG.infer") -def test_infer_node_2(mock_infer, mock_get_annotation): - """Return set(node.infer()) when InferenceError is not raised and an - annotation has not been returned - """ - mock_get_annotation.return_value = None - node = astroid.extract_node("a: str = 'mystr'") - mock_infer.return_value = "x" - assert infer_node(node) == set("x") - assert mock_infer.called