Skip to content

Commit

Permalink
pyreverse: Add option for colored output (#4850)
Browse files Browse the repository at this point in the history
* Add option to produce colored output from ``pyreverse``

* Use indentation in PlantUML diagrams

Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
  • Loading branch information
DudeNr33 and Pierre-Sassoulas committed Aug 15, 2021
1 parent e54df78 commit 14c0075
Show file tree
Hide file tree
Showing 16 changed files with 281 additions and 86 deletions.
6 changes: 5 additions & 1 deletion ChangeLog
Expand Up @@ -12,7 +12,11 @@ Release date: TBA
..
Put bug fixes that should not wait for a new minor version here

+ pyreverse: add output in PlantUML format.
* pyreverse: add option to produce colored output.

Closes #4488

* pyreverse: add output in PlantUML format.

Closes #4498

Expand Down
2 changes: 2 additions & 0 deletions doc/whatsnew/2.10.rst
Expand Up @@ -44,6 +44,8 @@ Extensions
Other Changes
=============

* pyreverse now permit to produce colored generated diagram by using the ``colorized`` option.

* Pyreverse - add output in PlantUML format

* pylint does not crash with a traceback anymore when a file is problematic. It
Expand Down
8 changes: 5 additions & 3 deletions pylint/pyreverse/dot_printer.py
Expand Up @@ -35,6 +35,8 @@


class DotPrinter(Printer):
DEFAULT_COLOR = "black"

def __init__(
self,
title: str,
Expand All @@ -43,7 +45,6 @@ def __init__(
):
layout = layout or Layout.BOTTOM_TO_TOP
self.charset = "utf-8"
self.node_style = "solid"
super().__init__(title, layout, use_automatic_namespace)

def _open_graph(self) -> None:
Expand All @@ -67,7 +68,8 @@ def emit_node(
if properties is None:
properties = NodeProperties(label=name)
shape = SHAPES[type_]
color = properties.color if properties.color is not None else "black"
color = properties.color if properties.color is not None else self.DEFAULT_COLOR
style = "filled" if color != self.DEFAULT_COLOR else "solid"
label = self._build_label_for_node(properties)
if label:
label_part = f', label="{label}"'
Expand All @@ -77,7 +79,7 @@ def emit_node(
f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else ""
)
self.emit(
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];'
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{style}"];'
)

def _build_label_for_node(
Expand Down
69 changes: 44 additions & 25 deletions pylint/pyreverse/main.py
Expand Up @@ -11,6 +11,7 @@
# Copyright (c) 2020 hippo91 <guillaume.peillex@gmail.com>
# Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com>
# Copyright (c) 2021 Mark Byrne <31762852+mbyrnepr2@users.noreply.github.com>
# Copyright (c) 2021 Andreas Finkler <andi.finkler@gmail.com>

# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
Expand Down Expand Up @@ -124,8 +125,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",
),
),
(
Expand All @@ -139,37 +139,56 @@
help="create a *.<format> 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="<depth>",
type="int",
help="Use separate colors up to package depth of <depth>",
),
),
(
"ignore",
{
"type": "csv",
"metavar": "<file[,file...]>",
"dest": "ignore_list",
"default": ("CVS",),
"help": "Files or directories to be skipped. They "
"should be base names, not paths.",
},
dict(
type="csv",
metavar="<file[,file...]>",
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": "<project name>",
"help": "set the project name.",
},
dict(
default="",
type="string",
short="p",
metavar="<project name>",
help="set the project name.",
),
),
(
"output-directory",
{
"default": "",
"type": "string",
"short": "d",
"action": "store",
"metavar": "<output_directory>",
"help": "set the output directory path.",
},
dict(
default="",
type="string",
short="d",
action="store",
metavar="<output_directory>",
help="set the output directory path.",
),
),
)

Expand Down
17 changes: 11 additions & 6 deletions pylint/pyreverse/plantuml_printer.py
Expand Up @@ -59,20 +59,25 @@ def emit_node(
color = f" #{properties.color}"
else:
color = ""
body = ""
body = []
if properties.attrs:
body += "\n".join(properties.attrs)
body.extend(properties.attrs)
if properties.methods:
body += "\n"
for func in properties.methods:
args = self._get_method_arguments(func)
body += f"\n{func.name}({', '.join(args)})"
line = f"{func.name}({', '.join(args)})"
if func.returns:
body += " -> " + get_annotation_label(func.returns)
line += " -> " + get_annotation_label(func.returns)
body.append(line)
label = properties.label if properties.label is not None else name
if properties.fontcolor and properties.fontcolor != self.DEFAULT_COLOR:
label = f"<color:{properties.fontcolor}>{label}</color>"
self.emit(f'{nodetype} "{label}" as {name}{stereotype}{color} {{\n{body}\n}}')
self.emit(f'{nodetype} "{label}" as {name}{stereotype}{color} {{')
self._inc_indent()
for line in body:
self.emit(line)
self._dec_indent()
self.emit("}")

def emit_edge(
self,
Expand Down
11 changes: 10 additions & 1 deletion pylint/pyreverse/printer.py
Expand Up @@ -56,16 +56,25 @@ def __init__(
self.layout = layout
self.use_automatic_namespace = use_automatic_namespace
self.lines: List[str] = []
self._indent = ""
self._open_graph()

def _inc_indent(self):
"""increment indentation"""
self._indent += " "

def _dec_indent(self):
"""decrement indentation"""
self._indent = self._indent[:-2]

@abstractmethod
def _open_graph(self) -> None:
"""Emit the header lines, i.e. all boilerplate code that defines things like layout etc."""

def emit(self, line: str, force_newline: Optional[bool] = True) -> None:
if force_newline and not line.endswith("\n"):
line += "\n"
self.lines.append(line)
self.lines.append(self._indent + line)

@abstractmethod
def emit_node(
Expand Down
31 changes: 7 additions & 24 deletions pylint/pyreverse/vcg_printer.py
Expand Up @@ -185,18 +185,9 @@


class VCGPrinter(Printer):
def __init__(
self,
title: str,
layout: Optional[Layout] = None,
use_automatic_namespace: Optional[bool] = None,
):
self._indent = ""
super().__init__(title, layout, use_automatic_namespace)

def _open_graph(self) -> None:
"""Emit the header lines"""
self.emit(f"{self._indent}graph:{{\n")
self.emit("graph:{\n")
self._inc_indent()
self._write_attributes(
GRAPH_ATTRS,
Expand All @@ -212,7 +203,7 @@ def _open_graph(self) -> None:
def _close_graph(self) -> None:
"""Emit the lines needed to properly close the graph."""
self._dec_indent()
self.emit(f"{self._indent}}}")
self.emit("}")

def emit_node(
self,
Expand All @@ -225,7 +216,7 @@ def emit_node(
properties = NodeProperties(label=name)
elif properties.label is None:
properties.label = name
self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False)
self.emit(f'node: {{title:"{name}"', force_newline=False)
self._write_attributes(
NODE_ATTRS,
label=self._build_label_for_node(properties),
Expand Down Expand Up @@ -264,7 +255,7 @@ def emit_edge(
) -> None:
"""Create an edge from one node to another to display relationships."""
self.emit(
f'{self._indent}edge: {{sourcename:"{from_node}" targetname:"{to_node}"',
f'edge: {{sourcename:"{from_node}" targetname:"{to_node}"',
force_newline=False,
)
attributes = ARROWS[type_]
Expand All @@ -287,20 +278,12 @@ def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None:
) from e

if not _type:
self.emit(f'{self._indent}{key}:"{value}"\n')
self.emit(f'{key}:"{value}"\n')
elif _type == 1:
self.emit(f"{self._indent}{key}:{int(value)}\n")
self.emit(f"{key}:{int(value)}\n")
elif value in _type:
self.emit(f"{self._indent}{key}:{value}\n")
self.emit(f"{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 += " "

def _dec_indent(self):
"""decrement indentation"""
self._indent = self._indent[:-2]
51 changes: 47 additions & 4 deletions pylint/pyreverse/writer.py
Expand Up @@ -17,11 +17,16 @@

"""Utilities for creating VCG and Dot diagrams"""

import itertools
import os

import astroid
from astroid import modutils

from pylint.pyreverse.diagrams import (
ClassDiagram,
ClassEntity,
DiagramEntity,
PackageDiagram,
PackageEntity,
)
Expand All @@ -38,6 +43,29 @@ def __init__(self, config):
self.printer_class = get_printer_for_filetype(self.config.output_format)
self.printer = None # defined in set_printer
self.file_name = "" # defined in set_printer
self.depth = self.config.max_color_depth
self.available_colors = itertools.cycle(
[
"aliceblue",
"antiquewhite",
"aquamarine",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cyan",
"darkgoldenrod",
"darkseagreen",
"dodgerblue",
"forestgreen",
"gold",
"hotpink",
"mediumspringgreen",
]
)
self.used_colors = {}

def write(self, diadefs):
"""write files for <project> according to <diadefs>"""
Expand Down Expand Up @@ -108,12 +136,11 @@ def set_printer(self, file_name: str, basename: str) -> None:
self.printer = self.printer_class(basename)
self.file_name = file_name

@staticmethod
def get_package_properties(obj: PackageEntity) -> NodeProperties:
def get_package_properties(self, obj: PackageEntity) -> NodeProperties:
"""get label and shape for packages."""
return NodeProperties(
label=obj.title,
color="black",
color=self.get_shape_color(obj) if self.config.colorized else "black",
)

def get_class_properties(self, obj: ClassEntity) -> NodeProperties:
Expand All @@ -123,10 +150,26 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties:
attrs=obj.attrs if not self.config.only_classnames else None,
methods=obj.methods if not self.config.only_classnames else None,
fontcolor="red" if is_exception(obj.node) else "black",
color="black",
color=self.get_shape_color(obj) if self.config.colorized else "black",
)
return properties

def get_shape_color(self, obj: DiagramEntity) -> str:
"""get shape color"""
qualified_name = obj.node.qname()
if modutils.is_standard_module(qualified_name.split(".", maxsplit=1)[0]):
return "grey"
if isinstance(obj.node, astroid.ClassDef):
package = qualified_name.rsplit(".", maxsplit=2)[0]
elif obj.node.package:
package = qualified_name
else:
package = qualified_name.rsplit(".", maxsplit=1)[0]
base_name = ".".join(package.split(".", self.depth)[: self.depth])
if base_name not in self.used_colors:
self.used_colors[base_name] = next(self.available_colors)
return self.used_colors[base_name]

def save(self) -> None:
"""write to disk"""
self.printer.generate(self.file_name)

0 comments on commit 14c0075

Please sign in to comment.