diff --git a/pylint/pyreverse/diagrams.py b/pylint/pyreverse/diagrams.py index 5b854ed232..1c3d4c7af1 100644 --- a/pylint/pyreverse/diagrams.py +++ b/pylint/pyreverse/diagrams.py @@ -45,6 +45,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""" diff --git a/pylint/pyreverse/dot_printer.py b/pylint/pyreverse/dot_printer.py new file mode 100644 index 0000000000..af90e4a537 --- /dev/null +++ b/pylint/pyreverse/dot_printer.py @@ -0,0 +1,129 @@ +# 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/main/LICENSE + +""" +Class to generate files in dot format and image formats supported by Graphviz. +""" +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Dict, FrozenSet, Optional + +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer +from pylint.pyreverse.utils import check_graphviz_availability + +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"), +} + + +class DotPrinter(Printer): + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.charset = "utf-8" + self.node_style = "solid" + super().__init__(title, 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.layout.value}") + if self.charset: + assert ( + self.charset.lower() in ALLOWED_CHARSETS + ), f"unsupported charset {self.charset}" + self.emit(f'charset="{self.charset}"') + + 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) + shape = 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="{properties.fontcolor}"' if properties.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 = 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 = Path(outputfile).suffix.lstrip(".") + 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: + check_graphviz_availability() + use_shell = sys.platform == "win32" + subprocess.call( + ["dot", "-T", target, dot_sourcepath, "-o", outputfile], + shell=use_shell, + ) + os.unlink(dot_sourcepath) + + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" + self.emit("}\n") diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py index bfa0eb783d..5e5dc1b057 100644 --- a/pylint/pyreverse/main.py +++ b/pylint/pyreverse/main.py @@ -21,7 +21,6 @@ create UML diagrams for classes and modules in """ import os -import subprocess import sys from typing import Iterable @@ -29,7 +28,7 @@ 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.utils import check_graphviz_availability, insert_default_options OPTIONS = ( ( @@ -175,19 +174,6 @@ ) -def _check_graphviz_available(output_format): - """check if we need graphviz for different output format""" - try: - subprocess.call(["dot", "-V"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except OSError: - print( - "The output format '%s' is currently not available.\n" - "Please install 'Graphviz' to have other output formats " - "than 'dot' or 'vcg'." % output_format - ) - sys.exit(32) - - class Run(ConfigurationMixIn): """base class providing common behaviour for pyreverse commands""" @@ -198,7 +184,7 @@ def __init__(self, args: Iterable[str]): insert_default_options() args = self.load_command_line_configuration(args) if self.config.output_format not in ("dot", "vcg"): - _check_graphviz_available(self.config.output_format) + check_graphviz_availability() sys.exit(self.run(args)) diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py new file mode 100644 index 0000000000..66700c2d62 --- /dev/null +++ b/pylint/pyreverse/printer.py @@ -0,0 +1,92 @@ +# 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/main/LICENSE + +""" +Base class defining the interface for a printer. +""" +from abc import ABC, abstractmethod +from enum import Enum +from typing import List, NamedTuple, Optional + + +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 NodeProperties(NamedTuple): + label: str + color: Optional[str] = None + fontcolor: Optional[str] = None + body: Optional[str] = None + + +class Printer(ABC): + """Base class defining the interface for a printer""" + + def __init__( + self, + title: str, + layout: Optional[Layout] = None, + use_automatic_namespace: Optional[bool] = None, + ): + self.title: str = title + self.layout = layout + self.use_automatic_namespace = use_automatic_namespace + self.lines: List[str] = [] + self._open_graph() + + @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) + + @abstractmethod + def emit_node( + self, + name: str, + type_: NodeType, + properties: Optional[NodeProperties] = None, + ) -> None: + """Create a new node. Nodes can be classes, packages, participants etc.""" + + @abstractmethod + 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.""" + + 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) + + @abstractmethod + def _close_graph(self) -> None: + """Emit the lines needed to properly close the graph.""" diff --git a/pylint/pyreverse/utils.py b/pylint/pyreverse/utils.py index 6eb9d43ccf..7ca4501f1f 100644 --- a/pylint/pyreverse/utils.py +++ b/pylint/pyreverse/utils.py @@ -20,6 +20,7 @@ """Generic classes/functions for pyreverse core/extensions. """ import os import re +import shutil import sys from typing import Optional, Union @@ -275,3 +276,16 @@ def infer_node(node: Union[astroid.AssignAttr, astroid.AssignName]) -> set: return set(node.infer()) except astroid.InferenceError: return {ann} if ann else set() + + +def check_graphviz_availability(): + """Check if the ``dot`` command is available on the machine. + This is needed if image output is desired and ``dot`` is used to convert + from *.dot or *.gv into the final output format.""" + if shutil.which("dot") is None: + print( + "The requested output format is currently not available.\n" + "Please install 'Graphviz' to have other output formats " + "than 'dot' or 'vcg'." + ) + sys.exit(32) diff --git a/pylint/pyreverse/vcgutils.py b/pylint/pyreverse/vcg_printer.py similarity index 58% rename from pylint/pyreverse/vcgutils.py rename to pylint/pyreverse/vcg_printer.py index 3c97191478..86df2f6c06 100644 --- a/pylint/pyreverse/vcgutils.py +++ b/pylint/pyreverse/vcg_printer.py @@ -7,19 +7,21 @@ # Copyright (c) 2020 谭九鼎 <109224573@qq.com> # Copyright (c) 2020 Anthony Sottile # Copyright (c) 2021 Marc Mueller <30130371+cdce8p@users.noreply.github.com> +# 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/main/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. """ +from typing import Any, Dict, Mapping, Optional + +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer ATTRS_VAL = { "algos": ( @@ -152,73 +154,129 @@ "anchor": 1, "horizontal_order": 1, } - +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", +} # Misc utilities ############################################################### -class VCGPrinter: - """A vcg graph writer.""" - - def __init__(self, output_stream): - self._stream = output_stream +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, **args): - """open a vcg graph""" - self._stream.write("%sgraph:{\n" % self._indent) + def _open_graph(self) -> None: + """Emit the header lines""" + self.emit(f"{self._indent}graph:{{\n") self._inc_indent() - self._write_attributes(GRAPH_ATTRS, **args) + self._write_attributes( + GRAPH_ATTRS, + title=self.title, + layoutalgorithm="dfs", + late_edge_labels="yes", + port_sharing="no", + manhattan_edges="yes", + ) + if self.layout: + self._write_attributes(GRAPH_ATTRS, orientation=ORIENTATION[self.layout]) - def close_graph(self): - """close a vcg graph""" + def _close_graph(self) -> None: + """Emit the lines needed to properly close the 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.emit(f"{self._indent}}}") + + 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) + self.emit(f'{self._indent}node: {{title:"{name}"', force_newline=False) + label = properties.label if properties.label is not None else name + self._write_attributes( + NODE_ATTRS, + label=label, + shape=SHAPES[type_], ) - self._write_attributes(EDGE_ATTRS, **args) - self._stream.write("}\n") + self.emit("}") - # private ################################################################## + 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 = ARROWS[type_] + if label: + attributes["label"] = label + self._write_attributes( + EDGE_ATTRS, + **attributes, + ) + self.emit("}") - def _write_attributes(self, attributes_dict, **args): + 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( - """no such attribute %s -possible attributes are %s""" - % (key, attributes_dict.keys()) + f"no such attribute {key}\npossible attributes are {attributes_dict.keys()}" ) from e if not _type: - self._stream.write(f'{self._indent}{key}:"{value}"\n') + self.emit(f'{self._indent}{key}:"{value}"\n') elif _type == 1: - self._stream.write(f"{self._indent}{key}:{int(value)}\n") + self.emit(f"{self._indent}{key}:{int(value)}\n") elif value in _type: - self._stream.write(f"{self._indent}{key}:{value}\n") + self.emit(f"{self._indent}{key}:{value}\n") else: raise Exception( - f"""value {value} isn't correct for attribute {key} -correct values are {type}""" + f"value {value} isn't correct for attribute {key} correct values are {type}" ) def _inc_indent(self): """increment indentation""" - self._indent = " %s" % self._indent + self._indent += " " def _dec_indent(self): """decrement indentation""" diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py index f45783c498..ee8a922b92 100644 --- a/pylint/pyreverse/writer.py +++ b/pylint/pyreverse/writer.py @@ -18,19 +18,30 @@ """Utilities for creating VCG and Dot diagrams""" import os - -from pylint.graph import DotBackend +from typing import List + +import astroid + +from pylint.pyreverse.diagrams import ( + ClassDiagram, + ClassEntity, + DiagramEntity, + PackageDiagram, + PackageEntity, +) +from pylint.pyreverse.dot_printer import DotPrinter +from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType from pylint.pyreverse.utils import get_annotation_label, is_exception -from pylint.pyreverse.vcgutils import VCGPrinter +from pylint.pyreverse.vcg_printer 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 + self.file_name = "" # defined in set_printer def write(self, diadefs): """write files for according to """ @@ -44,35 +55,48 @@ def write(self, diadefs): self.write_classes(diagram) else: self.write_packages(diagram) - self.close_graph() + 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 i, obj in enumerate(sorted(diagram.modules(), key=lambda x: x.title)): - self.printer.emit_node(i, label=self.get_title(obj), shape="box") - obj.fig_id = i + for module in sorted(diagram.modules(), key=lambda x: x.title): + module.fig_id = module.node.qname() + self.printer.emit_node( + module.fig_id, + type_=NodeType.PACKAGE, + properties=self.get_package_properties(module), + ) # 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): + def write_classes(self, diagram: ClassDiagram) -> None: """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)) - obj.fig_id = i + 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_, properties=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"): @@ -80,58 +104,55 @@ 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): + 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_values(self, obj): - """get label and shape for classes.""" + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" raise NotImplementedError - def close_graph(self): - """finalize the graph""" + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: + """get label and shape for classes.""" raise NotImplementedError + def save(self) -> None: + """write to disk""" + self.printer.generate(self.file_name) + class DotWriter(DiagramWriter): """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) - - def set_printer(self, file_name, basename): + def set_printer(self, file_name: str, basename: str) -> None: """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) 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_values(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + return NodeProperties( + label=self.get_title(obj), + color="black", + ) + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. 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: @@ -140,12 +161,14 @@ def get_values(self, obj): ) if func.args.args: - args = [arg for arg in func.args.args if arg.name != "self"] + arguments: List[astroid.AssignName] = [ + arg for arg in func.args.args if arg.name != "self" + ] else: - args = [] + arguments = [] - annotations = dict(zip(args, func.args.annotations[1:])) - for arg in args: + annotations = dict(zip(arguments, func.args.annotations[1:])) + for arg in arguments: annotation_label = "" ann = annotations.get(arg) if ann: @@ -159,53 +182,33 @@ def get_values(self, obj): label = fr"{label}{func.name}({args}){return_type}\l" label = "{%s}" % label - if is_exception(obj.node): - return dict(fontcolor="red", label=label, shape="record") - return dict(label=label, shape="record") - - def close_graph(self): - """print the dot graph into """ - self.printer.generate(self.file_name) + properties = NodeProperties( + label=label, + fontcolor="red" if is_exception(obj.node) else "black", + color="black", + ) + return properties 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): + def set_printer(self, file_name: str, basename: str) -> None: """initialize VCGWriter for a UML graph""" - self.graph_file = open( # pylint: disable=consider-using-with - file_name, "w+", encoding="utf-8" - ) - 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): + def get_title(self, obj: DiagramEntity) -> str: """get project title in vcg format""" return r"\fb%s\fn" % obj.title - def get_values(self, obj): + def get_package_properties(self, obj: PackageEntity) -> NodeProperties: + """get label and shape for packages.""" + return NodeProperties( + label=self.get_title(obj), + ) + + def get_class_properties(self, obj: ClassEntity) -> NodeProperties: """get label and shape for classes. The label contains all attributes and methods @@ -214,10 +217,6 @@ def get_values(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] @@ -231,9 +230,4 @@ def get_values(self, obj): label = fr"{label}\n\f{line}" for func in methods: label = fr"{label}\n\f10{func}()" - return dict(label=label, shape=shape) - - def close_graph(self): - """close graph and file""" - self.printer.close_graph() - self.graph_file.close() + return NodeProperties(label=label) diff --git a/tests/data/classes_No_Name.dot b/tests/data/classes_No_Name.dot index c5eb70f0c4..c0141fd2a4 100644 --- a/tests/data/classes_No_Name.dot +++ b/tests/data/classes_No_Name.dot @@ -1,14 +1,14 @@ 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="{DoNothing2|\l|}", shape="record"]; -"3" [label="{Interface|\l|get_value()\lset_value(value)\l}", shape="record"]; -"4" [label="{Specialization|TYPE : str\lrelation\lrelation2\ltop : str\l|}", shape="record"]; -"4" -> "0" [arrowhead="empty", arrowtail="none"]; -"0" -> "3" [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"]; -"2" -> "4" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation2", style="solid"]; +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.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"]; +"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"]; +"data.suppliermodule_test.DoNothing2" -> "data.clientmodule_test.Specialization" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation2", style="solid"]; } diff --git a/tests/data/classes_No_Name.vcg b/tests/data/classes_No_Name.vcg new file mode 100644 index 0000000000..1bf83e3386 --- /dev/null +++ b/tests/data/classes_No_Name.vcg @@ -0,0 +1,46 @@ +graph:{ + title:"classes_No_Name" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + 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:"data.suppliermodule_test.DoNothing" label:"\fbDoNothing\fn\n\f___________" + shape:box +} + node: {title:"data.suppliermodule_test.DoNothing2" label:"\fbDoNothing2\fn\n\f____________" + shape:box +} + node: {title:"data.suppliermodule_test.Interface" label:"\fbInterface\fn\n\f___________\n\f10get_value()\n\f10set_value()" + shape:box +} + node: {title:"data.clientmodule_test.Specialization" label:"\fbSpecialization\fn\n\f________________\n\f08TYPE : str\n\f08relation\n\f08relation2\n\f08top : str\n\f________________" + shape:box +} + edge: {sourcename:"data.clientmodule_test.Specialization" targetname:"data.clientmodule_test.Ancestor" arrowstyle:solid + backarrowstyle:none + backarrowsize:10 +} + edge: {sourcename:"data.clientmodule_test.Ancestor" targetname:"data.suppliermodule_test.Interface" arrowstyle:solid + backarrowstyle:none + linestyle:dotted + backarrowsize:10 +} + 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" arrowstyle:solid + backarrowstyle:none + textcolor:green + label:"relation" +} + edge: {sourcename:"data.suppliermodule_test.DoNothing2" targetname:"data.clientmodule_test.Specialization" arrowstyle:solid + backarrowstyle:none + textcolor:green + label:"relation2" +} +} diff --git a/tests/data/packages_No_Name.dot b/tests/data/packages_No_Name.dot index 1ceeb72427..7b145dc910 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"]; -"1" -> "2" [arrowhead="open", arrowtail="none"]; +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"]; +"data.clientmodule_test" -> "data.suppliermodule_test" [arrowhead="open", arrowtail="none"]; } diff --git a/tests/data/packages_No_Name.vcg b/tests/data/packages_No_Name.vcg new file mode 100644 index 0000000000..03f4e2481a --- /dev/null +++ b/tests/data/packages_No_Name.vcg @@ -0,0 +1,20 @@ +graph:{ + title:"packages_No_Name" + layoutalgorithm:dfs + late_edge_labels:yes + port_sharing:no + manhattan_edges:yes + node: {title:"data" label:"\fbdata\fn" + shape:box +} + node: {title:"data.clientmodule_test" label:"\fbdata.clientmodule_test\fn" + shape:box +} + node: {title:"data.suppliermodule_test" label:"\fbdata.suppliermodule_test\fn" + shape:box +} + 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 8a33eb7a35..534b25cade 100644 --- a/tests/unittest_pyreverse_writer.py +++ b/tests/unittest_pyreverse_writer.py @@ -11,6 +11,7 @@ # Copyright (c) 2020 Anthony Sottile # 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 # 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 @@ -31,7 +32,7 @@ from pylint.pyreverse.diadefslib import DefaultDiadefGenerator, DiadefsHandler from pylint.pyreverse.inspector import Linker, project_from_files from pylint.pyreverse.utils import get_annotation, get_visibility, infer_node -from pylint.pyreverse.writer import DotWriter +from pylint.pyreverse.writer import DotWriter, VCGWriter _DEFAULTS = { "all_ancestors": None, @@ -82,28 +83,41 @@ def _astroid_wrapper(func, modname): DOT_FILES = ["packages_No_Name.dot", "classes_No_Name.dot"] +VCG_FILES = ["packages_No_Name.vcg", "classes_No_Name.vcg"] @pytest.fixture(scope="module") -def setup(): +def setup_dot(): + config = Config() + writer = DotWriter(config) + yield from _setup(config, writer) + + +@pytest.fixture(scope="module") +def setup_vcg(): + config = Config() + config.output_format = "vcg" + writer = VCGWriter(config) + yield from _setup(config, writer) + + +def _setup(config, writer): project = get_project(os.path.join(os.path.dirname(__file__), "data")) 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.write(dd) yield - for fname in DOT_FILES: + for fname in DOT_FILES + VCG_FILES: try: os.remove(fname) except FileNotFoundError: continue -@pytest.mark.usefixtures("setup") +@pytest.mark.usefixtures("setup_dot") @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) @@ -119,6 +133,22 @@ def test_dot_files(generated_file): os.remove(generated_file) +@pytest.mark.usefixtures("setup_vcg") +@pytest.mark.parametrize("generated_file", VCG_FILES) +def test_vcg_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.parametrize( "names, expected", [