Jack Rosenthal | 1b7b398 | 2020-10-02 09:15:01 -0600 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # Copyright 2020 The Chromium OS Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | """Tool to visualize the inheritance of portage overlays using graphviz. |
| 7 | |
| 8 | Usage example (write graph to output_file.dot, and trim the visualization to |
| 9 | just show what samus-private:base and auron_yuna:base requires): |
| 10 | graphviz_overlays.py -o output_file.dot -r samus-private:base auron_yuna:base |
| 11 | |
| 12 | This is contrib-quality code. Don't make something that really depends on it ;) |
| 13 | """ |
| 14 | |
| 15 | import argparse |
| 16 | import collections |
| 17 | import os |
| 18 | import pathlib |
| 19 | import sys |
| 20 | |
| 21 | |
| 22 | def dot_repr_str(str_to_repr): |
| 23 | """Represent a string compatible with dot syntax. |
| 24 | |
| 25 | Args: |
| 26 | str_to_repr: The string to represent. |
| 27 | |
| 28 | Returns: |
| 29 | The string, in dot lanugage compatible syntax. |
| 30 | """ |
| 31 | out = repr(str_to_repr) |
| 32 | if out.startswith("'"): |
| 33 | out = '"{}"'.format(out[1:-1].replace("'", "\\'")) |
| 34 | return out |
| 35 | |
| 36 | |
| 37 | class Digraph: |
| 38 | """Class representing a directed graph structure.""" |
| 39 | def __init__(self, stylesheet=None): |
| 40 | self.nodes = {} |
| 41 | self.edges = [] |
| 42 | self.subgraphs = {} |
| 43 | self.subgraph_items = collections.defaultdict(set) |
| 44 | self.stylesheet = stylesheet |
| 45 | |
| 46 | def cut_to_roots(self, roots): |
| 47 | """Reduce a graph to only the specified nodes and their children. |
| 48 | |
| 49 | Args: |
| 50 | roots: A list of the nodes desired. |
| 51 | |
| 52 | Returns: |
| 53 | A new Digraph. |
| 54 | """ |
| 55 | g = Digraph(stylesheet=self.stylesheet) |
| 56 | id_to_name = {v: k for k, v in self.nodes.items()} |
| 57 | |
| 58 | def add_node(node): |
| 59 | if node in g.nodes: |
| 60 | return |
| 61 | g.add_node(node) |
| 62 | for from_e, to_e in self.edges: |
| 63 | if from_e == self.nodes[node]: |
| 64 | to_node = id_to_name[to_e] |
| 65 | add_node(to_node) |
| 66 | g.add_edge(node, to_node) |
| 67 | |
| 68 | for node in roots: |
| 69 | add_node(node) |
| 70 | |
| 71 | for subgraph_name, subgraph_id in self.subgraphs.items(): |
| 72 | for node in self.subgraph_items[subgraph_id]: |
| 73 | if id_to_name[node] in g.nodes: |
| 74 | g.subgraph_set(subgraph_name, id_to_name[node]) |
| 75 | |
| 76 | return g |
| 77 | |
| 78 | |
| 79 | def add_node(self, name, subgraph=None): |
| 80 | """Add a node to the graph, or do nothing if it already exists. |
| 81 | |
| 82 | Args: |
| 83 | name: The node label. |
| 84 | subgraph: Optionally, the subgraph to appear in. |
| 85 | """ |
| 86 | if name in self.nodes: |
| 87 | # Node already added |
| 88 | return |
| 89 | nid = 'N{}'.format(len(self.nodes) + 1) |
| 90 | self.nodes[name] = nid |
| 91 | if subgraph: |
| 92 | self.subgraph_set(subgraph, name) |
| 93 | |
| 94 | def subgraph_set(self, name, node_name): |
| 95 | """Set the subgraph of a node. |
| 96 | |
| 97 | Args: |
| 98 | name: The subgraph. |
| 99 | node_name: The node. |
| 100 | """ |
| 101 | if name not in self.subgraphs: |
| 102 | cid = 'cluster_{}'.format(len(self.subgraphs) + 1) |
| 103 | self.subgraphs[name] = cid |
| 104 | else: |
| 105 | cid = self.subgraphs[name] |
| 106 | self.subgraph_items[cid].add(self.nodes[node_name]) |
| 107 | |
| 108 | def add_edge(self, from_node, to_node): |
| 109 | """Add an edge to the graph. |
| 110 | |
| 111 | Args: |
| 112 | from_node: The starting node. |
| 113 | to_node: The ending node. |
| 114 | """ |
| 115 | self.edges.append((self.nodes[from_node], self.nodes[to_node])) |
| 116 | |
| 117 | def to_dot(self, output_file=sys.stdout): |
| 118 | """Generate a dot-format representation of the graph. |
| 119 | |
| 120 | Args: |
| 121 | output_file: The file to write to. |
| 122 | """ |
| 123 | output_file.write('digraph {\n') |
| 124 | if self.stylesheet: |
| 125 | output_file.write( |
| 126 | 'graph [stylesheet={}]\n'.format(dot_repr_str(self.stylesheet))) |
| 127 | output_file.write('node [shape=box, style=rounded]\n') |
| 128 | for node_label, node_id in self.nodes.items(): |
| 129 | output_file.write('{} [label={}]\n'.format( |
| 130 | node_id, dot_repr_str(node_label))) |
| 131 | for subgraph_label, subgraph_id in self.subgraphs.items(): |
| 132 | output_file.write('subgraph {}'.format(subgraph_id)) |
| 133 | output_file.write(' {\n') |
| 134 | output_file.write('label = {}\n'.format( |
| 135 | dot_repr_str(subgraph_label))) |
| 136 | output_file.write('{}\n'.format( |
| 137 | '; '.join(self.subgraph_items[subgraph_id]))) |
| 138 | output_file.write('}\n') |
| 139 | for from_nid, to_nid in self.edges: |
| 140 | output_file.write('{} -> {}\n'.format(from_nid, to_nid)) |
| 141 | output_file.write('}\n') |
| 142 | |
| 143 | |
| 144 | def add_profiles(graph, repo_name, path, basedir=None): |
| 145 | """Add profiles from a portage overlay to the graph. |
| 146 | |
| 147 | Args: |
| 148 | graph: The graph to add to. |
| 149 | repo_name: The Portage "repo-name". |
| 150 | path: The path to the "profiles" directory in the overlay. |
| 151 | basedir: Used for recursive invocation by this function. |
| 152 | |
| 153 | Yields: |
| 154 | Each of the profiles added to this graph in this overlay only. |
| 155 | """ |
| 156 | |
| 157 | if not basedir: |
| 158 | basedir = path |
| 159 | for ent in path.iterdir(): |
| 160 | if ent.is_dir(): |
| 161 | yield from add_profiles(graph, repo_name, ent, basedir=basedir) |
| 162 | elif ent.name == 'parent': |
| 163 | pname = '{}:{}'.format(repo_name, path.relative_to(basedir)) |
| 164 | graph.add_node(pname) |
| 165 | yield pname |
| 166 | with open(ent, 'r') as f: |
| 167 | for line in f: |
| 168 | line, _, _ = line.partition('#') |
| 169 | line = line.strip() |
| 170 | if not line: |
| 171 | continue |
| 172 | if ':' in line: |
| 173 | cname = line |
| 174 | else: |
| 175 | cname = '{}:{}'.format( |
| 176 | repo_name, |
| 177 | (path / line).resolve().relative_to(basedir)) |
| 178 | graph.add_node(cname) |
| 179 | graph.add_edge(pname, cname) |
| 180 | if cname.startswith('{}:'.format(repo_name)): |
| 181 | yield cname |
| 182 | elif ent.name in ('package.use', 'make.defaults'): |
| 183 | pname = '{}:{}'.format(repo_name, path.relative_to(basedir)) |
| 184 | graph.add_node(pname) |
| 185 | yield pname |
| 186 | |
| 187 | |
| 188 | def add_overlay(path, graph): |
| 189 | """Add an overlay to the graph. |
| 190 | |
| 191 | Args: |
| 192 | path: The path to the overlay. |
| 193 | graph: The graph to add to. |
| 194 | """ |
| 195 | with open(path / 'metadata' / 'layout.conf') as f: |
| 196 | for line in f: |
| 197 | k, part, v = line.partition('=') |
| 198 | if not part: |
| 199 | continue |
| 200 | if k.strip() == 'repo-name': |
| 201 | repo_name = v.strip() |
| 202 | break |
| 203 | else: |
| 204 | repo_name = path.name |
| 205 | subgraph = repo_name |
| 206 | if path.parent.name == 'private-overlays': |
| 207 | subgraph = 'Private Overlays' |
| 208 | elif path.parent.name == 'overlays': |
| 209 | subgraph = 'Public Overlays' |
| 210 | for profile in add_profiles(graph, repo_name, path / 'profiles'): |
| 211 | graph.subgraph_set(subgraph, profile) |
| 212 | |
| 213 | |
| 214 | def find_overlays(path, max_depth=10, skip_dirs=()): |
| 215 | """Generator to find all portage overlays in a directory. |
| 216 | |
| 217 | Args: |
| 218 | path: Path to begin search. |
| 219 | max_depth: Maximum recursion depth. |
| 220 | skip_dirs: Optional set of paths to skip. |
| 221 | """ |
| 222 | if path.name == '.git': |
| 223 | return |
| 224 | if max_depth == 0: |
| 225 | return |
| 226 | for d in path.iterdir(): |
| 227 | if d in skip_dirs: |
| 228 | continue |
| 229 | if d.is_dir(): |
| 230 | if (d / 'metadata' / 'layout.conf').is_file(): |
| 231 | yield d |
| 232 | else: |
| 233 | yield from find_overlays(d, max_depth=max_depth - 1) |
| 234 | |
| 235 | |
| 236 | def get_default_src_dir(): |
| 237 | """Find the path to ~/trunk/src.""" |
| 238 | home = pathlib.Path(os.getenv('HOME')) |
| 239 | for path in (home / 'trunk' / 'src', |
| 240 | home / 'chromiumos' / 'src', |
| 241 | pathlib.Path('mnt') / 'host' / 'source' / 'src'): |
| 242 | if path.is_dir(): |
| 243 | return path |
| 244 | raise OSError( |
| 245 | 'Cannot find path to ~/trunk/src. ' |
| 246 | 'You may need to manually specify --src-dir.') |
| 247 | |
| 248 | |
| 249 | def main(): |
| 250 | """The main function.""" |
| 251 | parser = argparse.ArgumentParser(description=__doc__) |
| 252 | parser.add_argument('--src-dir', type=pathlib.Path) |
| 253 | parser.add_argument('-o', '--output', |
| 254 | type=argparse.FileType('w'), default=sys.stdout) |
| 255 | parser.add_argument('-r', '--roots', nargs='*') |
| 256 | parser.add_argument( |
| 257 | '--stylesheet', |
| 258 | # pylint: disable=line-too-long |
| 259 | default='https://g3doc.corp.google.com/frameworks/g3doc/includes/graphviz-style.css', |
| 260 | # pylint: enable=line-too-long |
| 261 | ) |
| 262 | args = parser.parse_args() |
| 263 | |
| 264 | src_dir = args.src_dir |
| 265 | if not src_dir: |
| 266 | src_dir = get_default_src_dir() |
| 267 | src_dir = src_dir.resolve() |
| 268 | |
| 269 | g = Digraph(stylesheet=args.stylesheet) |
| 270 | for d in find_overlays(src_dir, skip_dirs=(src_dir / 'platform', |
| 271 | src_dir / 'platform2')): |
| 272 | if not (d / 'profiles').is_dir(): |
| 273 | print('WARNING: skipping {} due to missing profiles dir'.format(d), |
| 274 | file=sys.stderr) |
| 275 | continue |
| 276 | add_overlay(d, g) |
| 277 | |
| 278 | if args.roots: |
| 279 | g = g.cut_to_roots(args.roots) |
| 280 | g.to_dot(args.output) |
| 281 | |
| 282 | |
| 283 | if __name__ == '__main__': |
| 284 | main() |