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

1"""Interface for graphviz (https://github.com/xflr6/graphviz).""" 

2 

3from pathlib import Path 

4from typing import Any, Literal, Optional 

5 

6from graphviz import Digraph # type: ignore[import-untyped] 

7 

8from ..constants import DictStrStr 

9from .dag_base import DagBase 

10 

11 

12def handle_none(x: Optional[str] = None) -> str: 

13 """Handle None values when sorting.""" 

14 return "" if x is None else x 

15 

16 

17class DagGraphviz(DagBase): 

18 """Graphviz interface.""" 

19 

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)) 

30 

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" 

51 

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) 

58 

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 ) 

88 

89 tree_names = [t["name"] for t in sorted_standalone_trees] 

90 blob_names = [b["name"] for b in sorted_standalone_blobs] 

91 

92 for node in sorted_standalone_trees: 

93 c.node(**node) 

94 c.edges(zip(tree_names, tree_names[1:])) 

95 

96 for node in sorted_standalone_blobs: 

97 c.node(**node) 

98 c.edges(zip(blob_names, blob_names[1:])) 

99 

100 if not tree_names and not blob_names: 

101 c.node("node", style="invis") # to display an empty cluster 

102 

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 ) 

110 

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) 

119 

120 form_clulster_of_standalone_trees_and_blobs() 

121 

122 def render(self) -> None: 

123 self._dag.render() 

124 

125 def source(self) -> str: 

126 return str(self._dag.source) # use str(.) is to make mypy happy 

127 

128 def get(self) -> Any: 

129 return self._dag