Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pyreverse: add PlantUML output #4846

Merged
merged 13 commits into from
Aug 14, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Release date: TBA
..
Put bug fixes that should not wait for a new minor version here

+ pyreverse: add output in PlantUML format.

Closes #4498

* pylint does not crash with a traceback anymore when a file is problematic. It
creates a template text file for opening an issue on the bug tracker instead.
The linting can go on for other non problematic files instead of being impossible.
Expand Down
2 changes: 2 additions & 0 deletions doc/whatsnew/2.10.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Extensions
Other Changes
=============

* Pyreverse - add output in PlantUML format

* pylint does not crash with a traceback anymore when a file is problematic. It
creates a template text file for opening an issue on the bug tracker instead.
The linting can go on for other non problematic files instead of being impossible.
Expand Down
33 changes: 6 additions & 27 deletions pylint/pyreverse/dot_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@ def emit_node(
f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{self.node_style}"];'
)

@staticmethod
def _build_label_for_node(
properties: NodeProperties, is_interface: Optional[bool] = False
self, properties: NodeProperties, is_interface: Optional[bool] = False
) -> str:
if not properties.label:
return ""
Expand All @@ -103,31 +102,11 @@ def _build_label_for_node(
# Add class methods
methods: List[astroid.FunctionDef] = properties.methods or []
for func in methods:
return_type = (
f": {get_annotation_label(func.returns)}" if func.returns else ""
)

if func.args.args:
arguments: List[astroid.AssignName] = [
arg for arg in func.args.args if arg.name != "self"
]
else:
arguments = []

annotations = dict(zip(arguments, func.args.annotations[1:]))
for arg in arguments:
annotation_label = ""
ann = annotations.get(arg)
if ann:
annotation_label = get_annotation_label(ann)
annotations[arg] = annotation_label

args = ", ".join(
f"{arg.name}: {ann}" if ann else f"{arg.name}"
for arg, ann in annotations.items()
)

label += fr"{func.name}({args}){return_type}\l"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same logic is used by PlantUmlPrinter, so I extracted a method and put it inside the base Printer class.

args = self._get_method_arguments(func)
label += fr"{func.name}({', '.join(args)})"
if func.returns:
label += ": " + get_annotation_label(func.returns)
label += r"\l"
label += "}"
return label

Expand Down
5 changes: 1 addition & 4 deletions pylint/pyreverse/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@
from pylint.config import ConfigurationMixIn
from pylint.pyreverse import writer
from pylint.pyreverse.diadefslib import DiadefsHandler
from pylint.pyreverse.dot_printer import DotPrinter
from pylint.pyreverse.inspector import Linker, project_from_files
from pylint.pyreverse.utils import check_graphviz_availability, insert_default_options
from pylint.pyreverse.vcg_printer import VCGPrinter

OPTIONS = (
(
Expand Down Expand Up @@ -209,8 +207,7 @@ def run(self, args):
diadefs = handler.get_diadefs(project, linker)
finally:
sys.path.pop(0)
printer_class = VCGPrinter if self.config.output_format == "vcg" else DotPrinter
writer.DiagramWriter(self.config, printer_class).write(diadefs)
writer.DiagramWriter(self.config).write(diadefs)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduced a factory function to get the printer class for a given filetype. This supports the Open-Closed-Principle (only factory function needs to be updated if a new printer is added). Also, the DiagramWriter can now obtain the printer class itself.

return 0


Expand Down
91 changes: 91 additions & 0 deletions pylint/pyreverse/plantuml_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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

"""
Class to generate files in dot format and image formats supported by Graphviz.
"""
from typing import Dict, Optional

from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer
from pylint.pyreverse.utils import get_annotation_label


class PlantUmlPrinter(Printer):
"""Printer for PlantUML diagrams"""

DEFAULT_COLOR = "black"

NODES: Dict[NodeType, str] = {
NodeType.CLASS: "class",
NodeType.INTERFACE: "class",
NodeType.PACKAGE: "package",
}
ARROWS: Dict[EdgeType, str] = {
EdgeType.INHERITS: "--|>",
EdgeType.IMPLEMENTS: "..|>",
EdgeType.ASSOCIATION: "--*",
EdgeType.USES: "-->",
}

def _open_graph(self) -> None:
"""Emit the header lines"""
self.emit("@startuml " + self.title)
if not self.use_automatic_namespace:
self.emit("set namespaceSeparator none")
if self.layout:
if self.layout is Layout.LEFT_TO_RIGHT:
self.emit("left to right direction")
elif self.layout is Layout.TOP_TO_BOTTOM:
self.emit("top to bottom direction")
else:
raise ValueError(
f"Unsupported layout {self.layout}. PlantUmlPrinter only supports left to right and top to bottom layout."
)

def emit_node(
self,
name: str,
type_: NodeType,
properties: Optional[NodeProperties] = None,
) -> None:
"""Create a new node. Nodes can be classes, packages, participants etc."""
if properties is None:
properties = NodeProperties(label=name)
stereotype = " << interface >>" if type_ is NodeType.INTERFACE else ""
nodetype = self.NODES[type_]
color = (
f" #{properties.color}" if properties.color != self.DEFAULT_COLOR else ""
)
body = ""
if properties.attrs:
body += "\n".join(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)})"
if func.returns:
body += " -> " + get_annotation_label(func.returns)
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}}')

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")
24 changes: 24 additions & 0 deletions pylint/pyreverse/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import astroid

from pylint.pyreverse.utils import get_annotation_label


class NodeType(Enum):
CLASS = "class"
Expand Down Expand Up @@ -84,6 +86,28 @@ def emit_edge(
) -> None:
"""Create an edge from one node to another to display relationships."""

@staticmethod
def _get_method_arguments(method: astroid.FunctionDef) -> List[str]:
if method.args.args:
arguments: List[astroid.AssignName] = [
arg for arg in method.args.args if arg.name != "self"
]
else:
arguments = []

annotations = dict(zip(arguments, method.args.annotations[1:]))
for arg in arguments:
annotation_label = ""
ann = annotations.get(arg)
if ann:
annotation_label = get_annotation_label(ann)
annotations[arg] = annotation_label

return [
f"{arg.name}: {ann}" if ann else f"{arg.name}"
for arg, ann in annotations.items()
]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is used by both DotPrinter and PlantUmlPrinter. We could probably add this feature for VCG as well if somebody needs it.

def generate(self, outputfile: str) -> None:
"""Generate and save the final outputfile."""
self._close_graph()
Expand Down
17 changes: 17 additions & 0 deletions pylint/pyreverse/printer_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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

from typing import Type

from pylint.pyreverse.dot_printer import DotPrinter
from pylint.pyreverse.plantuml_printer import PlantUmlPrinter
from pylint.pyreverse.printer import Printer
from pylint.pyreverse.vcg_printer import VCGPrinter

filetype_to_printer = {"vcg": VCGPrinter, "puml": PlantUmlPrinter, "dot": DotPrinter}
DudeNr33 marked this conversation as resolved.
Show resolved Hide resolved


def get_printer_for_filetype(filetype: str) -> Type[Printer]:
return filetype_to_printer.get(filetype, DotPrinter)
5 changes: 3 additions & 2 deletions pylint/pyreverse/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@
PackageEntity,
)
from pylint.pyreverse.printer import EdgeType, NodeProperties, NodeType
from pylint.pyreverse.printer_factory import get_printer_for_filetype
from pylint.pyreverse.utils import is_exception


class DiagramWriter:
"""base class for writing project diagrams"""

def __init__(self, config, printer_class):
def __init__(self, config):
self.config = config
self.printer_class = printer_class
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

Expand Down
8 changes: 8 additions & 0 deletions tests/data/suppliermodule_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ def set_value(self, value):
class DoNothing: pass

class DoNothing2: pass

class DoSomething:
def __init__(self, a_string: str, optional_int: int = None):
self.my_string = a_string
self.my_int = optional_int

def do_it(self, new_int: int) -> int:
return self.my_int + new_int
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new class is for checking that #4551 works correctly with PlantUML output too.

7 changes: 7 additions & 0 deletions tests/pyreverse/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ def vcg_config() -> PyreverseConfig:
)


@pytest.fixture()
def puml_config() -> PyreverseConfig:
return PyreverseConfig(
output_format="puml",
)


@pytest.fixture(scope="session")
def get_project() -> Callable:
def _get_project(module: str, name: Optional[str] = "No Name") -> Project:
Expand Down
1 change: 1 addition & 0 deletions tests/pyreverse/data/classes_No_Name.dot
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.DoNothing2" [color="black", fontcolor="black", label="{DoNothing2|\l|}", shape="record", style="solid"];
"data.suppliermodule_test.DoSomething" [color="black", fontcolor="black", label="{DoSomething|my_int : Optional[int]\lmy_string : str\l|do_it(new_int: int): int\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\lrelation2\ltop : str\l|}", shape="record", style="solid"];
"data.clientmodule_test.Specialization" -> "data.clientmodule_test.Ancestor" [arrowhead="empty", arrowtail="none"];
Expand Down
39 changes: 39 additions & 0 deletions tests/pyreverse/data/classes_No_Name.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@startuml classes_No_Name
set namespaceSeparator none
class "Ancestor" as data.clientmodule_test.Ancestor {
attr : str
cls_member

get_value()
set_value(value)
}
class "DoNothing" as data.suppliermodule_test.DoNothing {

}
class "DoNothing2" as data.suppliermodule_test.DoNothing2 {

}
class "DoSomething" as data.suppliermodule_test.DoSomething {
my_int : Optional[int]
my_string : str

do_it(new_int: int) -> int
}
class "Interface" as data.suppliermodule_test.Interface {


get_value()
set_value(value)
}
class "Specialization" as data.clientmodule_test.Specialization {
TYPE : str
relation
relation2
top : str
}
data.clientmodule_test.Specialization --|> data.clientmodule_test.Ancestor
data.clientmodule_test.Ancestor ..|> data.suppliermodule_test.Interface
data.suppliermodule_test.DoNothing --* data.clientmodule_test.Ancestor : cls_member
data.suppliermodule_test.DoNothing --* data.clientmodule_test.Specialization : relation
data.suppliermodule_test.DoNothing2 --* data.clientmodule_test.Specialization : relation2
@enduml
3 changes: 3 additions & 0 deletions tests/pyreverse/data/classes_No_Name.vcg
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ graph:{
}
node: {title:"data.suppliermodule_test.DoNothing2" label:"\fbDoNothing2\fn\n\f____________"
shape:box
}
node: {title:"data.suppliermodule_test.DoSomething" label:"\fbDoSomething\fn\n\f________________________\n\f08my_int : Optional[int]\n\f08my_string : str\n\f________________________\n\f10do_it()"
shape:box
}
node: {title:"data.suppliermodule_test.Interface" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()"
shape:box
Expand Down
13 changes: 13 additions & 0 deletions tests/pyreverse/data/packages_No_Name.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@startuml packages_No_Name
set namespaceSeparator none
package "data" as data {

}
package "data.clientmodule_test" as data.clientmodule_test {

}
package "data.suppliermodule_test" as data.suppliermodule_test {

}
data.clientmodule_test --> data.suppliermodule_test
@enduml
1 change: 1 addition & 0 deletions tests/pyreverse/test_diadefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def test_known_values1(HANDLER, PROJECT):
(True, "Ancestor"),
(True, "DoNothing"),
(True, "DoNothing2"),
(True, "DoSomething"),
(True, "Interface"),
(True, "Specialization"),
]
Expand Down
3 changes: 3 additions & 0 deletions tests/pyreverse/test_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

from pylint.pyreverse.dot_printer import DotPrinter
from pylint.pyreverse.plantuml_printer import PlantUmlPrinter
from pylint.pyreverse.printer import Layout, Printer
from pylint.pyreverse.vcg_printer import VCGPrinter

Expand All @@ -23,6 +24,8 @@
(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(
Expand Down