dot_printer.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
  2. # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
  3. # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
  4. """Class to generate files in dot format and image formats supported by Graphviz."""
  5. from __future__ import annotations
  6. import os
  7. import subprocess
  8. import tempfile
  9. from enum import Enum
  10. from pathlib import Path
  11. from astroid import nodes
  12. from pylint.pyreverse.printer import EdgeType, Layout, NodeProperties, NodeType, Printer
  13. from pylint.pyreverse.utils import get_annotation_label
  14. class HTMLLabels(Enum):
  15. LINEBREAK_LEFT = '<br ALIGN="LEFT"/>'
  16. ALLOWED_CHARSETS: frozenset[str] = frozenset(("utf-8", "iso-8859-1", "latin1"))
  17. SHAPES: dict[NodeType, str] = {
  18. NodeType.PACKAGE: "box",
  19. NodeType.CLASS: "record",
  20. }
  21. # pylint: disable-next=consider-using-namedtuple-or-dataclass
  22. ARROWS: dict[EdgeType, dict[str, str]] = {
  23. EdgeType.INHERITS: {"arrowtail": "none", "arrowhead": "empty"},
  24. EdgeType.COMPOSITION: {
  25. "fontcolor": "green",
  26. "arrowtail": "none",
  27. "arrowhead": "diamond",
  28. "style": "solid",
  29. },
  30. EdgeType.ASSOCIATION: {
  31. "fontcolor": "green",
  32. "arrowtail": "none",
  33. "arrowhead": "vee",
  34. "style": "solid",
  35. },
  36. EdgeType.AGGREGATION: {
  37. "fontcolor": "green",
  38. "arrowtail": "none",
  39. "arrowhead": "odiamond",
  40. "style": "solid",
  41. },
  42. EdgeType.USES: {"arrowtail": "none", "arrowhead": "open"},
  43. EdgeType.TYPE_DEPENDENCY: {
  44. "arrowtail": "none",
  45. "arrowhead": "open",
  46. "style": "dashed",
  47. },
  48. }
  49. class DotPrinter(Printer):
  50. DEFAULT_COLOR = "black"
  51. def __init__(
  52. self,
  53. title: str,
  54. layout: Layout | None = None,
  55. use_automatic_namespace: bool | None = None,
  56. ):
  57. layout = layout or Layout.BOTTOM_TO_TOP
  58. self.charset = "utf-8"
  59. super().__init__(title, layout, use_automatic_namespace)
  60. def _open_graph(self) -> None:
  61. """Emit the header lines."""
  62. self.emit(f'digraph "{self.title}" {{')
  63. if self.layout:
  64. self.emit(f"rankdir={self.layout.value}")
  65. if self.charset:
  66. assert (
  67. self.charset.lower() in ALLOWED_CHARSETS
  68. ), f"unsupported charset {self.charset}"
  69. self.emit(f'charset="{self.charset}"')
  70. def emit_node(
  71. self,
  72. name: str,
  73. type_: NodeType,
  74. properties: NodeProperties | None = None,
  75. ) -> None:
  76. """Create a new node.
  77. Nodes can be classes, packages, participants etc.
  78. """
  79. if properties is None:
  80. properties = NodeProperties(label=name)
  81. shape = SHAPES[type_]
  82. color = properties.color if properties.color is not None else self.DEFAULT_COLOR
  83. style = "filled" if color != self.DEFAULT_COLOR else "solid"
  84. label = self._build_label_for_node(properties)
  85. label_part = f", label=<{label}>" if label else ""
  86. fontcolor_part = (
  87. f', fontcolor="{properties.fontcolor}"' if properties.fontcolor else ""
  88. )
  89. self.emit(
  90. f'"{name}" [color="{color}"{fontcolor_part}{label_part}, shape="{shape}", style="{style}"];'
  91. )
  92. def _build_label_for_node(self, properties: NodeProperties) -> str:
  93. if not properties.label:
  94. return ""
  95. label: str = properties.label
  96. if properties.attrs is None and properties.methods is None:
  97. # return a "compact" form which only displays the class name in a box
  98. return label
  99. # Add class attributes
  100. attrs: list[str] = properties.attrs or []
  101. attrs_string = rf"{HTMLLabels.LINEBREAK_LEFT.value}".join(
  102. attr.replace("|", r"\|") for attr in attrs
  103. )
  104. label = rf"{{{label}|{attrs_string}{HTMLLabels.LINEBREAK_LEFT.value}|"
  105. # Add class methods
  106. methods: list[nodes.FunctionDef] = properties.methods or []
  107. for func in methods:
  108. args = ", ".join(self._get_method_arguments(func)).replace("|", r"\|")
  109. method_name = (
  110. f"<I>{func.name}</I>" if func.is_abstract() else f"{func.name}"
  111. )
  112. label += rf"{method_name}({args})"
  113. if func.returns:
  114. annotation_label = get_annotation_label(func.returns)
  115. label += ": " + self._escape_annotation_label(annotation_label)
  116. label += rf"{HTMLLabels.LINEBREAK_LEFT.value}"
  117. label += "}"
  118. return label
  119. def _escape_annotation_label(self, annotation_label: str) -> str:
  120. # Escape vertical bar characters to make them appear as a literal characters
  121. # otherwise it gets treated as field separator of record-based nodes
  122. annotation_label = annotation_label.replace("|", r"\|")
  123. return annotation_label
  124. def emit_edge(
  125. self,
  126. from_node: str,
  127. to_node: str,
  128. type_: EdgeType,
  129. label: str | None = None,
  130. ) -> None:
  131. """Create an edge from one node to another to display relationships."""
  132. arrowstyle = ARROWS[type_]
  133. attrs = [f'{prop}="{value}"' for prop, value in arrowstyle.items()]
  134. if label:
  135. attrs.append(f'label="{label}"')
  136. self.emit(f'"{from_node}" -> "{to_node}" [{", ".join(sorted(attrs))}];')
  137. def generate(self, outputfile: str) -> None:
  138. self._close_graph()
  139. graphviz_extensions = ("dot", "gv")
  140. name = self.title
  141. if outputfile is None:
  142. target = "png"
  143. pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
  144. ppng, outputfile = tempfile.mkstemp(".png", name)
  145. os.close(pdot)
  146. os.close(ppng)
  147. else:
  148. target = Path(outputfile).suffix.lstrip(".")
  149. if not target:
  150. target = "png"
  151. outputfile = outputfile + "." + target
  152. if target not in graphviz_extensions:
  153. pdot, dot_sourcepath = tempfile.mkstemp(".gv", name)
  154. os.close(pdot)
  155. else:
  156. dot_sourcepath = outputfile
  157. with open(dot_sourcepath, "w", encoding="utf8") as outfile:
  158. outfile.writelines(self.lines)
  159. if target not in graphviz_extensions:
  160. subprocess.run(
  161. ["dot", "-T", target, dot_sourcepath, "-o", outputfile], check=True
  162. )
  163. os.unlink(dot_sourcepath)
  164. def _close_graph(self) -> None:
  165. """Emit the lines needed to properly close the graph."""
  166. self.emit("}\n")