Skip to content

Commit 14c0075

Browse files
pyreverse: Add option for colored output (#4850)
* Add option to produce colored output from ``pyreverse`` * Use indentation in PlantUML diagrams Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent e54df78 commit 14c0075

16 files changed

+281
-86
lines changed

ChangeLog

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ Release date: TBA
1212
..
1313
Put bug fixes that should not wait for a new minor version here
1414

15-
+ pyreverse: add output in PlantUML format.
15+
* pyreverse: add option to produce colored output.
16+
17+
Closes #4488
18+
19+
* pyreverse: add output in PlantUML format.
1620

1721
Closes #4498
1822

doc/whatsnew/2.10.rst

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ Extensions
4444
Other Changes
4545
=============
4646

47+
* pyreverse now permit to produce colored generated diagram by using the ``colorized`` option.
48+
4749
* Pyreverse - add output in PlantUML format
4850

4951
* pylint does not crash with a traceback anymore when a file is problematic. It

pylint/pyreverse/dot_printer.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535

3636

3737
class DotPrinter(Printer):
38+
DEFAULT_COLOR = "black"
39+
3840
def __init__(
3941
self,
4042
title: str,
@@ -43,7 +45,6 @@ def __init__(
4345
):
4446
layout = layout or Layout.BOTTOM_TO_TOP
4547
self.charset = "utf-8"
46-
self.node_style = "solid"
4748
super().__init__(title, layout, use_automatic_namespace)
4849

4950
def _open_graph(self) -> None:
@@ -67,7 +68,8 @@ def emit_node(
6768
if properties is None:
6869
properties = NodeProperties(label=name)
6970
shape = SHAPES[type_]
70-
color = properties.color if properties.color is not None else "black"
71+
color = properties.color if properties.color is not None else self.DEFAULT_COLOR
72+
style = "filled" if color != self.DEFAULT_COLOR else "solid"
7173
label = self._build_label_for_node(properties)
7274
if label:
7375
label_part = f', label="{label}"'
@@ -77,7 +79,7 @@ def emit_node(
7779
f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else ""
7880
)
7981
self.emit(
80-
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];'
82+
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{style}"];'
8183
)
8284

8385
def _build_label_for_node(

pylint/pyreverse/main.py

+44-25
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# Copyright (c) 2020 hippo91 <[email protected]>
1212
# Copyright (c) 2021 Marc Mueller <[email protected]>
1313
# Copyright (c) 2021 Mark Byrne <[email protected]>
14+
# Copyright (c) 2021 Andreas Finkler <[email protected]>
1415

1516
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
1617
# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
@@ -124,8 +125,7 @@
124125
short="k",
125126
action="store_true",
126127
default=False,
127-
help="don't show attributes and methods in the class boxes; \
128-
this disables -f values",
128+
help="don't show attributes and methods in the class boxes; this disables -f values",
129129
),
130130
),
131131
(
@@ -139,37 +139,56 @@
139139
help="create a *.<format> output file if format available.",
140140
),
141141
),
142+
(
143+
"colorized",
144+
dict(
145+
dest="colorized",
146+
action="store_true",
147+
default=False,
148+
help="Use colored output. Classes/modules of the same package get the same color.",
149+
),
150+
),
151+
(
152+
"max-color-depth",
153+
dict(
154+
dest="max_color_depth",
155+
action="store",
156+
default=2,
157+
metavar="<depth>",
158+
type="int",
159+
help="Use separate colors up to package depth of <depth>",
160+
),
161+
),
142162
(
143163
"ignore",
144-
{
145-
"type": "csv",
146-
"metavar": "<file[,file...]>",
147-
"dest": "ignore_list",
148-
"default": ("CVS",),
149-
"help": "Files or directories to be skipped. They "
150-
"should be base names, not paths.",
151-
},
164+
dict(
165+
type="csv",
166+
metavar="<file[,file...]>",
167+
dest="ignore_list",
168+
default=("CVS",),
169+
help="Files or directories to be skipped. They should be base names, not paths.",
170+
),
152171
),
153172
(
154173
"project",
155-
{
156-
"default": "",
157-
"type": "string",
158-
"short": "p",
159-
"metavar": "<project name>",
160-
"help": "set the project name.",
161-
},
174+
dict(
175+
default="",
176+
type="string",
177+
short="p",
178+
metavar="<project name>",
179+
help="set the project name.",
180+
),
162181
),
163182
(
164183
"output-directory",
165-
{
166-
"default": "",
167-
"type": "string",
168-
"short": "d",
169-
"action": "store",
170-
"metavar": "<output_directory>",
171-
"help": "set the output directory path.",
172-
},
184+
dict(
185+
default="",
186+
type="string",
187+
short="d",
188+
action="store",
189+
metavar="<output_directory>",
190+
help="set the output directory path.",
191+
),
173192
),
174193
)
175194

pylint/pyreverse/plantuml_printer.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,25 @@ def emit_node(
5959
color = f" #{properties.color}"
6060
else:
6161
color = ""
62-
body = ""
62+
body = []
6363
if properties.attrs:
64-
body += "\n".join(properties.attrs)
64+
body.extend(properties.attrs)
6565
if properties.methods:
66-
body += "\n"
6766
for func in properties.methods:
6867
args = self._get_method_arguments(func)
69-
body += f"\n{func.name}({', '.join(args)})"
68+
line = f"{func.name}({', '.join(args)})"
7069
if func.returns:
71-
body += " -> " + get_annotation_label(func.returns)
70+
line += " -> " + get_annotation_label(func.returns)
71+
body.append(line)
7272
label = properties.label if properties.label is not None else name
7373
if properties.fontcolor and properties.fontcolor != self.DEFAULT_COLOR:
7474
label = f"<color:{properties.fontcolor}>{label}</color>"
75-
self.emit(f'{nodetype} "{label}" as {name}{stereotype}{color} {{\n{body}\n}}')
75+
self.emit(f'{nodetype} "{label}" as {name}{stereotype}{color} {{')
76+
self._inc_indent()
77+
for line in body:
78+
self.emit(line)
79+
self._dec_indent()
80+
self.emit("}")
7681

7782
def emit_edge(
7883
self,

pylint/pyreverse/printer.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,25 @@ def __init__(
5656
self.layout = layout
5757
self.use_automatic_namespace = use_automatic_namespace
5858
self.lines: List[str] = []
59+
self._indent = ""
5960
self._open_graph()
6061

62+
def _inc_indent(self):
63+
"""increment indentation"""
64+
self._indent += " "
65+
66+
def _dec_indent(self):
67+
"""decrement indentation"""
68+
self._indent = self._indent[:-2]
69+
6170
@abstractmethod
6271
def _open_graph(self) -> None:
6372
"""Emit the header lines, i.e. all boilerplate code that defines things like layout etc."""
6473

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

7079
@abstractmethod
7180
def emit_node(

pylint/pyreverse/vcg_printer.py

+7-24
Original file line numberDiff line numberDiff line change
@@ -185,18 +185,9 @@
185185

186186

187187
class VCGPrinter(Printer):
188-
def __init__(
189-
self,
190-
title: str,
191-
layout: Optional[Layout] = None,
192-
use_automatic_namespace: Optional[bool] = None,
193-
):
194-
self._indent = ""
195-
super().__init__(title, layout, use_automatic_namespace)
196-
197188
def _open_graph(self) -> None:
198189
"""Emit the header lines"""
199-
self.emit(f"{self._indent}graph:{{\n")
190+
self.emit("graph:{\n")
200191
self._inc_indent()
201192
self._write_attributes(
202193
GRAPH_ATTRS,
@@ -212,7 +203,7 @@ def _open_graph(self) -> None:
212203
def _close_graph(self) -> None:
213204
"""Emit the lines needed to properly close the graph."""
214205
self._dec_indent()
215-
self.emit(f"{self._indent}}}")
206+
self.emit("}")
216207

217208
def emit_node(
218209
self,
@@ -225,7 +216,7 @@ def emit_node(
225216
properties = NodeProperties(label=name)
226217
elif properties.label is None:
227218
properties.label = name
228-
self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False)
219+
self.emit(f'node: {{title:"{name}"', force_newline=False)
229220
self._write_attributes(
230221
NODE_ATTRS,
231222
label=self._build_label_for_node(properties),
@@ -264,7 +255,7 @@ def emit_edge(
264255
) -> None:
265256
"""Create an edge from one node to another to display relationships."""
266257
self.emit(
267-
f'{self._indent}edge: {{sourcename:"{from_node}" targetname:"{to_node}"',
258+
f'edge: {{sourcename:"{from_node}" targetname:"{to_node}"',
268259
force_newline=False,
269260
)
270261
attributes = ARROWS[type_]
@@ -287,20 +278,12 @@ def _write_attributes(self, attributes_dict: Mapping[str, Any], **args) -> None:
287278
) from e
288279

289280
if not _type:
290-
self.emit(f'{self._indent}{key}:"{value}"\n')
281+
self.emit(f'{key}:"{value}"\n')
291282
elif _type == 1:
292-
self.emit(f"{self._indent}{key}:{int(value)}\n")
283+
self.emit(f"{key}:{int(value)}\n")
293284
elif value in _type:
294-
self.emit(f"{self._indent}{key}:{value}\n")
285+
self.emit(f"{key}:{value}\n")
295286
else:
296287
raise Exception(
297288
f"value {value} isn't correct for attribute {key} correct values are {type}"
298289
)
299-
300-
def _inc_indent(self):
301-
"""increment indentation"""
302-
self._indent += " "
303-
304-
def _dec_indent(self):
305-
"""decrement indentation"""
306-
self._indent = self._indent[:-2]

pylint/pyreverse/writer.py

+47-4
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
1717

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

20+
import itertools
2021
import os
2122

23+
import astroid
24+
from astroid import modutils
25+
2226
from pylint.pyreverse.diagrams import (
2327
ClassDiagram,
2428
ClassEntity,
29+
DiagramEntity,
2530
PackageDiagram,
2631
PackageEntity,
2732
)
@@ -38,6 +43,29 @@ def __init__(self, config):
3843
self.printer_class = get_printer_for_filetype(self.config.output_format)
3944
self.printer = None # defined in set_printer
4045
self.file_name = "" # defined in set_printer
46+
self.depth = self.config.max_color_depth
47+
self.available_colors = itertools.cycle(
48+
[
49+
"aliceblue",
50+
"antiquewhite",
51+
"aquamarine",
52+
"burlywood",
53+
"cadetblue",
54+
"chartreuse",
55+
"chocolate",
56+
"coral",
57+
"cornflowerblue",
58+
"cyan",
59+
"darkgoldenrod",
60+
"darkseagreen",
61+
"dodgerblue",
62+
"forestgreen",
63+
"gold",
64+
"hotpink",
65+
"mediumspringgreen",
66+
]
67+
)
68+
self.used_colors = {}
4169

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

111-
@staticmethod
112-
def get_package_properties(obj: PackageEntity) -> NodeProperties:
139+
def get_package_properties(self, obj: PackageEntity) -> NodeProperties:
113140
"""get label and shape for packages."""
114141
return NodeProperties(
115142
label=obj.title,
116-
color="black",
143+
color=self.get_shape_color(obj) if self.config.colorized else "black",
117144
)
118145

119146
def get_class_properties(self, obj: ClassEntity) -> NodeProperties:
@@ -123,10 +150,26 @@ def get_class_properties(self, obj: ClassEntity) -> NodeProperties:
123150
attrs=obj.attrs if not self.config.only_classnames else None,
124151
methods=obj.methods if not self.config.only_classnames else None,
125152
fontcolor="red" if is_exception(obj.node) else "black",
126-
color="black",
153+
color=self.get_shape_color(obj) if self.config.colorized else "black",
127154
)
128155
return properties
129156

157+
def get_shape_color(self, obj: DiagramEntity) -> str:
158+
"""get shape color"""
159+
qualified_name = obj.node.qname()
160+
if modutils.is_standard_module(qualified_name.split(".", maxsplit=1)[0]):
161+
return "grey"
162+
if isinstance(obj.node, astroid.ClassDef):
163+
package = qualified_name.rsplit(".", maxsplit=2)[0]
164+
elif obj.node.package:
165+
package = qualified_name
166+
else:
167+
package = qualified_name.rsplit(".", maxsplit=1)[0]
168+
base_name = ".".join(package.split(".", self.depth)[: self.depth])
169+
if base_name not in self.used_colors:
170+
self.used_colors[base_name] = next(self.available_colors)
171+
return self.used_colors[base_name]
172+
130173
def save(self) -> None:
131174
"""write to disk"""
132175
self.printer.generate(self.file_name)

0 commit comments

Comments
 (0)