Coverage for src/git_dag/interfaces/graphviz.py: 96%
52 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 12:49 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 12:49 +0200
1"""Interface for graphviz (https://github.com/xflr6/graphviz)."""
3from pathlib import Path
4from typing import Any, Literal, Optional
6from graphviz import Digraph # type: ignore[import-untyped]
8from ..constants import DictStrStr
9from .dag_base import DagBase
12def handle_none(x: Optional[str] = None) -> str:
13 """Handle None values when sorting."""
14 return "" if x is None else x
17class DagGraphviz(DagBase):
18 """Graphviz interface."""
20 def edge(
21 self,
22 node1_name: str,
23 node2_name: str,
24 **attrs: str,
25 ) -> None:
26 if attrs:
27 self.edges_custom.append((node1_name, node2_name, attrs))
28 else:
29 self.edges.append((node1_name, node2_name))
31 def node( # pylint: disable=too-many-positional-arguments
32 self,
33 name: str,
34 label: str,
35 color: Optional[str] = None,
36 tooltip: Optional[str] = None,
37 URL: Optional[str] = None,
38 standalone_kind: Optional[Literal["tree", "blob"]] = None,
39 **attrs: str,
40 ) -> None:
41 combined_attrs = {
42 "name": name,
43 "label": label,
44 "color": color,
45 "tooltip": tooltip,
46 "URL": URL,
47 **attrs,
48 }
49 if URL is not None: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 combined_attrs["target"] = "_blank"
52 if standalone_kind is None:
53 self.nodes.append(combined_attrs)
54 elif standalone_kind == "tree":
55 self.standalone_trees.append(combined_attrs)
56 elif standalone_kind == "blob": 56 ↛ exitline 56 didn't return from function 'node' because the condition on line 56 was always true
57 self.standalone_blobs.append(combined_attrs)
59 def build( # pylint: disable=too-many-positional-arguments
60 self,
61 format: str, # pylint: disable=redefined-builtin
62 node_attr: DictStrStr,
63 edge_attr: DictStrStr,
64 dag_attr: DictStrStr,
65 filename: str | Path,
66 cluster_params: DictStrStr,
67 ) -> None:
68 def form_clulster_of_standalone_trees_and_blobs() -> None:
69 # standalone blobs and trees are placed in a cluster
70 if (
71 self.standalone_cluster
72 or self.standalone_trees
73 or self.standalone_blobs
74 ):
75 with self._dag.subgraph(
76 name="cluster_standalone",
77 edge_attr={"style": "invis"},
78 ) as c:
79 c.attr(**cluster_params)
80 sorted_standalone_trees = sorted(
81 self.standalone_trees,
82 key=lambda x: (x["label"], handle_none(x["tooltip"])),
83 )
84 sorted_standalone_blobs = sorted(
85 self.standalone_blobs,
86 key=lambda x: (x["label"], handle_none(x["tooltip"])),
87 )
89 tree_names = [t["name"] for t in sorted_standalone_trees]
90 blob_names = [b["name"] for b in sorted_standalone_blobs]
92 for node in sorted_standalone_trees:
93 c.node(**node)
94 c.edges(zip(tree_names, tree_names[1:]))
96 for node in sorted_standalone_blobs:
97 c.node(**node)
98 c.edges(zip(blob_names, blob_names[1:]))
100 if not tree_names and not blob_names:
101 c.node("node", style="invis") # to display an empty cluster
103 self._dag = Digraph(
104 format=format,
105 node_attr=node_attr,
106 edge_attr=edge_attr,
107 graph_attr=dag_attr,
108 filename=filename,
109 )
111 # node order influences DAG
112 for node in sorted(
113 self.nodes, key=lambda x: (x["label"], handle_none(x["tooltip"]))
114 ):
115 self._dag.node(**node)
116 self._dag.edges(sorted(self.edges))
117 for node1, node2, attrs in self.edges_custom:
118 self._dag.edge(node1, node2, **attrs)
120 form_clulster_of_standalone_trees_and_blobs()
122 def render(self) -> None:
123 self._dag.render()
125 def source(self) -> str:
126 return str(self._dag.source) # use str(.) is to make mypy happy
128 def get(self) -> Any:
129 return self._dag