From 53e99b450dc7560b0a9983b9b9b52e60b2c1cdf9 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Mon, 24 May 2021 11:17:48 +0200 Subject: [PATCH 01/32] WIP: automatically color package diagrams in pyreverse --- pylint/pyreverse/writer.py | 46 ++++++++++++++++++++++++++++++++- tests/data/packages_No_Name.dot | 6 ++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 0d80bb9ca6..ea5ac02db9 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -16,6 +16,7 @@ """Utilities for creating VCG and Dot diagrams""" +import itertools import os from pylint.graph import DotBackend @@ -49,7 +50,13 @@ def write_packages(self, diagram): """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") + self.printer.emit_node( + i, + label=self.get_title(obj), + shape="box", + color=self.get_color(obj), + style="filled", + ) obj.fig_id = i # package dependencies for rel in diagram.get_relationships("depends"): @@ -90,6 +97,10 @@ def get_title(self, obj): """get project title""" raise NotImplementedError + def get_color(self, obj): + """get shape color""" + raise NotImplementedError + def get_values(self, obj): """get label and shape for classes.""" raise NotImplementedError @@ -111,6 +122,28 @@ def __init__(self, config): fontcolor="green", arrowtail="none", arrowhead="diamond", style="solid" ), ] + 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 = {} DiagramWriter.__init__(self, config, styles) def set_printer(self, file_name, basename): @@ -123,6 +156,13 @@ def get_title(self, obj): """get project title""" return obj.title + def get_color(self, obj): + """get shape color""" + base_name = ".".join(obj.title.split(".", 2)[:2]) + if base_name not in self.used_colors: + self.used_colors[base_name] = next(self.available_colors) + return self.used_colors[base_name] + def get_values(self, obj): """get label and shape for classes. @@ -184,6 +224,10 @@ def get_title(self, obj): """get project title in vcg format""" return r"\fb%s\fn" % obj.title + def get_color(self, obj): + """get color for object""" + return None + def get_values(self, obj): """get label and shape for classes. diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot index 1ceeb72427..e2aa60bfd7 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"]; +"0" [color="aliceblue", label="data", shape="box", style="filled"]; +"1" [color="antiquewhite", label="data.clientmodule_test", shape="box", style="filled"]; +"2" [color="aquamarine", label="data.suppliermodule_test", shape="box", style="filled"]; "1" -> "2" [arrowhead="open", arrowtail="none"]; } From 832202b4f10b1b2d93ff261d9958b805ef583c59 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Mon, 24 May 2021 18:41:35 +0200 Subject: [PATCH 02/32] Add command line options ``--colorized`` and ``-max-color-depth``. Distinguish modules and packages. --- pylint/pyreverse/main.py | 20 ++++++++++++++++++++ pylint/pyreverse/writer.py | 25 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index a62a1c2e7f..9ada811bd6 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -138,6 +138,26 @@ 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", { diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index ea5ac02db9..61e4efa437 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -55,7 +55,7 @@ def write_packages(self, diagram): label=self.get_title(obj), shape="box", color=self.get_color(obj), - style="filled", + style=self.get_style(), ) obj.fig_id = i # package dependencies @@ -97,6 +97,10 @@ def get_title(self, obj): """get project title""" raise NotImplementedError + def get_style(self): + """get style""" + raise NotImplementedError + def get_color(self, obj): """get shape color""" raise NotImplementedError @@ -156,9 +160,22 @@ def get_title(self, obj): """get project title""" return obj.title + def get_style(self): + """get style of object""" + if not self.config.colorized: + return "solid" + return "filled" + def get_color(self, obj): """get shape color""" - base_name = ".".join(obj.title.split(".", 2)[:2]) + if not self.config.colorized: + return "black" + depth = self.config.max_color_depth + if obj.node.package: + package = obj.title + else: + package = obj.title.rsplit(".", maxsplit=1)[0] + base_name = ".".join(package.split(".", depth)[:depth]) if base_name not in self.used_colors: self.used_colors[base_name] = next(self.available_colors) return self.used_colors[base_name] @@ -224,6 +241,10 @@ def get_title(self, obj): """get project title in vcg format""" return r"\fb%s\fn" % obj.title + def get_style(self): + """get style of object""" + return None + def get_color(self, obj): """get color for object""" return None From 7010985da7ab61220dddfe2c07a48becabeb175f Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 25 May 2021 18:21:09 +0200 Subject: [PATCH 03/32] Dummy implementation of new methods for VCG writer; ignore style attribute in VCGPrinter --- pylint/pyreverse/vcgutils.py | 7 +++++++ pylint/pyreverse/writer.py | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py index 38193b3254..8f6810998a 100644 --- a/pylint/pyreverse/vcgutils.py +++ b/pylint/pyreverse/vcgutils.py @@ -81,6 +81,11 @@ "linestyles": ("continuous", "dashed", "dotted", "invisible"), } +# attributes that may be passed into the function but are not relevant vor the VCGPrinter (e.g. attributes only relevant for DOT files) +IGNORED_ATTRS = { + "style", +} + # meaning of possible values: # O -> string # 1 -> int @@ -194,6 +199,8 @@ def edge(self, from_node, to_node, edge_type="", **args): def _write_attributes(self, attributes_dict, **args): """write graph, node or edge attributes""" for key, value in args.items(): + if key in IGNORED_ATTRS: + continue try: _type = attributes_dict[key] except KeyError as e: diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 61e4efa437..e04f1880cb 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -174,7 +174,7 @@ def get_color(self, obj): if obj.node.package: package = obj.title else: - package = obj.title.rsplit(".", maxsplit=1)[0] + package, _ = obj.title.rsplit(".", maxsplit=1) base_name = ".".join(package.split(".", depth)[:depth]) if base_name not in self.used_colors: self.used_colors[base_name] = next(self.available_colors) @@ -247,7 +247,7 @@ def get_style(self): def get_color(self, obj): """get color for object""" - return None + return "black" def get_values(self, obj): """get label and shape for classes. From 72e2d01a5ab573c000d23ca83b3baf8ccb7e75bb Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 25 May 2021 18:23:04 +0200 Subject: [PATCH 04/32] Add simple unit test for colored output --- tests/data/packages_No_Name.dot | 6 +-- tests/unittest_pyreverse_writer.py | 76 +++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot index e2aa60bfd7..64d5725f58 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" [color="aliceblue", label="data", shape="box", style="filled"]; -"1" [color="antiquewhite", label="data.clientmodule_test", shape="box", style="filled"]; -"2" [color="aquamarine", label="data.suppliermodule_test", shape="box", style="filled"]; +"0" [color="black", label="data", shape="box", style="solid"]; +"1" [color="black", label="data.clientmodule_test", shape="box", style="solid"]; +"2" [color="black", label="data.suppliermodule_test", shape="box", style="solid"]; "1" -> "2" [arrowhead="open", arrowtail="none"]; } diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index 98bb014881..abb99cb4e1 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -44,14 +44,34 @@ "show_builtin": False, "only_classnames": False, "output_directory": "", + "colorized": False, + "max_color_depth": 2, +} + +_COLORIZED = { + "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": "", + "colorized": True, + "max_color_depth": 2, } class Config: """config object for tests""" - def __init__(self): - for attr, value in _DEFAULTS.items(): + def __init__(self, config): + for attr, value in config.items(): setattr(self, attr, value) @@ -79,41 +99,51 @@ def _astroid_wrapper(func, modname): DOT_FILES = ["packages_No_Name.dot", "classes_No_Name.dot"] +COLORIZED_DOT_FILES = ["packages_colorized.dot", "classes_colorized.dot"] -@pytest.fixture(scope="module") -def setup(): - project = get_project(os.path.join(os.path.dirname(__file__), "data")) +def _create_files(config, name="No Name"): + project = get_project(os.path.join(os.path.dirname(__file__), "data"), name) 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 = DotWriter(config) writer.write(dd) + + +@pytest.fixture(scope="module") +def cleanup(): yield - for fname in DOT_FILES: + for fname in DOT_FILES + COLORIZED_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.usefixtures("cleanup") +@pytest.mark.parametrize( + "config, name, generated_files", + [ + (Config(_DEFAULTS), "No Name", DOT_FILES), + (Config(_COLORIZED), "colorized", COLORIZED_DOT_FILES), + ], +) +def test_dot_files(config, name, generated_files): + _create_files(config, name) + for generated_file in generated_files: + 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( From 71d6d2974947ab383ba49a31b36fd2dfbaeb33cf Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 25 May 2021 18:23:51 +0200 Subject: [PATCH 05/32] Add test files for colorized output --- tests/data/classes_colorized.dot | 12 ++++++++++++ tests/data/packages_colorized.dot | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/data/classes_colorized.dot create mode 100644 tests/data/packages_colorized.dot diff --git a/tests/data/classes_colorized.dot b/tests/data/classes_colorized.dot new file mode 100644 index 0000000000..2509f1132c --- /dev/null +++ b/tests/data/classes_colorized.dot @@ -0,0 +1,12 @@ +digraph "classes_colorized" { +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_colorized.dot b/tests/data/packages_colorized.dot new file mode 100644 index 0000000000..82fcb2a582 --- /dev/null +++ b/tests/data/packages_colorized.dot @@ -0,0 +1,8 @@ +digraph "packages_colorized" { +charset="utf-8" +rankdir=BT +"0" [color="aliceblue", label="data", shape="box", style="filled"]; +"1" [color="aliceblue", label="data.clientmodule_test", shape="box", style="filled"]; +"2" [color="aliceblue", label="data.suppliermodule_test", shape="box", style="filled"]; +"1" -> "2" [arrowhead="open", arrowtail="none"]; +} From bc0febf5fdbc65718ee90df7460d600d1c496c07 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Wed, 26 May 2021 19:42:46 +0200 Subject: [PATCH 06/32] Apply consistent definition of dictionaries --- pylint/pyreverse/main.py | 49 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index 9ada811bd6..04b3b93695 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 @@ -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", ), ), ( @@ -160,35 +160,34 @@ ), ( "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.", + ), ), ) From 128ea443e938b20b7e77118ed549e5f92659e401 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Wed, 26 May 2021 19:43:42 +0200 Subject: [PATCH 07/32] Fix unittest that also relied on the Config class of another test module --- tests/unittest_pyreverse_diadefs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unittest_pyreverse_diadefs.py b/tests/unittest_pyreverse_diadefs.py index e90db22512..047060af38 100644 --- a/tests/unittest_pyreverse_diadefs.py +++ b/tests/unittest_pyreverse_diadefs.py @@ -21,7 +21,7 @@ import astroid import pytest -from unittest_pyreverse_writer import Config, get_project +from unittest_pyreverse_writer import _DEFAULTS, Config, get_project from pylint.pyreverse.diadefslib import ( ClassDiadefGenerator, @@ -49,7 +49,7 @@ def _process_relations(relations): @pytest.fixture def HANDLER(): - return DiadefsHandler(Config()) + return DiadefsHandler(Config(_DEFAULTS)) @pytest.fixture(scope="module") @@ -60,7 +60,7 @@ def PROJECT(): def test_option_values(HANDLER, PROJECT): """test for ancestor, associated and module options""" df_h = DiaDefGenerator(Linker(PROJECT), HANDLER) - cl_config = Config() + cl_config = Config(_DEFAULTS) cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) assert df_h._get_levels() == (0, 0) @@ -74,9 +74,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(Config(_DEFAULTS)) df_h = DiaDefGenerator(Linker(PROJECT), handler) - cl_config = Config() + cl_config = Config(_DEFAULTS) cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) for hndl in [df_h, cl_h]: @@ -114,7 +114,7 @@ def test_functional_relation_extraction(self): # 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(Config(_DEFAULTS)) diadefs = handler.get_diadefs(project, Linker(project, tag=True)) cd = diadefs[1] relations = _process_relations(cd.relationships) From d2763be8650b2413c429697c8f40a51f616b139e Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Wed, 26 May 2021 19:44:12 +0200 Subject: [PATCH 08/32] Enable coloring of class diagrams without relying on the module-names=y option --- pylint/pyreverse/writer.py | 25 +++++++++++++++++++------ tests/data/classes_No_Name.dot | 8 ++++---- tests/data/classes_colorized.dot | 8 ++++---- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index e04f1880cb..48b845dc4e 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -19,6 +19,9 @@ import itertools import os +import astroid +from astroid import modutils + from pylint.graph import DotBackend from pylint.pyreverse.utils import is_exception from pylint.pyreverse.vcgutils import VCGPrinter @@ -170,11 +173,16 @@ def get_color(self, obj): """get shape color""" if not self.config.colorized: return "black" + qualified_name = obj.node.qname() + if modutils.is_standard_module(qualified_name.split(".", maxsplit=1)[0]): + return "grey" depth = self.config.max_color_depth - if obj.node.package: - package = obj.title + if isinstance(obj.node, astroid.ClassDef): + package = qualified_name.rsplit(".", maxsplit=2)[0] + elif obj.node.package: + package = qualified_name else: - package, _ = obj.title.rsplit(".", maxsplit=1) + package = qualified_name.rsplit(".", maxsplit=1)[0] base_name = ".".join(package.split(".", depth)[:depth]) if base_name not in self.used_colors: self.used_colors[base_name] = next(self.available_colors) @@ -197,9 +205,14 @@ def get_values(self, obj): args = [] label = r"{}{}({})\l".format(label, func.name, ", ".join(args)) label = "{%s}" % label - if is_exception(obj.node): - return dict(fontcolor="red", label=label, shape="record") - return dict(label=label, shape="record") + values = dict( + label=label, + shape="record", + fontcolor="red" if is_exception(obj.node) else "black", + style=self.get_style(), + color=self.get_color(obj), + ) + return values def close_graph(self): """print the dot graph into """ diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index 8867b4e416..68f52e4b7b 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -1,10 +1,10 @@ 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"]; +"0" [color="black", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; +"1" [color="black", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="solid"]; +"2" [color="black", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; +"3" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; "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"]; diff --git a/tests/data/classes_colorized.dot b/tests/data/classes_colorized.dot index 2509f1132c..b815001b92 100644 --- a/tests/data/classes_colorized.dot +++ b/tests/data/classes_colorized.dot @@ -1,10 +1,10 @@ digraph "classes_colorized" { 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"]; +"0" [color="aliceblue", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"1" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; +"2" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"3" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; "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"]; From 738e7b92f4a42debc16f583025d721997bbfbe0a Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Wed, 26 May 2021 20:00:02 +0200 Subject: [PATCH 09/32] Modify test data and config so that colorization of standard library modules is checked --- tests/data/classes_No_Name.dot | 7 ++++--- tests/data/classes_colorized.dot | 29 ++++++++++++++++++++++------- tests/data/suppliermodule_test.py | 3 +++ tests/unittest_pyreverse_writer.py | 4 ++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index 68f52e4b7b..f3ee953b13 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -4,9 +4,10 @@ rankdir=BT "0" [color="black", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; "1" [color="black", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="solid"]; "2" [color="black", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; -"3" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; -"3" -> "0" [arrowhead="empty", arrowtail="none"]; +"3" [color="black", fontcolor="black", label="{MyDict|\l|}", shape="record", style="solid"]; +"4" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; +"4" -> "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"]; +"1" -> "4" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; } diff --git a/tests/data/classes_colorized.dot b/tests/data/classes_colorized.dot index b815001b92..899109b17a 100644 --- a/tests/data/classes_colorized.dot +++ b/tests/data/classes_colorized.dot @@ -2,11 +2,26 @@ digraph "classes_colorized" { charset="utf-8" rankdir=BT "0" [color="aliceblue", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"1" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; -"2" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"3" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; -"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"]; +"1" [color="grey", fontcolor="black", label="{Collection|\l|}", shape="record", style="filled"]; +"2" [color="grey", fontcolor="black", label="{Container|\l|}", shape="record", style="filled"]; +"3" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; +"4" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"5" [color="grey", fontcolor="black", label="{Iterable|\l|}", shape="record", style="filled"]; +"6" [color="grey", fontcolor="black", label="{Mapping|\l|get(key, default)\litems()\lkeys()\lvalues()\l}", shape="record", style="filled"]; +"7" [color="grey", fontcolor="black", label="{MutableMapping|\l|clear()\lpop(key, default)\lpopitem()\lsetdefault(key, default)\lupdate()\l}", shape="record", style="filled"]; +"8" [color="aliceblue", fontcolor="black", label="{MyDict|\l|}", shape="record", style="filled"]; +"9" [color="grey", fontcolor="black", label="{Sized|\l|}", shape="record", style="filled"]; +"10" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; +"11" [color="grey", fontcolor="black", label="{UserDict|data : dict\l|copy()\lfromkeys(cls, iterable, value)\l}", shape="record", style="filled"]; +"1" -> "2" [arrowhead="empty", arrowtail="none"]; +"1" -> "5" [arrowhead="empty", arrowtail="none"]; +"1" -> "9" [arrowhead="empty", arrowtail="none"]; +"6" -> "1" [arrowhead="empty", arrowtail="none"]; +"7" -> "6" [arrowhead="empty", arrowtail="none"]; +"8" -> "11" [arrowhead="empty", arrowtail="none"]; +"10" -> "0" [arrowhead="empty", arrowtail="none"]; +"11" -> "7" [arrowhead="empty", arrowtail="none"]; +"0" -> "4" [arrowhead="empty", arrowtail="node", style="dashed"]; +"3" -> "0" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="cls_member", style="solid"]; +"3" -> "10" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; } diff --git a/tests/data/suppliermodule_test.py b/tests/data/suppliermodule_test.py index 24dc9a02fe..b86eadd8db 100644 --- a/tests/data/suppliermodule_test.py +++ b/tests/data/suppliermodule_test.py @@ -1,4 +1,5 @@ """ file suppliermodule.py """ +from collections import UserDict class Interface: def get_value(self): @@ -8,3 +9,5 @@ def set_value(self, value): raise NotImplementedError class DoNothing: pass + +class MyDict(UserDict): pass diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index abb99cb4e1..fd1dd5f73d 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -15,7 +15,7 @@ # For details: https://github.com/PyCQA/pylint/blob/master/LICENSE """ -unit test for visitors.diadefs and extensions.diadefslib modules +unit test for pyreverse writer classes """ @@ -49,7 +49,7 @@ } _COLORIZED = { - "all_ancestors": None, + "all_ancestors": True, "show_associated": None, "module_names": None, "output_format": "dot", From 36c36880106004cfee7e39d320589a9b8b29671a Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Fri, 28 May 2021 21:27:32 +0200 Subject: [PATCH 10/32] Add regression tests for vcg output --- pylint/pyreverse/vcgutils.py | 4 +-- tests/data/classes_vcg.vcg | 41 +++++++++++++++++++++++++++++ tests/data/packages_vcg.vcg | 20 ++++++++++++++ tests/unittest_pyreverse_diadefs.py | 1 + tests/unittest_pyreverse_writer.py | 29 +++++++++++++++++--- 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 tests/data/classes_vcg.vcg create mode 100644 tests/data/packages_vcg.vcg diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py index 8f6810998a..3a940849ba 100644 --- a/pylint/pyreverse/vcgutils.py +++ b/pylint/pyreverse/vcgutils.py @@ -82,9 +82,7 @@ } # attributes that may be passed into the function but are not relevant vor the VCGPrinter (e.g. attributes only relevant for DOT files) -IGNORED_ATTRS = { - "style", -} +IGNORED_ATTRS = {"style", "color"} # meaning of possible values: # O -> string diff --git a/tests/data/classes_vcg.vcg b/tests/data/classes_vcg.vcg new file mode 100644 index 0000000000..2c87100db0 --- /dev/null +++ b/tests/data/classes_vcg.vcg @@ -0,0 +1,41 @@ +graph:{ + title:"classes_vcg" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + node: {title:"0" label:"\fbAncestor\fn\n\f____________\n\f08attr : str\n\f08cls_member\n\f____________\n\f10get_value()\n\f10set_value()" + shape:box +} + node: {title:"1" label:"\fbDoNothing\fn\n\f___________" + shape:box +} + node: {title:"2" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" + shape:box +} + node: {title:"3" label:"\fbMyDict\fn\n\f________" + shape:box +} + node: {title:"4" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" + shape:box +} + edge: {sourcename:"4" targetname:"0" arrowstyle:solid + backarrowstyle:none + backarrowsize:10 +} + edge: {sourcename:"0" targetname:"2" arrowstyle:solid + backarrowstyle:none + linestyle:dotted + backarrowsize:10 +} + edge: {sourcename:"1" targetname:"0" label:"cls_member" + arrowstyle:solid + backarrowstyle:none + textcolor:green +} + edge: {sourcename:"1" targetname:"4" label:"relation" + arrowstyle:solid + backarrowstyle:none + textcolor:green +} +} diff --git a/tests/data/packages_vcg.vcg b/tests/data/packages_vcg.vcg new file mode 100644 index 0000000000..c646166c6e --- /dev/null +++ b/tests/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:"0" label:"\fbdata\fn" + shape:box +} + node: {title:"1" label:"\fbdata.clientmodule_test\fn" + shape:box +} + node: {title:"2" label:"\fbdata.suppliermodule_test\fn" + shape:box +} + edge: {sourcename:"1" targetname:"2" arrowstyle:solid + backarrowstyle:none + backarrowsize:0 +} +} diff --git a/tests/unittest_pyreverse_diadefs.py b/tests/unittest_pyreverse_diadefs.py index 047060af38..9d4e355250 100644 --- a/tests/unittest_pyreverse_diadefs.py +++ b/tests/unittest_pyreverse_diadefs.py @@ -143,6 +143,7 @@ def test_known_values1(HANDLER, PROJECT): (True, "Ancestor"), (True, "DoNothing"), (True, "Interface"), + (True, "MyDict"), (True, "Specialization"), ] diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index fd1dd5f73d..e69e5840ca 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -28,7 +28,7 @@ from pylint.pyreverse.diadefslib import DefaultDiadefGenerator, DiadefsHandler from pylint.pyreverse.inspector import Linker, project_from_files from pylint.pyreverse.utils import get_visibility -from pylint.pyreverse.writer import DotWriter +from pylint.pyreverse.writer import DotWriter, VCGWriter _DEFAULTS = { "all_ancestors": None, @@ -48,6 +48,24 @@ "max_color_depth": 2, } +_VCG_OUTPUT = { + "all_ancestors": None, + "show_associated": None, + "module_names": None, + "output_format": "vcg", + "diadefs_file": None, + "quiet": 0, + "show_ancestors": None, + "classes": (), + "all_associated": None, + "mode": "PUB_ONLY", + "show_builtin": False, + "only_classnames": False, + "output_directory": "", + "colorized": False, + "max_color_depth": 2, +} + _COLORIZED = { "all_ancestors": True, "show_associated": None, @@ -100,6 +118,7 @@ def _astroid_wrapper(func, modname): 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"] def _create_files(config, name="No Name"): @@ -109,14 +128,17 @@ def _create_files(config, name="No Name"): dd = DefaultDiadefGenerator(linker, handler).visit(project) for diagram in dd: diagram.extract_relationships() - writer = DotWriter(config) + if config.output_format == "vcg": + writer = VCGWriter(config) + else: + writer = DotWriter(config) writer.write(dd) @pytest.fixture(scope="module") def cleanup(): yield - for fname in DOT_FILES + COLORIZED_DOT_FILES: + for fname in DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES: try: os.remove(fname) except FileNotFoundError: @@ -129,6 +151,7 @@ def cleanup(): [ (Config(_DEFAULTS), "No Name", DOT_FILES), (Config(_COLORIZED), "colorized", COLORIZED_DOT_FILES), + (Config(_VCG_OUTPUT), "vcg", VCG_FILES), ], ) def test_dot_files(config, name, generated_files): From 225ef45af675d047edd7fe07792b12ed7831f172 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Fri, 28 May 2021 21:53:05 +0200 Subject: [PATCH 11/32] Delete class inheriting from standard library in test data, since the test does not work in tox env --- tests/data/classes_No_Name.dot | 7 +++---- tests/data/classes_colorized.dot | 29 +++++++---------------------- tests/data/classes_vcg.vcg | 9 +++------ tests/data/suppliermodule_test.py | 3 --- tests/unittest_pyreverse_diadefs.py | 1 - 5 files changed, 13 insertions(+), 36 deletions(-) diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index f3ee953b13..68f52e4b7b 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -4,10 +4,9 @@ rankdir=BT "0" [color="black", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; "1" [color="black", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="solid"]; "2" [color="black", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; -"3" [color="black", fontcolor="black", label="{MyDict|\l|}", shape="record", style="solid"]; -"4" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; -"4" -> "0" [arrowhead="empty", arrowtail="none"]; +"3" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; +"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" -> "4" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; +"1" -> "3" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; } diff --git a/tests/data/classes_colorized.dot b/tests/data/classes_colorized.dot index 899109b17a..b815001b92 100644 --- a/tests/data/classes_colorized.dot +++ b/tests/data/classes_colorized.dot @@ -2,26 +2,11 @@ digraph "classes_colorized" { charset="utf-8" rankdir=BT "0" [color="aliceblue", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"1" [color="grey", fontcolor="black", label="{Collection|\l|}", shape="record", style="filled"]; -"2" [color="grey", fontcolor="black", label="{Container|\l|}", shape="record", style="filled"]; -"3" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; -"4" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"5" [color="grey", fontcolor="black", label="{Iterable|\l|}", shape="record", style="filled"]; -"6" [color="grey", fontcolor="black", label="{Mapping|\l|get(key, default)\litems()\lkeys()\lvalues()\l}", shape="record", style="filled"]; -"7" [color="grey", fontcolor="black", label="{MutableMapping|\l|clear()\lpop(key, default)\lpopitem()\lsetdefault(key, default)\lupdate()\l}", shape="record", style="filled"]; -"8" [color="aliceblue", fontcolor="black", label="{MyDict|\l|}", shape="record", style="filled"]; -"9" [color="grey", fontcolor="black", label="{Sized|\l|}", shape="record", style="filled"]; -"10" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; -"11" [color="grey", fontcolor="black", label="{UserDict|data : dict\l|copy()\lfromkeys(cls, iterable, value)\l}", shape="record", style="filled"]; -"1" -> "2" [arrowhead="empty", arrowtail="none"]; -"1" -> "5" [arrowhead="empty", arrowtail="none"]; -"1" -> "9" [arrowhead="empty", arrowtail="none"]; -"6" -> "1" [arrowhead="empty", arrowtail="none"]; -"7" -> "6" [arrowhead="empty", arrowtail="none"]; -"8" -> "11" [arrowhead="empty", arrowtail="none"]; -"10" -> "0" [arrowhead="empty", arrowtail="none"]; -"11" -> "7" [arrowhead="empty", arrowtail="none"]; -"0" -> "4" [arrowhead="empty", arrowtail="node", style="dashed"]; -"3" -> "0" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="cls_member", style="solid"]; -"3" -> "10" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"]; +"1" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; +"2" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; +"3" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; +"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/classes_vcg.vcg b/tests/data/classes_vcg.vcg index 2c87100db0..101d791c58 100644 --- a/tests/data/classes_vcg.vcg +++ b/tests/data/classes_vcg.vcg @@ -13,13 +13,10 @@ graph:{ node: {title:"2" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" shape:box } - node: {title:"3" label:"\fbMyDict\fn\n\f________" + node: {title:"3" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" shape:box } - node: {title:"4" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" - shape:box -} - edge: {sourcename:"4" targetname:"0" arrowstyle:solid + edge: {sourcename:"3" targetname:"0" arrowstyle:solid backarrowstyle:none backarrowsize:10 } @@ -33,7 +30,7 @@ graph:{ backarrowstyle:none textcolor:green } - edge: {sourcename:"1" targetname:"4" label:"relation" + edge: {sourcename:"1" targetname:"3" label:"relation" arrowstyle:solid backarrowstyle:none textcolor:green diff --git a/tests/data/suppliermodule_test.py b/tests/data/suppliermodule_test.py index b86eadd8db..24dc9a02fe 100644 --- a/tests/data/suppliermodule_test.py +++ b/tests/data/suppliermodule_test.py @@ -1,5 +1,4 @@ """ file suppliermodule.py """ -from collections import UserDict class Interface: def get_value(self): @@ -9,5 +8,3 @@ def set_value(self, value): raise NotImplementedError class DoNothing: pass - -class MyDict(UserDict): pass diff --git a/tests/unittest_pyreverse_diadefs.py b/tests/unittest_pyreverse_diadefs.py index 098d1257d0..7809654109 100644 --- a/tests/unittest_pyreverse_diadefs.py +++ b/tests/unittest_pyreverse_diadefs.py @@ -141,7 +141,6 @@ def test_known_values1(HANDLER, PROJECT): (True, "Ancestor"), (True, "DoNothing"), (True, "Interface"), - (True, "MyDict"), (True, "Specialization"), ] From e8e898fc003b1218f3209222927a65204ecd6548 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 29 May 2021 11:24:51 +0200 Subject: [PATCH 12/32] Refactoring: extract logic what attributes to set for packages in method ``get_package_properties``. Rename ``get_values`` to ``get_class_properties``. VCGWriter and VCGPrinter thus no longer need special handling. --- pylint/pyreverse/vcgutils.py | 5 ---- pylint/pyreverse/writer.py | 46 +++++++++++++++++------------------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcgutils.py index 3a940849ba..38193b3254 100644 --- a/pylint/pyreverse/vcgutils.py +++ b/pylint/pyreverse/vcgutils.py @@ -81,9 +81,6 @@ "linestyles": ("continuous", "dashed", "dotted", "invisible"), } -# attributes that may be passed into the function but are not relevant vor the VCGPrinter (e.g. attributes only relevant for DOT files) -IGNORED_ATTRS = {"style", "color"} - # meaning of possible values: # O -> string # 1 -> int @@ -197,8 +194,6 @@ def edge(self, from_node, to_node, edge_type="", **args): def _write_attributes(self, attributes_dict, **args): """write graph, node or edge attributes""" for key, value in args.items(): - if key in IGNORED_ATTRS: - continue try: _type = attributes_dict[key] except KeyError as e: diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 48b845dc4e..62863dc1d0 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -53,13 +53,7 @@ def write_packages(self, diagram): """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", - color=self.get_color(obj), - style=self.get_style(), - ) + self.printer.emit_node(i, **self.get_package_properties(obj)) obj.fig_id = i # package dependencies for rel in diagram.get_relationships("depends"): @@ -71,7 +65,7 @@ def write_classes(self, diagram): """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)) + self.printer.emit_node(i, **self.get_class_properties(obj)) obj.fig_id = i # inheritance links for rel in diagram.get_relationships("specialization"): @@ -100,15 +94,11 @@ def get_title(self, obj): """get project title""" raise NotImplementedError - def get_style(self): - """get style""" - raise NotImplementedError - - def get_color(self, obj): - """get shape color""" + def get_package_properties(self, obj): + """get label and shape for packages.""" raise NotImplementedError - def get_values(self, obj): + def get_class_properties(self, obj): """get label and shape for classes.""" raise NotImplementedError @@ -188,7 +178,16 @@ def get_color(self, obj): self.used_colors[base_name] = next(self.available_colors) return self.used_colors[base_name] - def get_values(self, obj): + def get_package_properties(self, obj): + """get label and shape for packages.""" + return dict( + label=self.get_title(obj), + shape="box", + color=self.get_color(obj), + style=self.get_style(), + ) + + def get_class_properties(self, obj): """get label and shape for classes. The label contains all attributes and methods @@ -254,15 +253,14 @@ def get_title(self, obj): """get project title in vcg format""" return r"\fb%s\fn" % obj.title - def get_style(self): - """get style of object""" - return None - - def get_color(self, obj): - """get color for object""" - return "black" + def get_package_properties(self, obj): + """get label and shape for packages.""" + return dict( + label=self.get_title(obj), + shape="box", + ) - def get_values(self, obj): + def get_class_properties(self, obj): """get label and shape for classes. The label contains all attributes and methods From e70c1356c3f9f408cf685ab1b70c08fd85b94a90 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 29 May 2021 11:34:27 +0200 Subject: [PATCH 13/32] Add ChangeLog and whatsnew entrAdd ChangeLog and whatsnew entryy Add ChangeLog and whatsnew entry --- ChangeLog | 4 ++++ doc/whatsnew/2.9.rst | 2 ++ 2 files changed, 6 insertions(+) diff --git a/ChangeLog b/ChangeLog index a62cb9231d..4977c6db66 100644 --- a/ChangeLog +++ b/ChangeLog @@ -9,6 +9,10 @@ Release date: TBA .. Put new features and bugfixes here and also in 'doc/whatsnew/2.9.rst' +* Added ``--colorized`` option to ``pyreverse`` to visualize modules/packages of the same parent package. + + Closes #4488 + * Added ``deprecated-decorator``: Emitted when deprecated decorator is used. Closes #4429 diff --git a/doc/whatsnew/2.9.rst b/doc/whatsnew/2.9.rst index c3ad8e7bf3..0f7cb27bc8 100644 --- a/doc/whatsnew/2.9.rst +++ b/doc/whatsnew/2.9.rst @@ -55,3 +55,5 @@ Other Changes like ``[]`` and ``[1, 2, 3]``. * ``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. From ca0e3ec2a050b95a1c6951d1a8aeec134938fc3b Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 30 May 2021 08:45:24 +0200 Subject: [PATCH 14/32] First implementation of PlantUML backend for pyreverse. --- pylint/pyreverse/main.py | 2 + pylint/pyreverse/pumlutils.py | 63 ++++++++++++++++++++++++++++++ pylint/pyreverse/writer.py | 53 +++++++++++++++++++++++++ tests/data/classes_plantuml.puml | 23 +++++++++++ tests/data/packages_plantuml.puml | 12 ++++++ tests/unittest_pyreverse_writer.py | 26 +++++++++++- 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 pylint/pyreverse/pumlutils.py create mode 100644 tests/data/classes_plantuml.puml create mode 100644 tests/data/packages_plantuml.puml diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index 04b3b93695..3a4d934ae7 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -241,6 +241,8 @@ def run(self, args): if self.config.output_format == "vcg": writer.VCGWriter(self.config).write(diadefs) + elif self.config.output_format == "puml": + writer.PlantUmlWriter(self.config).write(diadefs) else: writer.DotWriter(self.config).write(diadefs) return 0 diff --git a/pylint/pyreverse/pumlutils.py b/pylint/pyreverse/pumlutils.py new file mode 100644 index 0000000000..f9f1540ac0 --- /dev/null +++ b/pylint/pyreverse/pumlutils.py @@ -0,0 +1,63 @@ +# 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 enum import Enum +from typing import List, Optional, Union + + +class PumlItem(Enum): + CLASS = "class" + PACKAGE = "package" + + +class PumlArrow(Enum): + INHERITS = "--|>" + IMPLEMENTS = "..|>" + ASSOCIATION = "--*" + USES = "-->" + + +class PlantUmlPrinter: + def __init__(self, title: str): + self.title: str = title + self.lines: List[str] = [] + self._begin() + + def _begin(self): + self.emit("@startuml " + self.title) + + def emit(self, line: str): + if not line.endswith("\n"): + line += "\n" + self.lines.append(line) + + def emit_node( + self, + id_: Union[int, str], + type_: PumlItem, + label: str, + body: Optional[str] = "", + ): + self.emit(f'{type_.value} "{label}" as {id_} {{\n{body}\n}}') + + def emit_edge( + self, + from_node: Union[int, str], + to_node: Union[int, str], + type_: PumlArrow, + label: Optional[str] = None, + ): + edge = f"{from_node} {type_.value} {to_node}" + if label: + edge += f" : {label}" + self.emit(edge) + + def generate(self, outputfile: str): + self._end() + with open(outputfile, "w", encoding="utf-8") as outfile: + outfile.writelines(self.lines) + + def _end(self): + self.emit("@enduml") diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 62863dc1d0..5802b536eb 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -23,6 +23,7 @@ from astroid import modutils from pylint.graph import DotBackend +from pylint.pyreverse.pumlutils import PlantUmlPrinter, PumlArrow, PumlItem from pylint.pyreverse.utils import is_exception from pylint.pyreverse.vcgutils import VCGPrinter @@ -292,3 +293,55 @@ def close_graph(self): """close graph and file""" self.printer.close_graph() self.graph_file.close() + + +class PlantUmlWriter(DiagramWriter): + """write PlantUML graphs from a diagram definition and a project""" + + def __init__(self, config): + styles = [ + dict(type_=PumlArrow.USES), # package edges + dict(type_=PumlArrow.INHERITS), # inheritance edges + dict(type_=PumlArrow.IMPLEMENTS), # implementation edges + dict(type_=PumlArrow.ASSOCIATION), # association edges + ] + self.file_name = None + DiagramWriter.__init__(self, config, styles) + + def set_printer(self, file_name, basename): + """set printer""" + self.file_name = file_name + self.printer = PlantUmlPrinter(basename) + + def get_title(self, obj): + """get project title""" + return obj.title + + def get_package_properties(self, obj): + """get label and shape for packages.""" + return dict( + type_=PumlItem.PACKAGE, + label=obj.title, + ) + + def get_class_properties(self, obj): + """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 dict( + type_=PumlItem.CLASS, + label=obj.title, + body=body, + ) + + def close_graph(self): + """finalize the graph""" + self.printer.generate(self.file_name) diff --git a/tests/data/classes_plantuml.puml b/tests/data/classes_plantuml.puml new file mode 100644 index 0000000000..275266bf84 --- /dev/null +++ b/tests/data/classes_plantuml.puml @@ -0,0 +1,23 @@ +@startuml classes_plantuml +class "Ancestor" as 0 { + attr : str + cls_member + get_value() + set_value(value) +} +class "DoNothing" as 1 { +} +class "Interface" as 2 { + get_value() + set_value(value) +} +class "Specialization" as 3 { + TYPE : str + relation + top : str +} +3 --|> 0 +0 ..|> 2 +1 --* 0 : cls_member +1 --* 3 : relation +@enduml diff --git a/tests/data/packages_plantuml.puml b/tests/data/packages_plantuml.puml new file mode 100644 index 0000000000..2a6396ef7d --- /dev/null +++ b/tests/data/packages_plantuml.puml @@ -0,0 +1,12 @@ +@startuml packages_plantuml +package "data" as 0 { + +} +package "data.clientmodule_test" as 1 { + +} +package "data.suppliermodule_test" as 2 { + +} +1 --> 2 +@enduml diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index e69e5840ca..6d68614bc8 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -28,7 +28,7 @@ from pylint.pyreverse.diadefslib import DefaultDiadefGenerator, DiadefsHandler from pylint.pyreverse.inspector import Linker, project_from_files from pylint.pyreverse.utils import get_visibility -from pylint.pyreverse.writer import DotWriter, VCGWriter +from pylint.pyreverse.writer import DotWriter, PlantUmlWriter, VCGWriter _DEFAULTS = { "all_ancestors": None, @@ -66,6 +66,24 @@ "max_color_depth": 2, } +_PUML_OUTPUT = { + "all_ancestors": None, + "show_associated": None, + "module_names": None, + "output_format": "puml", + "diadefs_file": None, + "quiet": 0, + "show_ancestors": None, + "classes": (), + "all_associated": None, + "mode": "PUB_ONLY", + "show_builtin": False, + "only_classnames": False, + "output_directory": "", + "colorized": False, + "max_color_depth": 2, +} + _COLORIZED = { "all_ancestors": True, "show_associated": None, @@ -119,6 +137,7 @@ def _astroid_wrapper(func, modname): 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"] def _create_files(config, name="No Name"): @@ -130,6 +149,8 @@ def _create_files(config, name="No Name"): 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) @@ -138,7 +159,7 @@ def _create_files(config, name="No Name"): @pytest.fixture(scope="module") def cleanup(): yield - for fname in DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES: + for fname in DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES + PUML_FILES: try: os.remove(fname) except FileNotFoundError: @@ -152,6 +173,7 @@ def cleanup(): (Config(_DEFAULTS), "No Name", DOT_FILES), (Config(_COLORIZED), "colorized", COLORIZED_DOT_FILES), (Config(_VCG_OUTPUT), "vcg", VCG_FILES), + (Config(_PUML_OUTPUT), "plantuml", PUML_FILES), ], ) def test_dot_files(config, name, generated_files): From f8deba07b2aef626828fbe32f92b3577b8d1646b Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 30 May 2021 12:34:32 +0200 Subject: [PATCH 15/32] Implement automatic coloring for PlantUML diagrams. Refactor coloring logic into own Mixin class that can be used for both ``DotWriter`` and ``PlantUmlWriter`` --- pylint/pyreverse/pumlutils.py | 4 +- pylint/pyreverse/writer.py | 74 ++++++++++++++----------- tests/data/classes_puml_colorized.puml | 24 ++++++++ tests/data/packages_puml_colorized.puml | 12 ++++ tests/unittest_pyreverse_writer.py | 24 +++++++- 5 files changed, 103 insertions(+), 35 deletions(-) create mode 100644 tests/data/classes_puml_colorized.puml create mode 100644 tests/data/packages_puml_colorized.puml diff --git a/pylint/pyreverse/pumlutils.py b/pylint/pyreverse/pumlutils.py index f9f1540ac0..fc659d5156 100644 --- a/pylint/pyreverse/pumlutils.py +++ b/pylint/pyreverse/pumlutils.py @@ -39,8 +39,10 @@ def emit_node( type_: PumlItem, label: str, body: Optional[str] = "", + color: Optional[str] = None, ): - self.emit(f'{type_.value} "{label}" as {id_} {{\n{body}\n}}') + color = " #" + color if color else "" + self.emit(f'{type_.value} "{label}" as {id_}{color} {{\n{body}\n}}') def emit_edge( self, diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 5802b536eb..4a5b7fb00f 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -108,18 +108,11 @@ def close_graph(self): raise NotImplementedError -class DotWriter(DiagramWriter): - """write dot graphs from a diagram definition and a project""" +class ColorMixin: + """provide methods to apply colors to objects""" - 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" - ), - ] + def __init__(self, depth): + self.depth = depth self.available_colors = itertools.cycle( [ "aliceblue", @@ -142,7 +135,38 @@ def __init__(self, config): ] ) self.used_colors = {} + + def get_color(self, obj): + """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) + ColorMixin.__init__(self, self.config.max_color_depth) def set_printer(self, file_name, basename): """initialize DotWriter and add options for layout.""" @@ -160,31 +184,12 @@ def get_style(self): return "solid" return "filled" - def get_color(self, obj): - """get shape color""" - if not self.config.colorized: - return "black" - qualified_name = obj.node.qname() - if modutils.is_standard_module(qualified_name.split(".", maxsplit=1)[0]): - return "grey" - depth = self.config.max_color_depth - 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(".", depth)[:depth]) - if base_name not in self.used_colors: - self.used_colors[base_name] = next(self.available_colors) - return self.used_colors[base_name] - def get_package_properties(self, obj): """get label and shape for packages.""" return dict( label=self.get_title(obj), shape="box", - color=self.get_color(obj), + color=self.get_color(obj) if self.config.colorized else "black", style=self.get_style(), ) @@ -210,7 +215,7 @@ def get_class_properties(self, obj): shape="record", fontcolor="red" if is_exception(obj.node) else "black", style=self.get_style(), - color=self.get_color(obj), + color=self.get_color(obj) if self.config.colorized else "black", ) return values @@ -295,7 +300,7 @@ def close_graph(self): self.graph_file.close() -class PlantUmlWriter(DiagramWriter): +class PlantUmlWriter(DiagramWriter, ColorMixin): """write PlantUML graphs from a diagram definition and a project""" def __init__(self, config): @@ -307,6 +312,7 @@ def __init__(self, config): ] self.file_name = None DiagramWriter.__init__(self, config, styles) + ColorMixin.__init__(self, self.config.max_color_depth) def set_printer(self, file_name, basename): """set printer""" @@ -322,6 +328,7 @@ def get_package_properties(self, obj): return dict( type_=PumlItem.PACKAGE, label=obj.title, + color=self.get_color(obj) if self.config.colorized else None, ) def get_class_properties(self, obj): @@ -340,6 +347,7 @@ def get_class_properties(self, obj): type_=PumlItem.CLASS, label=obj.title, body=body, + color=self.get_color(obj) if self.config.colorized else None, ) def close_graph(self): diff --git a/tests/data/classes_puml_colorized.puml b/tests/data/classes_puml_colorized.puml new file mode 100644 index 0000000000..916a7ce5cf --- /dev/null +++ b/tests/data/classes_puml_colorized.puml @@ -0,0 +1,24 @@ +@startuml classes_puml_colorized +class "Ancestor" as 0 #aliceblue { +attr : str +cls_member +get_value() +set_value(value) +} +class "DoNothing" as 1 #aliceblue { + +} +class "Interface" as 2 #aliceblue { +get_value() +set_value(value) +} +class "Specialization" as 3 #aliceblue { +TYPE : str +relation +top : str +} +3 --|> 0 +0 ..|> 2 +1 --* 0 : cls_member +1 --* 3 : relation +@enduml diff --git a/tests/data/packages_puml_colorized.puml b/tests/data/packages_puml_colorized.puml new file mode 100644 index 0000000000..741df7ebca --- /dev/null +++ b/tests/data/packages_puml_colorized.puml @@ -0,0 +1,12 @@ +@startuml packages_puml_colorized +package "data" as 0 #aliceblue { + +} +package "data.clientmodule_test" as 1 #aliceblue { + +} +package "data.suppliermodule_test" as 2 #aliceblue { + +} +1 --> 2 +@enduml diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index 6d68614bc8..9b8fd79ca1 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -84,6 +84,24 @@ "max_color_depth": 2, } +_COLORIZED_PUML_OUTPUT = { + "all_ancestors": None, + "show_associated": None, + "module_names": None, + "output_format": "puml", + "diadefs_file": None, + "quiet": 0, + "show_ancestors": None, + "classes": (), + "all_associated": None, + "mode": "PUB_ONLY", + "show_builtin": False, + "only_classnames": False, + "output_directory": "", + "colorized": True, + "max_color_depth": 2, +} + _COLORIZED = { "all_ancestors": True, "show_associated": None, @@ -138,6 +156,7 @@ def _astroid_wrapper(func, modname): 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"): @@ -159,7 +178,9 @@ def _create_files(config, name="No Name"): @pytest.fixture(scope="module") def cleanup(): yield - for fname in DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES + PUML_FILES: + for fname in ( + DOT_FILES + COLORIZED_DOT_FILES + VCG_FILES + PUML_FILES + COLORIZED_PUML_FILES + ): try: os.remove(fname) except FileNotFoundError: @@ -174,6 +195,7 @@ def cleanup(): (Config(_COLORIZED), "colorized", COLORIZED_DOT_FILES), (Config(_VCG_OUTPUT), "vcg", VCG_FILES), (Config(_PUML_OUTPUT), "plantuml", PUML_FILES), + (Config(_COLORIZED_PUML_OUTPUT), "puml_colorized", COLORIZED_PUML_FILES), ], ) def test_dot_files(config, name, generated_files): From 7b54169af3ef125f8b50e308c78f2ea3197883b5 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 30 May 2021 12:56:17 +0200 Subject: [PATCH 16/32] Stereotypes can now be optionally specified for classes in ``PlantUmlPrinter`` --- pylint/pyreverse/pumlutils.py | 6 ++++-- pylint/pyreverse/writer.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pylint/pyreverse/pumlutils.py b/pylint/pyreverse/pumlutils.py index fc659d5156..09096f6865 100644 --- a/pylint/pyreverse/pumlutils.py +++ b/pylint/pyreverse/pumlutils.py @@ -39,10 +39,12 @@ def emit_node( type_: PumlItem, label: str, body: Optional[str] = "", + stereotype: Optional[str] = "", color: Optional[str] = None, ): - color = " #" + color if color else "" - self.emit(f'{type_.value} "{label}" as {id_}{color} {{\n{body}\n}}') + stereotype = f" << {stereotype} >>" if stereotype else "" + color = f" #{color}" if color else "" + self.emit(f'{type_.value} "{label}" as {id_}{stereotype}{color} {{\n{body}\n}}') def emit_edge( self, diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 4a5b7fb00f..8d6c278356 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -347,6 +347,7 @@ def get_class_properties(self, obj): type_=PumlItem.CLASS, label=obj.title, body=body, + stereotype="interface" if obj.shape == "interface" else None, color=self.get_color(obj) if self.config.colorized else None, ) From ec7265b7e16067aa2e331ce1c1e9b640e3f3a090 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 30 May 2021 13:33:54 +0200 Subject: [PATCH 17/32] Delete ``classes.dot`` file in cleanup fixture that was generated during unittest --- tests/test_pylint_runners.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_pylint_runners.py b/tests/test_pylint_runners.py index 1f3739eed3..58d7ba0e5b 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] ) From ce98e5ff052f27a2278226447e5685b587679666 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Mon, 31 May 2021 19:29:33 +0200 Subject: [PATCH 18/32] Use the qualified name of objects as unique ID for diagrams to make them more readable. --- pylint/pyreverse/pumlutils.py | 5 +++- pylint/pyreverse/writer.py | 12 ++++----- tests/data/classes_No_Name.dot | 16 +++++------ tests/data/classes_colorized.dot | 16 +++++------ tests/data/classes_plantuml.puml | 36 +++++++++++++------------ tests/data/classes_puml_colorized.puml | 17 ++++++------ tests/data/classes_vcg.vcg | 16 +++++------ tests/data/packages_No_Name.dot | 8 +++--- tests/data/packages_colorized.dot | 8 +++--- tests/data/packages_plantuml.puml | 9 ++++--- tests/data/packages_puml_colorized.puml | 9 ++++--- tests/data/packages_vcg.vcg | 8 +++--- tests/unittest_pyreverse_writer.py | 2 +- 13 files changed, 85 insertions(+), 77 deletions(-) diff --git a/pylint/pyreverse/pumlutils.py b/pylint/pyreverse/pumlutils.py index 09096f6865..c8ebc1dcc9 100644 --- a/pylint/pyreverse/pumlutils.py +++ b/pylint/pyreverse/pumlutils.py @@ -20,13 +20,16 @@ class PumlArrow(Enum): class PlantUmlPrinter: - def __init__(self, title: str): + def __init__(self, title: str, use_automatic_namespace=False): self.title: str = title + self.use_automatic_namespace = use_automatic_namespace self.lines: List[str] = [] self._begin() def _begin(self): self.emit("@startuml " + self.title) + if not self.use_automatic_namespace: + self.emit("set namespaceSeparator none") def emit(self, line: str): if not line.endswith("\n"): diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 8d6c278356..cfcd3cd56c 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -53,9 +53,9 @@ def write(self, diadefs): def write_packages(self, diagram): """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, **self.get_package_properties(obj)) - 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, **self.get_package_properties(obj)) # package dependencies for rel in diagram.get_relationships("depends"): self.printer.emit_edge( @@ -65,9 +65,9 @@ def write_packages(self, diagram): def write_classes(self, diagram): """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_class_properties(obj)) - obj.fig_id = i + for obj in sorted(diagram.objects, key=lambda x: x.title): + obj.fig_id = obj.node.qname() + self.printer.emit_node(obj.fig_id, **self.get_class_properties(obj)) # inheritance links for rel in diagram.get_relationships("specialization"): self.printer.emit_edge( diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index 68f52e4b7b..feb32f3b77 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" [color="black", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; -"1" [color="black", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="solid"]; -"2" [color="black", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="solid"]; -"3" [color="black", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="solid"]; -"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"]; +"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_colorized.dot b/tests/data/classes_colorized.dot index b815001b92..3fa52b0a67 100644 --- a/tests/data/classes_colorized.dot +++ b/tests/data/classes_colorized.dot @@ -1,12 +1,12 @@ digraph "classes_colorized" { charset="utf-8" rankdir=BT -"0" [color="aliceblue", fontcolor="black", label="{Ancestor|attr : str\lcls_member\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"1" [color="aliceblue", fontcolor="black", label="{DoNothing|\l|}", shape="record", style="filled"]; -"2" [color="aliceblue", fontcolor="black", label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record", style="filled"]; -"3" [color="aliceblue", fontcolor="black", label="{Specialization|TYPE : str\lrelation\ltop : str\l|}", shape="record", style="filled"]; -"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"]; +"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/data/classes_plantuml.puml b/tests/data/classes_plantuml.puml index 275266bf84..b3e77562d6 100644 --- a/tests/data/classes_plantuml.puml +++ b/tests/data/classes_plantuml.puml @@ -1,23 +1,25 @@ @startuml classes_plantuml -class "Ancestor" as 0 { - attr : str - cls_member - get_value() - set_value(value) +set namespaceSeparator none +class "Ancestor" as data.clientmodule_test.Ancestor { +attr : str +cls_member +get_value() +set_value(value) } -class "DoNothing" as 1 { +class "DoNothing" as data.suppliermodule_test.DoNothing { + } -class "Interface" as 2 { - get_value() - set_value(value) +class "Interface" as data.suppliermodule_test.Interface { +get_value() +set_value(value) } -class "Specialization" as 3 { - TYPE : str - relation - top : str +class "Specialization" as data.clientmodule_test.Specialization { +TYPE : str +relation +top : str } -3 --|> 0 -0 ..|> 2 -1 --* 0 : cls_member -1 --* 3 : relation +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/data/classes_puml_colorized.puml b/tests/data/classes_puml_colorized.puml index 916a7ce5cf..2ce4eb3239 100644 --- a/tests/data/classes_puml_colorized.puml +++ b/tests/data/classes_puml_colorized.puml @@ -1,24 +1,25 @@ @startuml classes_puml_colorized -class "Ancestor" as 0 #aliceblue { +set namespaceSeparator none +class "Ancestor" as data.clientmodule_test.Ancestor #aliceblue { attr : str cls_member get_value() set_value(value) } -class "DoNothing" as 1 #aliceblue { +class "DoNothing" as data.suppliermodule_test.DoNothing #aliceblue { } -class "Interface" as 2 #aliceblue { +class "Interface" as data.suppliermodule_test.Interface #aliceblue { get_value() set_value(value) } -class "Specialization" as 3 #aliceblue { +class "Specialization" as data.clientmodule_test.Specialization #aliceblue { TYPE : str relation top : str } -3 --|> 0 -0 ..|> 2 -1 --* 0 : cls_member -1 --* 3 : relation +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/data/classes_vcg.vcg b/tests/data/classes_vcg.vcg index 101d791c58..9419efc0de 100644 --- a/tests/data/classes_vcg.vcg +++ b/tests/data/classes_vcg.vcg @@ -4,33 +4,33 @@ graph:{ late_edge_labels:yes port_sharing:no manhattan_edges:yes - node: {title:"0" label:"\fbAncestor\fn\n\f____________\n\f08attr : str\n\f08cls_member\n\f____________\n\f10get_value()\n\f10set_value()" + 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:"1" label:"\fbDoNothing\fn\n\f___________" + node: {title:"data.suppliermodule_test.DoNothing" label:"\fbDoNothing\fn\n\f___________" shape:box } - node: {title:"2" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" + node: {title:"data.suppliermodule_test.Interface" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" shape:box } - node: {title:"3" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" + 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:"3" targetname:"0" arrowstyle:solid + edge: {sourcename:"data.clientmodule_test.Specialization" targetname:"data.clientmodule_test.Ancestor" arrowstyle:solid backarrowstyle:none backarrowsize:10 } - edge: {sourcename:"0" targetname:"2" arrowstyle:solid + edge: {sourcename:"data.clientmodule_test.Ancestor" targetname:"data.suppliermodule_test.Interface" arrowstyle:solid backarrowstyle:none linestyle:dotted backarrowsize:10 } - edge: {sourcename:"1" targetname:"0" label:"cls_member" + edge: {sourcename:"data.suppliermodule_test.DoNothing" targetname:"data.clientmodule_test.Ancestor" label:"cls_member" arrowstyle:solid backarrowstyle:none textcolor:green } - edge: {sourcename:"1" targetname:"3" label:"relation" + edge: {sourcename:"data.suppliermodule_test.DoNothing" targetname:"data.clientmodule_test.Specialization" label:"relation" arrowstyle:solid backarrowstyle:none textcolor:green diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot index 64d5725f58..bc5c13f3e0 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" [color="black", label="data", shape="box", style="solid"]; -"1" [color="black", label="data.clientmodule_test", shape="box", style="solid"]; -"2" [color="black", label="data.suppliermodule_test", shape="box", style="solid"]; -"1" -> "2" [arrowhead="open", arrowtail="none"]; +"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_colorized.dot b/tests/data/packages_colorized.dot index 82fcb2a582..0f05798041 100644 --- a/tests/data/packages_colorized.dot +++ b/tests/data/packages_colorized.dot @@ -1,8 +1,8 @@ digraph "packages_colorized" { charset="utf-8" rankdir=BT -"0" [color="aliceblue", label="data", shape="box", style="filled"]; -"1" [color="aliceblue", label="data.clientmodule_test", shape="box", style="filled"]; -"2" [color="aliceblue", label="data.suppliermodule_test", shape="box", style="filled"]; -"1" -> "2" [arrowhead="open", arrowtail="none"]; +"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/data/packages_plantuml.puml b/tests/data/packages_plantuml.puml index 2a6396ef7d..e6f950f7b0 100644 --- a/tests/data/packages_plantuml.puml +++ b/tests/data/packages_plantuml.puml @@ -1,12 +1,13 @@ @startuml packages_plantuml -package "data" as 0 { +set namespaceSeparator none +package "data" as data { } -package "data.clientmodule_test" as 1 { +package "data.clientmodule_test" as data.clientmodule_test { } -package "data.suppliermodule_test" as 2 { +package "data.suppliermodule_test" as data.suppliermodule_test { } -1 --> 2 +data.clientmodule_test --> data.suppliermodule_test @enduml diff --git a/tests/data/packages_puml_colorized.puml b/tests/data/packages_puml_colorized.puml index 741df7ebca..826db4ca68 100644 --- a/tests/data/packages_puml_colorized.puml +++ b/tests/data/packages_puml_colorized.puml @@ -1,12 +1,13 @@ @startuml packages_puml_colorized -package "data" as 0 #aliceblue { +set namespaceSeparator none +package "data" as data #aliceblue { } -package "data.clientmodule_test" as 1 #aliceblue { +package "data.clientmodule_test" as data.clientmodule_test #aliceblue { } -package "data.suppliermodule_test" as 2 #aliceblue { +package "data.suppliermodule_test" as data.suppliermodule_test #aliceblue { } -1 --> 2 +data.clientmodule_test --> data.suppliermodule_test @enduml diff --git a/tests/data/packages_vcg.vcg b/tests/data/packages_vcg.vcg index c646166c6e..3d3b18acc5 100644 --- a/tests/data/packages_vcg.vcg +++ b/tests/data/packages_vcg.vcg @@ -4,16 +4,16 @@ graph:{ late_edge_labels:yes port_sharing:no manhattan_edges:yes - node: {title:"0" label:"\fbdata\fn" + node: {title:"data" label:"\fbdata\fn" shape:box } - node: {title:"1" label:"\fbdata.clientmodule_test\fn" + node: {title:"data.clientmodule_test" label:"\fbdata.clientmodule_test\fn" shape:box } - node: {title:"2" label:"\fbdata.suppliermodule_test\fn" + node: {title:"data.suppliermodule_test" label:"\fbdata.suppliermodule_test\fn" shape:box } - edge: {sourcename:"1" targetname:"2" arrowstyle:solid + 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 9b8fd79ca1..63063eb2c2 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -198,7 +198,7 @@ def cleanup(): (Config(_COLORIZED_PUML_OUTPUT), "puml_colorized", COLORIZED_PUML_FILES), ], ) -def test_dot_files(config, name, generated_files): +def test_generated_files(config, name, generated_files): _create_files(config, name) for generated_file in generated_files: expected_file = os.path.join(os.path.dirname(__file__), "data", generated_file) From 9e7c36db630e988a9bb078c668f291f0ee24d230 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 6 Jun 2021 14:56:01 +0200 Subject: [PATCH 19/32] Refactoring: create ``Printer`` base class and move logic for different backends into ``printer`` module. --- pylint/checkers/imports.py | 64 +++- pylint/graph.py | 207 ----------- pylint/pyreverse/main.py | 2 +- pylint/pyreverse/printer.py | 566 ++++++++++++++++++++++++++++++ pylint/pyreverse/pumlutils.py | 70 ---- pylint/pyreverse/vcgutils.py | 224 ------------ pylint/pyreverse/writer.py | 124 +++---- tests/data/classes_No_Name.dot | 2 +- tests/data/classes_colorized.dot | 2 +- tests/data/classes_vcg.vcg | 8 +- tests/data/packages_No_Name.dot | 2 +- tests/data/packages_colorized.dot | 2 +- tests/test_import_graph.py | 12 +- 13 files changed, 680 insertions(+), 605 deletions(-) delete mode 100644 pylint/graph.py create mode 100644 pylint/pyreverse/printer.py delete mode 100644 pylint/pyreverse/pumlutils.py delete mode 100644 pylint/pyreverse/vcgutils.py diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py index 7f5b48d9aa..a4d3c7fed5 100644 --- a/pylint/checkers/imports.py +++ b/pylint/checkers/imports.py @@ -37,6 +37,7 @@ # Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> # Copyright (c) 2021 Andrew Howe # Copyright (c) 2021 Matus Valo +# 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 @@ -59,8 +60,8 @@ 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 DotPrinter, EdgeType, Layout, NodeType from pylint.reporters.ureports.nodes import Paragraph, VerbatimText, VNode from pylint.utils import IsortDriver, get_global_option @@ -190,30 +191,75 @@ 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 = DotPrinter( + os.path.splitext(os.path.basename(filename))[0], layout=Layout.LEFT_TO_RIGHT + ) printer.emit('URL="." node[shape="box"]') 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/main.py b/pylint/pyreverse/main.py index 3a4d934ae7..a7b8d9da59 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -200,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) diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py new file mode 100644 index 0000000000..779bff35ee --- /dev/null +++ b/pylint/pyreverse/printer.py @@ -0,0 +1,566 @@ +# 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, Optional, Tuple, Type + + +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 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, + label: Optional[str] = None, + body: Optional[str] = None, + color: Optional[str] = None, + fontcolor: Optional[str] = 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, + label: Optional[str] = None, + body: Optional[str] = None, + color: Optional[str] = None, + fontcolor: Optional[str] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + stereotype = " << interface >>" if type_ is NodeType.INTERFACE else "" + nodetype = self.NODES[type_] + color = f" #{color}" if color else "" + body = body if body else "" + if label is None: + label = name + if 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, + label: Optional[str] = None, + body: Optional[str] = None, + color: Optional[str] = None, + fontcolor: Optional[str] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False) + if label is None: + label = 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, + label: Optional[str] = None, + body: Optional[str] = None, + color: Optional[str] = "black", + fontcolor: Optional[str] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + shape = self.SHAPES[type_] + if label: + if type_ is NodeType.INTERFACE: + label = "<>\\n" + label + label_part = f', label="{label}"' + else: + label_part = "" + fontcolor_part = f', fontcolor="{fontcolor}"' if 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 = 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 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 = target_info_from_filename(filename_or_extension) + printer_mapping = { + "dot": DotPrinter, + "vcg": VCGPrinter, + "puml": PlantUmlPrinter, + } + # will probably fail if just the extension is passed into! + return printer_mapping.get(extension, default=DotPrinter) + + +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 diff --git a/pylint/pyreverse/pumlutils.py b/pylint/pyreverse/pumlutils.py deleted file mode 100644 index c8ebc1dcc9..0000000000 --- a/pylint/pyreverse/pumlutils.py +++ /dev/null @@ -1,70 +0,0 @@ -# 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 enum import Enum -from typing import List, Optional, Union - - -class PumlItem(Enum): - CLASS = "class" - PACKAGE = "package" - - -class PumlArrow(Enum): - INHERITS = "--|>" - IMPLEMENTS = "..|>" - ASSOCIATION = "--*" - USES = "-->" - - -class PlantUmlPrinter: - def __init__(self, title: str, use_automatic_namespace=False): - self.title: str = title - self.use_automatic_namespace = use_automatic_namespace - self.lines: List[str] = [] - self._begin() - - def _begin(self): - self.emit("@startuml " + self.title) - if not self.use_automatic_namespace: - self.emit("set namespaceSeparator none") - - def emit(self, line: str): - if not line.endswith("\n"): - line += "\n" - self.lines.append(line) - - def emit_node( - self, - id_: Union[int, str], - type_: PumlItem, - label: str, - body: Optional[str] = "", - stereotype: Optional[str] = "", - color: Optional[str] = None, - ): - stereotype = f" << {stereotype} >>" if stereotype else "" - color = f" #{color}" if color else "" - self.emit(f'{type_.value} "{label}" as {id_}{stereotype}{color} {{\n{body}\n}}') - - def emit_edge( - self, - from_node: Union[int, str], - to_node: Union[int, str], - type_: PumlArrow, - label: Optional[str] = None, - ): - edge = f"{from_node} {type_.value} {to_node}" - if label: - edge += f" : {label}" - self.emit(edge) - - def generate(self, outputfile: str): - self._end() - with open(outputfile, "w", encoding="utf-8") as outfile: - outfile.writelines(self.lines) - - def _end(self): - self.emit("@enduml") 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 cfcd3cd56c..ca83fac614 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -22,18 +22,22 @@ import astroid from astroid import modutils -from pylint.graph import DotBackend -from pylint.pyreverse.pumlutils import PlantUmlPrinter, PumlArrow, PumlItem +from pylint.pyreverse.printer import ( + DotPrinter, + EdgeType, + Layout, + NodeType, + PlantUmlPrinter, + VCGPrinter, +) from pylint.pyreverse.utils import 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): @@ -48,18 +52,22 @@ def write(self, diadefs): self.write_classes(diagram) else: self.write_packages(diagram) - self.close_graph() + self.save() def write_packages(self, diagram): """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() - self.printer.emit_node(obj.fig_id, **self.get_package_properties(obj)) + self.printer.emit_node( + obj.fig_id, type_=NodeType.PACKAGE, **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): @@ -67,16 +75,23 @@ def write_classes(self, diagram): # sorted to get predictable (hence testable) results for obj in sorted(diagram.objects, key=lambda x: x.title): obj.fig_id = obj.node.qname() - self.printer.emit_node(obj.fig_id, **self.get_class_properties(obj)) + type_ = NodeType.INTERFACE if obj.shape == "interface" else NodeType.CLASS + self.printer.emit_node( + obj.fig_id, type_=type_, **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"): @@ -84,7 +99,7 @@ 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): @@ -103,8 +118,8 @@ def get_class_properties(self, obj): """get label and shape for classes.""" raise NotImplementedError - def close_graph(self): - """finalize the graph""" + def save(self): + """write to disk""" raise NotImplementedError @@ -157,21 +172,14 @@ 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) + DiagramWriter.__init__(self, config) ColorMixin.__init__(self, self.config.max_color_depth) def set_printer(self, file_name, basename): """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): @@ -188,9 +196,7 @@ def get_package_properties(self, obj): """get label and shape for packages.""" return dict( label=self.get_title(obj), - shape="box", color=self.get_color(obj) if self.config.colorized else "black", - style=self.get_style(), ) def get_class_properties(self, obj): @@ -199,8 +205,6 @@ def get_class_properties(self, obj): 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: @@ -212,48 +216,23 @@ def get_class_properties(self, obj): label = "{%s}" % label values = dict( label=label, - shape="record", fontcolor="red" if is_exception(obj.node) else "black", - style=self.get_style(), color=self.get_color(obj) if self.config.colorized else "black", ) return values - def close_graph(self): - """print the dot graph into """ + def save(self): + """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): """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): """get project title in vcg format""" @@ -263,7 +242,6 @@ def get_package_properties(self, obj): """get label and shape for packages.""" return dict( label=self.get_title(obj), - shape="box", ) def get_class_properties(self, obj): @@ -275,10 +253,6 @@ def get_class_properties(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] @@ -292,26 +266,19 @@ def get_class_properties(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 dict(label=label) - def close_graph(self): - """close graph and file""" - self.printer.close_graph() - self.graph_file.close() + def save(self): + """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): - styles = [ - dict(type_=PumlArrow.USES), # package edges - dict(type_=PumlArrow.INHERITS), # inheritance edges - dict(type_=PumlArrow.IMPLEMENTS), # implementation edges - dict(type_=PumlArrow.ASSOCIATION), # association edges - ] self.file_name = None - DiagramWriter.__init__(self, config, styles) + DiagramWriter.__init__(self, config) ColorMixin.__init__(self, self.config.max_color_depth) def set_printer(self, file_name, basename): @@ -326,7 +293,6 @@ def get_title(self, obj): def get_package_properties(self, obj): """get label and shape for packages.""" return dict( - type_=PumlItem.PACKAGE, label=obj.title, color=self.get_color(obj) if self.config.colorized else None, ) @@ -344,13 +310,11 @@ def get_class_properties(self, obj): items.append(f'{func.name}({", ".join(args)})') body = "\n".join(items) return dict( - type_=PumlItem.CLASS, label=obj.title, body=body, - stereotype="interface" if obj.shape == "interface" else None, color=self.get_color(obj) if self.config.colorized else None, ) - def close_graph(self): - """finalize the graph""" + def save(self): + """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 feb32f3b77..d2c10d6a64 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -1,6 +1,6 @@ digraph "classes_No_Name" { -charset="utf-8" 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"]; diff --git a/tests/data/classes_colorized.dot b/tests/data/classes_colorized.dot index 3fa52b0a67..550d127c31 100644 --- a/tests/data/classes_colorized.dot +++ b/tests/data/classes_colorized.dot @@ -1,6 +1,6 @@ digraph "classes_colorized" { -charset="utf-8" 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"]; diff --git a/tests/data/classes_vcg.vcg b/tests/data/classes_vcg.vcg index 9419efc0de..d063a85ac4 100644 --- a/tests/data/classes_vcg.vcg +++ b/tests/data/classes_vcg.vcg @@ -25,14 +25,14 @@ graph:{ linestyle:dotted backarrowsize:10 } - edge: {sourcename:"data.suppliermodule_test.DoNothing" targetname:"data.clientmodule_test.Ancestor" label:"cls_member" - arrowstyle:solid + 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" label:"relation" - arrowstyle:solid + 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 bc5c13f3e0..7b145dc910 100644 --- a/tests/data/packages_No_Name.dot +++ b/tests/data/packages_No_Name.dot @@ -1,6 +1,6 @@ digraph "packages_No_Name" { -charset="utf-8" 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"]; diff --git a/tests/data/packages_colorized.dot b/tests/data/packages_colorized.dot index 0f05798041..917fa86f5c 100644 --- a/tests/data/packages_colorized.dot +++ b/tests/data/packages_colorized.dot @@ -1,6 +1,6 @@ digraph "packages_colorized" { -charset="utf-8" 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"]; diff --git a/tests/test_import_graph.py b/tests/test_import_graph.py index 816e78a0ff..5a1d6af24d 100644 --- a/tests/test_import_graph.py +++ b/tests/test_import_graph.py @@ -53,12 +53,12 @@ def test_dependencies_graph(dest): rankdir=LR charset="utf-8" URL="." node[shape="box"] -"hoho" []; -"yep" []; -"labas" []; -"yep" -> "hoho" []; -"hoho" -> "labas" []; -"yep" -> "labas" []; +"hoho" [color="black", shape="box", style="solid"]; +"yep" [color="black", shape="box", style="solid"]; +"labas" [color="black", shape="box", style="solid"]; +"yep" -> "hoho" [arrowhead="open", arrowtail="none"]; +"hoho" -> "labas" [arrowhead="open", arrowtail="none"]; +"yep" -> "labas" [arrowhead="open", arrowtail="none"]; } """.strip() ) From 9ae5c49ecb30248d146e845e28c41c0ca8014d98 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 20 Jun 2021 18:56:56 +0200 Subject: [PATCH 20/32] Refactoring of pyreverse tests - extract common fixtures and functions, move to own directory --- .pre-commit-config.yaml | 2 +- pylint/testutils/pyreverse.py | 16 ++ tests/pyreverse/conftest.py | 54 ++++ tests/{ => pyreverse}/data/__init__.py | 0 tests/{ => pyreverse}/data/ascript | 0 .../{ => pyreverse}/data/classes_No_Name.dot | 0 .../data/classes_colorized.dot | 0 .../data/classes_plantuml.puml | 0 .../data/classes_puml_colorized.puml | 0 tests/{ => pyreverse}/data/classes_vcg.vcg | 0 .../{ => pyreverse}/data/clientmodule_test.py | 18 +- .../{ => pyreverse}/data/packages_No_Name.dot | 0 .../data/packages_colorized.dot | 0 .../data/packages_plantuml.puml | 0 .../data/packages_puml_colorized.puml | 0 tests/{ => pyreverse}/data/packages_vcg.vcg | 0 .../data/suppliermodule_test.py | 5 +- .../test_diadefs.py} | 21 +- .../test_inspector.py} | 4 +- tests/pyreverse/test_writer.py | 142 +++++++++++ tests/unittest_pyreverse_writer.py | 231 ------------------ 21 files changed, 240 insertions(+), 253 deletions(-) create mode 100644 pylint/testutils/pyreverse.py create mode 100644 tests/pyreverse/conftest.py rename tests/{ => pyreverse}/data/__init__.py (100%) rename tests/{ => pyreverse}/data/ascript (100%) rename tests/{ => pyreverse}/data/classes_No_Name.dot (100%) rename tests/{ => pyreverse}/data/classes_colorized.dot (100%) rename tests/{ => pyreverse}/data/classes_plantuml.puml (100%) rename tests/{ => pyreverse}/data/classes_puml_colorized.puml (100%) rename tests/{ => pyreverse}/data/classes_vcg.vcg (100%) rename tests/{ => pyreverse}/data/clientmodule_test.py (60%) rename tests/{ => pyreverse}/data/packages_No_Name.dot (100%) rename tests/{ => pyreverse}/data/packages_colorized.dot (100%) rename tests/{ => pyreverse}/data/packages_plantuml.puml (100%) rename tests/{ => pyreverse}/data/packages_puml_colorized.puml (100%) rename tests/{ => pyreverse}/data/packages_vcg.vcg (100%) rename tests/{ => pyreverse}/data/suppliermodule_test.py (86%) rename tests/{unittest_pyreverse_diadefs.py => pyreverse/test_diadefs.py} (92%) rename tests/{unittest_pyreverse_inspector.py => pyreverse/test_inspector.py} (96%) create mode 100644 tests/pyreverse/test_writer.py delete mode 100644 tests/unittest_pyreverse_writer.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b81ee36fb..66e7adf253 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,7 +78,7 @@ repos: args: ["--ignore-missing-imports", "--scripts-are-modules"] require_serial: true additional_dependencies: [] - exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|tests/data/|doc/|bin/ + exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|conftest.py|doc/|bin/ - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.3.0 hooks: 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/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/data/ascript b/tests/pyreverse/data/ascript similarity index 100% rename from tests/data/ascript rename to tests/pyreverse/data/ascript diff --git a/tests/data/classes_No_Name.dot b/tests/pyreverse/data/classes_No_Name.dot similarity index 100% rename from tests/data/classes_No_Name.dot rename to tests/pyreverse/data/classes_No_Name.dot diff --git a/tests/data/classes_colorized.dot b/tests/pyreverse/data/classes_colorized.dot similarity index 100% rename from tests/data/classes_colorized.dot rename to tests/pyreverse/data/classes_colorized.dot diff --git a/tests/data/classes_plantuml.puml b/tests/pyreverse/data/classes_plantuml.puml similarity index 100% rename from tests/data/classes_plantuml.puml rename to tests/pyreverse/data/classes_plantuml.puml diff --git a/tests/data/classes_puml_colorized.puml b/tests/pyreverse/data/classes_puml_colorized.puml similarity index 100% rename from tests/data/classes_puml_colorized.puml rename to tests/pyreverse/data/classes_puml_colorized.puml diff --git a/tests/data/classes_vcg.vcg b/tests/pyreverse/data/classes_vcg.vcg similarity index 100% rename from tests/data/classes_vcg.vcg rename to tests/pyreverse/data/classes_vcg.vcg diff --git a/tests/data/clientmodule_test.py b/tests/pyreverse/data/clientmodule_test.py similarity index 60% rename from tests/data/clientmodule_test.py rename to tests/pyreverse/data/clientmodule_test.py index 40db2e77ef..f6a404238b 100644 --- a/tests/data/clientmodule_test.py +++ b/tests/pyreverse/data/clientmodule_test.py @@ -1,27 +1,29 @@ """ 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/data/packages_No_Name.dot b/tests/pyreverse/data/packages_No_Name.dot similarity index 100% rename from tests/data/packages_No_Name.dot rename to tests/pyreverse/data/packages_No_Name.dot diff --git a/tests/data/packages_colorized.dot b/tests/pyreverse/data/packages_colorized.dot similarity index 100% rename from tests/data/packages_colorized.dot rename to tests/pyreverse/data/packages_colorized.dot diff --git a/tests/data/packages_plantuml.puml b/tests/pyreverse/data/packages_plantuml.puml similarity index 100% rename from tests/data/packages_plantuml.puml rename to tests/pyreverse/data/packages_plantuml.puml diff --git a/tests/data/packages_puml_colorized.puml b/tests/pyreverse/data/packages_puml_colorized.puml similarity index 100% rename from tests/data/packages_puml_colorized.puml rename to tests/pyreverse/data/packages_puml_colorized.puml diff --git a/tests/data/packages_vcg.vcg b/tests/pyreverse/data/packages_vcg.vcg similarity index 100% rename from tests/data/packages_vcg.vcg rename to tests/pyreverse/data/packages_vcg.vcg 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 7809654109..667a9db627 100644 --- a/tests/unittest_pyreverse_diadefs.py +++ b/tests/pyreverse/test_diadefs.py @@ -10,18 +10,18 @@ # Copyright (c) 2020 hippo91 # Copyright (c) 2020 Damien Baty # Copyright (c) 2020 Anthony Sottile +# 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 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 _DEFAULTS, Config, get_project from pylint.pyreverse.diadefslib import ( ClassDiadefGenerator, @@ -30,6 +30,7 @@ DiadefsHandler, ) from pylint.pyreverse.inspector import Linker +from pylint.testutils.pyreverse import get_project def _process_classes(classes): @@ -48,8 +49,8 @@ def _process_relations(relations): @pytest.fixture -def HANDLER(): - return DiadefsHandler(Config(_DEFAULTS)) +def HANDLER(default_config): + return DiadefsHandler(default_config) @pytest.fixture(scope="module") @@ -57,10 +58,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(_DEFAULTS) + cl_config = default_config cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) assert df_h._get_levels() == (0, 0) @@ -74,9 +75,9 @@ def test_option_values(HANDLER, PROJECT): hndl._set_default_options() assert hndl._get_levels() == (-1, -1) assert hndl.module_names - handler = DiadefsHandler(Config(_DEFAULTS)) + handler = DiadefsHandler(default_config) df_h = DiaDefGenerator(Linker(PROJECT), handler) - cl_config = Config(_DEFAULTS) + cl_config = default_config cl_config.classes = ["Specialization"] cl_h = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(cl_config)) for hndl in [df_h, cl_h]: @@ -108,13 +109,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(_DEFAULTS)) + 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_writer.py b/tests/pyreverse/test_writer.py new file mode 100644 index 0000000000..e101f0a3cd --- /dev/null +++ b/tests/pyreverse/test_writer.py @@ -0,0 +1,142 @@ +# 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 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.utils import get_visibility +from pylint.pyreverse.writer import DotWriter, PlantUmlWriter, VCGWriter +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( + "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}" diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py deleted file mode 100644 index 63063eb2c2..0000000000 --- a/tests/unittest_pyreverse_writer.py +++ /dev/null @@ -1,231 +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 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, project_from_files -from pylint.pyreverse.utils import get_visibility -from pylint.pyreverse.writer import DotWriter, PlantUmlWriter, VCGWriter - -_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": "", - "colorized": False, - "max_color_depth": 2, -} - -_VCG_OUTPUT = { - "all_ancestors": None, - "show_associated": None, - "module_names": None, - "output_format": "vcg", - "diadefs_file": None, - "quiet": 0, - "show_ancestors": None, - "classes": (), - "all_associated": None, - "mode": "PUB_ONLY", - "show_builtin": False, - "only_classnames": False, - "output_directory": "", - "colorized": False, - "max_color_depth": 2, -} - -_PUML_OUTPUT = { - "all_ancestors": None, - "show_associated": None, - "module_names": None, - "output_format": "puml", - "diadefs_file": None, - "quiet": 0, - "show_ancestors": None, - "classes": (), - "all_associated": None, - "mode": "PUB_ONLY", - "show_builtin": False, - "only_classnames": False, - "output_directory": "", - "colorized": False, - "max_color_depth": 2, -} - -_COLORIZED_PUML_OUTPUT = { - "all_ancestors": None, - "show_associated": None, - "module_names": None, - "output_format": "puml", - "diadefs_file": None, - "quiet": 0, - "show_ancestors": None, - "classes": (), - "all_associated": None, - "mode": "PUB_ONLY", - "show_builtin": False, - "only_classnames": False, - "output_directory": "", - "colorized": True, - "max_color_depth": 2, -} - -_COLORIZED = { - "all_ancestors": True, - "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": "", - "colorized": True, - "max_color_depth": 2, -} - - -class Config: - """config object for tests""" - - def __init__(self, config): - for attr, value in config.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"] -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") -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.usefixtures("cleanup") -@pytest.mark.parametrize( - "config, name, generated_files", - [ - (Config(_DEFAULTS), "No Name", DOT_FILES), - (Config(_COLORIZED), "colorized", COLORIZED_DOT_FILES), - (Config(_VCG_OUTPUT), "vcg", VCG_FILES), - (Config(_PUML_OUTPUT), "plantuml", PUML_FILES), - (Config(_COLORIZED_PUML_OUTPUT), "puml_colorized", COLORIZED_PUML_FILES), - ], -) -def test_generated_files(config, name, generated_files): - _create_files(config, name) - for generated_file in generated_files: - 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( - "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}" From 168faebf8c14eede5d93bf321f9b61dded40adf4 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 20 Jun 2021 22:18:18 +0200 Subject: [PATCH 21/32] Exclude all 'data' subdirectories under 'tests' for pre-commit hooks --- .pre-commit-config.yaml | 4 ++-- tests/pyreverse/data/ascript | 2 -- tests/pyreverse/data/clientmodule_test.py | 1 + tests/test_self.py | 8 ++++---- 4 files changed, 7 insertions(+), 8 deletions(-) delete mode 100755 tests/pyreverse/data/ascript diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66e7adf253..95e52f01a1 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/extensions/data|tests/regrtest_data/|tests(/.*)*/data/|doc/ - id: fix-documentation name: Fix documentation entry: python3 -m script.fix_documentation diff --git a/tests/pyreverse/data/ascript b/tests/pyreverse/data/ascript deleted file mode 100755 index f401ebc4bc..0000000000 --- a/tests/pyreverse/data/ascript +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/python -"""ttttttttttttttttttttoooooooooooooooooooooooooooooooooooooooooooooooooooooo lllllllllllooooooooooooooooooooooooooooonnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnngggggggggggggggggg""" diff --git a/tests/pyreverse/data/clientmodule_test.py b/tests/pyreverse/data/clientmodule_test.py index f6a404238b..5e7de79b38 100644 --- a/tests/pyreverse/data/clientmodule_test.py +++ b/tests/pyreverse/data/clientmodule_test.py @@ -9,6 +9,7 @@ class Ancestor: cls_member = DoNothing() def __init__(self, value): + local_variable = 0 self.attr = "this method shouldn't have a docstring" self.__value = value diff --git a/tests/test_self.py b/tests/test_self.py index c5653457a7..e86dfeea21 100644 --- a/tests/test_self.py +++ b/tests/test_self.py @@ -317,13 +317,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( From a933c2c10814bd6cf5ff7722f79b68f0991b50a2 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 20 Jun 2021 22:20:00 +0200 Subject: [PATCH 22/32] Move test file back to where it belongs --- tests/data/ascript | 2 ++ 1 file changed, 2 insertions(+) create mode 100755 tests/data/ascript diff --git a/tests/data/ascript b/tests/data/ascript new file mode 100755 index 0000000000..f401ebc4bc --- /dev/null +++ b/tests/data/ascript @@ -0,0 +1,2 @@ +#!/usr/bin/python +"""ttttttttttttttttttttoooooooooooooooooooooooooooooooooooooooooooooooooooooo lllllllllllooooooooooooooooooooooooooooonnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnngggggggggggggggggg""" From eb917e31469b6571c4b4140534b226cc68d58605 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Tue, 22 Jun 2021 06:57:43 +0200 Subject: [PATCH 23/32] Exclude 'data' subdirectories under test, as they may contain deliberately 'wrong' code. Check top level ``conftest.py`` but ignore remaining ones, as this raises mypy errors for duplicate modules. --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95e52f01a1..150e6915ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: ["--ignore-missing-imports", "--scripts-are-modules"] require_serial: true additional_dependencies: [] - exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|conftest.py|doc/|bin/ + exclude: tests/functional/|tests/input|tests(/.*)*/data|tests/regrtest_data/|tests(/.*)+/conftest.py|doc/|bin/ - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.3.0 hooks: From 7e0cba8e6145de948207c41ddf5bbe7cdb204e4b Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 26 Jun 2021 21:04:47 +0200 Subject: [PATCH 24/32] Improve typing by replacing dictionaries with NamedTuple, add type hints and introduce proper subclasses for different kinds of diagram entities. --- pylint/pyreverse/diagrams.py | 22 ++++++++++- pylint/pyreverse/printer.py | 65 ++++++++++++++++++------------ pylint/pyreverse/writer.py | 76 ++++++++++++++++++++---------------- tests/test_import_graph.py | 6 +-- 4 files changed, 106 insertions(+), 63 deletions(-) diff --git a/pylint/pyreverse/diagrams.py b/pylint/pyreverse/diagrams.py index bf9f67af66..c68b071380 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/printer.py b/pylint/pyreverse/printer.py index 779bff35ee..2834dbfcc2 100644 --- a/pylint/pyreverse/printer.py +++ b/pylint/pyreverse/printer.py @@ -14,7 +14,17 @@ import sys import tempfile from enum import Enum -from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple, Type +from typing import ( + Any, + Dict, + FrozenSet, + List, + Mapping, + NamedTuple, + Optional, + Tuple, + Type, +) class NodeType(Enum): @@ -37,6 +47,13 @@ class Layout(Enum): 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""" @@ -67,10 +84,7 @@ def emit_node( self, name: str, type_: NodeType, - label: Optional[str] = None, - body: Optional[str] = None, - color: Optional[str] = None, - fontcolor: Optional[str] = None, + properties: Optional[NodeProperties] = None, ) -> None: """Create a new node. Nodes can be classes, packages, participants etc.""" raise NotImplementedError @@ -130,20 +144,18 @@ def emit_node( self, name: str, type_: NodeType, - label: Optional[str] = None, - body: Optional[str] = None, - color: Optional[str] = None, - fontcolor: Optional[str] = None, + 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" #{color}" if color else "" - body = body if body else "" - if label is None: - label = name - if fontcolor: - label = f"{label}" + 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( @@ -360,15 +372,13 @@ def emit_node( self, name: str, type_: NodeType, - label: Optional[str] = None, - body: Optional[str] = None, - color: Optional[str] = None, - fontcolor: Optional[str] = None, + 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) - if label is None: - label = name + label = properties.label if properties.label is not None else name self._write_attributes( self.NODE_ATTRS, label=label, @@ -472,20 +482,23 @@ def emit_node( self, name: str, type_: NodeType, - label: Optional[str] = None, - body: Optional[str] = None, - color: Optional[str] = "black", - fontcolor: Optional[str] = None, + 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="{fontcolor}"' if fontcolor else "" + 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}"];' ) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index ca83fac614..5d3c780d7e 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -22,10 +22,18 @@ 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, @@ -54,13 +62,15 @@ def write(self, diadefs): self.write_packages(diagram) 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 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, **self.get_package_properties(obj) + obj.fig_id, + type_=NodeType.PACKAGE, + properties=self.get_package_properties(obj), ) # package dependencies for rel in diagram.get_relationships("depends"): @@ -70,14 +80,14 @@ def write_packages(self, diagram): 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 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_, **self.get_class_properties(obj) + obj.fig_id, type_=type_, properties=self.get_class_properties(obj) ) # inheritance links for rel in diagram.get_relationships("specialization"): @@ -102,23 +112,23 @@ def write_classes(self, diagram): 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_package_properties(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: """get label and shape for packages.""" raise NotImplementedError - def get_class_properties(self, obj): + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes.""" raise NotImplementedError - def save(self): + def save(self) -> None: """write to disk""" raise NotImplementedError @@ -151,7 +161,7 @@ def __init__(self, depth): ) self.used_colors = {} - def get_color(self, obj): + 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]): @@ -175,31 +185,31 @@ def __init__(self, config): DiagramWriter.__init__(self, config) ColorMixin.__init__(self, self.config.max_color_depth) - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """initialize DotWriter and add options for 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_style(self): + def get_style(self) -> str: """get style of object""" if not self.config.colorized: return "solid" return "filled" - def get_package_properties(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: """get label and shape for packages.""" - return dict( + return NodeProperties( label=self.get_title(obj), color=self.get_color(obj) if self.config.colorized else "black", ) - def get_class_properties(self, obj): + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. The label contains all attributes and methods @@ -214,14 +224,14 @@ def get_class_properties(self, obj): args = [] label = r"{}{}({})\l".format(label, func.name, ", ".join(args)) label = "{%s}" % label - values = dict( + 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 values + return properties - def save(self): + def save(self) -> None: """write to disk""" self.printer.generate(self.file_name) @@ -229,22 +239,22 @@ def save(self): class VCGWriter(DiagramWriter): """write vcg graphs from a diagram definition and a project""" - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """initialize VCGWriter for a UML graph""" 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_package_properties(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: """get label and shape for packages.""" - return dict( + return NodeProperties( label=self.get_title(obj), ) - def get_class_properties(self, obj): + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. The label contains all attributes and methods @@ -266,9 +276,9 @@ def get_class_properties(self, obj): label = fr"{label}\n\f{line}" for func in methods: label = fr"{label}\n\f10{func}()" - return dict(label=label) + return NodeProperties(label=label) - def save(self): + def save(self) -> None: """write to disk""" self.printer.generate(self.file_name) @@ -281,23 +291,23 @@ def __init__(self, config): DiagramWriter.__init__(self, config) ColorMixin.__init__(self, self.config.max_color_depth) - def set_printer(self, file_name, basename): + 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): + def get_title(self, obj: DiagramEntity) -> str: """get project title""" return obj.title - def get_package_properties(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: """get label and shape for packages.""" - return dict( + return NodeProperties( label=obj.title, color=self.get_color(obj) if self.config.colorized else None, ) - def get_class_properties(self, obj): + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes.""" body = "" if not self.config.only_classnames: @@ -309,12 +319,12 @@ def get_class_properties(self, obj): args = [] items.append(f'{func.name}({", ".join(args)})') body = "\n".join(items) - return dict( + return NodeProperties( label=obj.title, body=body, color=self.get_color(obj) if self.config.colorized else None, ) - def save(self): + def save(self) -> None: """write to disk""" self.printer.generate(self.file_name) diff --git a/tests/test_import_graph.py b/tests/test_import_graph.py index 5a1d6af24d..7f36339bf4 100644 --- a/tests/test_import_graph.py +++ b/tests/test_import_graph.py @@ -53,9 +53,9 @@ def test_dependencies_graph(dest): rankdir=LR charset="utf-8" URL="." node[shape="box"] -"hoho" [color="black", shape="box", style="solid"]; -"yep" [color="black", shape="box", style="solid"]; -"labas" [color="black", shape="box", style="solid"]; +"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"]; From db5f5071b7ae76c9737b6bfe371c900f887f2894 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 26 Jun 2021 22:15:25 +0200 Subject: [PATCH 25/32] Use factory functions to obtain a ``Printer`` or ``DiagramWriter`` class suitable for the given output format --- pylint/checkers/imports.py | 9 +++++++-- pylint/pyreverse/printer.py | 17 +++++------------ pylint/pyreverse/utils.py | 12 ++++++++++++ pylint/pyreverse/writer.py | 17 ++++++++++++++++- tests/test_import_graph.py | 24 ++++++++++++++---------- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py index 6f6c772a29..5354a6bb72 100644 --- a/pylint/checkers/imports.py +++ b/pylint/checkers/imports.py @@ -62,7 +62,12 @@ ) from pylint.exceptions import EmptyReportError from pylint.interfaces import IAstroidChecker -from pylint.pyreverse.printer import DotPrinter, EdgeType, Layout, NodeType +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 @@ -195,7 +200,7 @@ def _repr_tree_defs(data, indent_str=None): def _dependencies_graph(filename: str, dep_info: Dict[str, List[str]]) -> None: """write dependencies as a dot (graphviz) file""" done = {} - printer = DotPrinter( + printer = get_printer_for_filetype(filename)( os.path.splitext(os.path.basename(filename))[0], layout=Layout.LEFT_TO_RIGHT ) printer.emit('URL="." node[shape="box"]') diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py index 2834dbfcc2..6337fd1e21 100644 --- a/pylint/pyreverse/printer.py +++ b/pylint/pyreverse/printer.py @@ -26,6 +26,8 @@ Type, ) +from pylint.pyreverse.utils import get_file_extension + class NodeType(Enum): CLASS = "class" @@ -528,7 +530,7 @@ def generate(self, outputfile: str) -> None: os.close(pdot) os.close(ppng) else: - _, _, target = target_info_from_filename(outputfile) + target = get_file_extension(outputfile) if not target: target = "png" outputfile = outputfile + "." + target @@ -561,19 +563,10 @@ def _close_graph(self) -> None: 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 = target_info_from_filename(filename_or_extension) + extension = get_file_extension(filename_or_extension) printer_mapping = { "dot": DotPrinter, "vcg": VCGPrinter, "puml": PlantUmlPrinter, } - # will probably fail if just the extension is passed into! - return printer_mapping.get(extension, default=DotPrinter) - - -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 + return printer_mapping.get(extension, DotPrinter) diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 1f4a65e5be..29898d8b89 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("."): + 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 d18fcc4c24..12ddef5b5c 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -18,6 +18,7 @@ import itertools import os +from typing import Type import astroid from astroid import modutils @@ -38,7 +39,11 @@ PlantUmlPrinter, VCGPrinter, ) -from pylint.pyreverse.utils import get_annotation_label, is_exception +from pylint.pyreverse.utils import ( + get_annotation_label, + get_file_extension, + is_exception, +) class DiagramWriter: @@ -348,3 +353,13 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties: def save(self) -> None: """write to disk""" self.printer.generate(self.file_name) + + +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/tests/test_import_graph.py b/tests/test_import_graph.py index 81e94d1741..9d568a6238 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 @@ -85,26 +86,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) From bb227a1263163088a9e792d22474682801b019c8 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 26 Jun 2021 22:37:17 +0200 Subject: [PATCH 26/32] Emitting the "URL" attribute breaks output for VCG and PlantUML, but is also not necessary anymore. The "shape" is now automatically set by the ``Printer`` classes. --- pylint/checkers/imports.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py index 5354a6bb72..45c6a9c3d3 100644 --- a/pylint/checkers/imports.py +++ b/pylint/checkers/imports.py @@ -203,7 +203,6 @@ def _dependencies_graph(filename: str, dep_info: Dict[str, List[str]]) -> None: printer = get_printer_for_filetype(filename)( os.path.splitext(os.path.basename(filename))[0], layout=Layout.LEFT_TO_RIGHT ) - printer.emit('URL="." node[shape="box"]') for modname, dependencies in sorted(dep_info.items()): done[modname] = 1 printer.emit_node(modname, type_=NodeType.PACKAGE) From 7eeaa503834eb42ce0a50e1bbeaae046bdaaac42 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 26 Jun 2021 22:44:32 +0200 Subject: [PATCH 27/32] Add changelog and whatsnew entries --- ChangeLog | 8 ++++++++ doc/whatsnew/2.9.rst | 4 ++++ 2 files changed, 12 insertions(+) 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 a8036d8bf6..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. From 7375104cfb1a0cd9e0dbe7d329c24dc558244d83 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sat, 26 Jun 2021 22:46:43 +0200 Subject: [PATCH 28/32] Fix unit test after removing the URL attribute from import-graph --- tests/test_import_graph.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_import_graph.py b/tests/test_import_graph.py index 9d568a6238..776d832245 100644 --- a/tests/test_import_graph.py +++ b/tests/test_import_graph.py @@ -54,7 +54,6 @@ def test_dependencies_graph(dest): digraph "foo" { rankdir=LR charset="utf-8" -URL="." node[shape="box"] "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"]; From 36847b8e594a614b84f139475ba084fb91b12677 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 27 Jun 2021 11:11:17 +0200 Subject: [PATCH 29/32] Remove dead code --- pylint/pyreverse/writer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 12ddef5b5c..05b37962ca 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -201,12 +201,6 @@ def get_title(self, obj: DiagramEntity) -> str: """get project title""" return obj.title - def get_style(self) -> str: - """get style of object""" - if not self.config.colorized: - return "solid" - return "filled" - def get_package_properties(self, obj: PackageEntity) -> NodeProperties: """get label and shape for packages.""" return NodeProperties( From 7938bf155f6b01aa5bad8996f507e8c6e7b5c3d9 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 27 Jun 2021 12:06:19 +0200 Subject: [PATCH 30/32] Refactor tests and add new ones to increase coverage for new code --- pylint/pyreverse/utils.py | 2 +- tests/pyreverse/test_printer.py | 67 ++++++++++++++++++ tests/pyreverse/test_utils.py | 118 ++++++++++++++++++++++++++++++++ tests/pyreverse/test_writer.py | 100 ++++----------------------- 4 files changed, 201 insertions(+), 86 deletions(-) create mode 100644 tests/pyreverse/test_printer.py create mode 100644 tests/pyreverse/test_utils.py diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 29898d8b89..7e4e906560 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -281,7 +281,7 @@ def get_file_extension(filename_or_extension: str) -> str: 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("."): + 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/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 index 2bfb73a61e..f859f5f0b9 100644 --- a/tests/pyreverse/test_writer.py +++ b/tests/pyreverse/test_writer.py @@ -10,6 +10,7 @@ # 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 @@ -22,15 +23,17 @@ 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 -from pylint.pyreverse.utils import get_annotation, get_visibility, infer_node -from pylint.pyreverse.writer import DotWriter, PlantUmlWriter, VCGWriter +from pylint.pyreverse.writer import ( + DotWriter, + PlantUmlWriter, + VCGWriter, + get_writer_for_filetype, +) from pylint.testutils.pyreverse import get_project @@ -127,87 +130,14 @@ def _check_file(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", + "filename, expected_writer_class", [ - ("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]"), + ("dot", DotWriter), + ("png", DotWriter), + ("vcg", VCGWriter), + ("puml", PlantUmlWriter), ], ) -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 +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 From 14cd177545689106f23aab3abf07d9b0dfbd845c Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 27 Jun 2021 12:19:09 +0200 Subject: [PATCH 31/32] Use factory function to get the corresponding writer for the desired output format (Open-Closed-Principle). --- pylint/pyreverse/main.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index a7b8d9da59..9efb0eb6b4 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -25,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 = ( ( @@ -239,12 +239,8 @@ def run(self, args): finally: sys.path.pop(0) - if self.config.output_format == "vcg": - writer.VCGWriter(self.config).write(diadefs) - elif self.config.output_format == "puml": - writer.PlantUmlWriter(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 From 49ba34e11720af7727b42d862f966aace8916362 Mon Sep 17 00:00:00 2001 From: DudeNr33 <3929834+DudeNr33@users.noreply.github.com> Date: Sun, 27 Jun 2021 14:40:20 +0200 Subject: [PATCH 32/32] Fix expected output message --- tests/test_import_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_import_graph.py b/tests/test_import_graph.py index 776d832245..4d7130f8ee 100644 --- a/tests/test_import_graph.py +++ b/tests/test_import_graph.py @@ -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"]})