blob: 4112fadfa18dac2fc09188095994bb75b4d9ac53 [file] [log] [blame]
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
"""Graph manipulation utilities.
(dot generation adapted from pypy/translator/tool/make_dot.py)
"""
from __future__ import annotations
import codecs
import os
import shutil
import subprocess
import tempfile
from collections.abc import Sequence
from typing import Any
def target_info_from_filename(filename: str) -> tuple[str, str, str]:
"""Transforms /some/path/foo.png into ('/some/path', 'foo.png', 'png')."""
basename = os.path.basename(filename)
storedir = os.path.dirname(os.path.abspath(filename))
target = os.path.splitext(filename)[-1][1:]
return storedir, basename, target
class DotBackend:
"""Dot File back-end."""
def __init__(
self,
graphname: str,
rankdir: str | None = None,
size: Any = None,
ratio: Any = None,
charset: str = "utf-8",
renderer: str = "dot",
additional_param: dict[str, Any] | None = None,
) -> None:
if additional_param is None:
additional_param = {}
self.graphname = graphname
self.renderer = renderer
self.lines: list[str] = []
self._source: str | None = None
self.emit(f"digraph {normalize_node_id(graphname)} {{")
if rankdir:
self.emit(f"rankdir={rankdir}")
if ratio:
self.emit(f"ratio={ratio}")
if size:
self.emit(f'size="{size}"')
if charset:
assert charset.lower() in {
"utf-8",
"iso-8859-1",
"latin1",
}, f"unsupported charset {charset}"
self.emit(f'charset="{charset}"')
for param in additional_param.items():
self.emit("=".join(param))
def get_source(self) -> str:
"""Returns self._source."""
if self._source is None:
self.emit("}\n")
self._source = "\n".join(self.lines)
del self.lines
return self._source
source = property(get_source)
def generate(
self, outputfile: str | None = None, mapfile: str | None = None
) -> str:
"""Generates a graph file.
:param str outputfile: filename and path [defaults to graphname.png]
:param str mapfile: filename and path
:rtype: str
:return: a path to the generated file
:raises RuntimeError: if the executable for rendering was not found
"""
# pylint: disable=duplicate-code
graphviz_extensions = ("dot", "gv")
name = self.graphname
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 = target_info_from_filename(outputfile)
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 codecs.open(dot_sourcepath, "w", encoding="utf8") as file:
file.write(self.source)
if target not in graphviz_extensions:
if shutil.which(self.renderer) is None:
raise RuntimeError(
f"Cannot generate `{outputfile}` because '{self.renderer}' "
"executable not found. Install graphviz, or specify a `.gv` "
"outputfile to produce the DOT source code."
)
if mapfile:
subprocess.run(
[
self.renderer,
"-Tcmapx",
"-o",
mapfile,
"-T",
target,
dot_sourcepath,
"-o",
outputfile,
],
check=True,
)
else:
subprocess.run(
[self.renderer, "-T", target, dot_sourcepath, "-o", outputfile],
check=True,
)
os.unlink(dot_sourcepath)
return outputfile
def emit(self, line: str) -> None:
"""Adds <line> to final output."""
self.lines.append(line)
def emit_edge(self, name1: str, name2: str, **props: Any) -> None:
"""Emit an edge from <name1> to <name2>.
For edge properties: see https://www.graphviz.org/doc/info/attrs.html
"""
attrs = [f'{prop}="{value}"' for prop, value in props.items()]
n_from, n_to = normalize_node_id(name1), normalize_node_id(name2)
self.emit(f"{n_from} -> {n_to} [{', '.join(sorted(attrs))}];")
def emit_node(self, name: str, **props: Any) -> None:
"""Emit a node with given properties.
For node properties: see https://www.graphviz.org/doc/info/attrs.html
"""
attrs = [f'{prop}="{value}"' for prop, value in props.items()]
self.emit(f"{normalize_node_id(name)} [{', '.join(sorted(attrs))}];")
def normalize_node_id(nid: str) -> str:
"""Returns a suitable DOT node id for `nid`."""
return f'"{nid}"'
def get_cycles(
graph_dict: dict[str, set[str]], vertices: list[str] | None = None
) -> Sequence[list[str]]:
"""Return a list of detected cycles based on an ordered graph (i.e. keys are
vertices and values are lists of destination vertices representing edges).
"""
if not graph_dict:
return ()
result: list[list[str]] = []
if vertices is None:
vertices = list(graph_dict.keys())
for vertice in vertices:
_get_cycles(graph_dict, [], set(), result, vertice)
return result
def _get_cycles(
graph_dict: dict[str, set[str]],
path: list[str],
visited: set[str],
result: list[list[str]],
vertice: str,
) -> None:
"""Recursive function doing the real work for get_cycles."""
if vertice in path:
cycle = [vertice]
for node in path[::-1]:
if node == vertice:
break
cycle.insert(0, node)
# make a canonical representation
start_from = min(cycle)
index = cycle.index(start_from)
cycle = cycle[index:] + cycle[0:index]
# append it to result if not already in
if cycle not in result:
result.append(cycle)
return
path.append(vertice)
try:
for node in graph_dict[vertice]:
# don't check already visited nodes again
if node not in visited:
_get_cycles(graph_dict, path, visited, result, node)
visited.add(node)
except KeyError:
pass
path.pop()