diff --git a/ChangeLog b/ChangeLog index d75360773e..94680700e1 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 25e7d0da2a..ff5c161853 100644 --- a/doc/whatsnew/2.9.rst +++ b/doc/whatsnew/2.9.rst @@ -56,4 +56,6 @@ Other Changes * ``ignore-paths`` configuration directive has been added. Defined regex patterns are matched against file path. +* ``pyreverse`` now supports automatic coloring of package and class diagrams for .dot files by passing the ``--colorized`` option. + * Added handling of floating point values when parsing configuration from pyproject.toml diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index a62a1c2e7f..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", ), ), ( @@ -138,37 +138,56 @@ help="create a *. output file if format available.", ), ), + ( + "colorized", + dict( + dest="colorized", + action="store_true", + default=False, + help="Use colored output. Classes/modules of the same package get the same color.", + ), + ), + ( + "max-color-depth", + dict( + dest="max_color_depth", + action="store", + default=2, + metavar="", + type="int", + help="Use separate colors up to package depth of ", + ), + ), ( "ignore", - { - "type": "csv", - "metavar": "", - "dest": "ignore_list", - "default": ("CVS",), - "help": "Files or directories to be skipped. They " - "should be base names, not paths.", - }, + dict( + type="csv", + metavar="", + dest="ignore_list", + default=("CVS",), + help="Files or directories to be skipped. They should be base names, not paths.", + ), ), ( "project", - { - "default": "", - "type": "string", - "short": "p", - "metavar": "", - "help": "set the project name.", - }, + dict( + default="", + type="string", + short="p", + metavar="", + help="set the project name.", + ), ), ( "output-directory", - { - "default": "", - "type": "string", - "short": "d", - "action": "store", - "metavar": "", - "help": "set the output directory path.", - }, + dict( + default="", + type="string", + short="d", + action="store", + metavar="", + help="set the output directory path.", + ), ), ) diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index 0d80bb9ca6..62863dc1d0 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -16,8 +16,12 @@ """Utilities for creating VCG and Dot diagrams""" +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 @@ -49,7 +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") + self.printer.emit_node(i, **self.get_package_properties(obj)) obj.fig_id = i # package dependencies for rel in diagram.get_relationships("depends"): @@ -61,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"): @@ -90,7 +94,11 @@ def get_title(self, obj): """get project title""" raise NotImplementedError - def get_values(self, obj): + def get_package_properties(self, obj): + """get label and shape for packages.""" + raise NotImplementedError + + def get_class_properties(self, obj): """get label and shape for classes.""" raise NotImplementedError @@ -111,6 +119,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,7 +153,41 @@ def get_title(self, obj): """get project title""" return obj.title - def get_values(self, obj): + 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""" + 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), + style=self.get_style(), + ) + + def get_class_properties(self, obj): """get label and shape for classes. The label contains all attributes and methods @@ -140,9 +204,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 """ @@ -184,7 +253,14 @@ def get_title(self, obj): """get project title in vcg format""" return r"\fb%s\fn" % obj.title - 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", + ) + + def get_class_properties(self, obj): """get label and shape for classes. The label contains all attributes and methods 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 new file mode 100644 index 0000000000..b815001b92 --- /dev/null +++ b/tests/data/classes_colorized.dot @@ -0,0 +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"]; +} diff --git a/tests/data/classes_vcg.vcg b/tests/data/classes_vcg.vcg new file mode 100644 index 0000000000..101d791c58 --- /dev/null +++ b/tests/data/classes_vcg.vcg @@ -0,0 +1,38 @@ +graph:{ + title:"classes_vcg" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + node: {title:"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:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08top : str\n\f________________" + shape:box +} + edge: {sourcename:"3" 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:"3" 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 1ceeb72427..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" [label="data", shape="box"]; -"1" [label="data.clientmodule_test", shape="box"]; -"2" [label="data.suppliermodule_test", shape="box"]; +"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/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"]; +} 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 8eb333fad1..7809654109 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) diff --git a/tests/unittest_pyreverse_writer.py b/tests/unittest_pyreverse_writer.py index 98bb014881..e69e5840ca 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 """ @@ -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, @@ -44,14 +44,52 @@ "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, +} + +_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): - for attr, value in _DEFAULTS.items(): + def __init__(self, config): + for attr, value in config.items(): setattr(self, attr, value) @@ -79,41 +117,56 @@ 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"] -@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) + 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: + for fname in DOT_FILES + COLORIZED_DOT_FILES + VCG_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), + (Config(_VCG_OUTPUT), "vcg", VCG_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(