contrib: add graphviz_overlays.py script
Just uploading this in case it helps other folks in the future. It's
not particularly high-quality code, but hey, this is contrib, right?
BUG=none
TEST=example invocation from docstring
Change-Id: Ic961c978c379e0b3e0f891458597ebff5e05457c
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/2445799
Commit-Queue: Jack Rosenthal <jrosenth@chromium.org>
Tested-by: Jack Rosenthal <jrosenth@chromium.org>
Reviewed-by: Chris McDonald <cjmcdonald@chromium.org>
diff --git a/contrib/graphviz_overlays.py b/contrib/graphviz_overlays.py
new file mode 100755
index 0000000..44e4995
--- /dev/null
+++ b/contrib/graphviz_overlays.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Tool to visualize the inheritance of portage overlays using graphviz.
+
+Usage example (write graph to output_file.dot, and trim the visualization to
+just show what samus-private:base and auron_yuna:base requires):
+ graphviz_overlays.py -o output_file.dot -r samus-private:base auron_yuna:base
+
+This is contrib-quality code. Don't make something that really depends on it ;)
+"""
+
+import argparse
+import collections
+import os
+import pathlib
+import sys
+
+
+def dot_repr_str(str_to_repr):
+ """Represent a string compatible with dot syntax.
+
+ Args:
+ str_to_repr: The string to represent.
+
+ Returns:
+ The string, in dot lanugage compatible syntax.
+ """
+ out = repr(str_to_repr)
+ if out.startswith("'"):
+ out = '"{}"'.format(out[1:-1].replace("'", "\\'"))
+ return out
+
+
+class Digraph:
+ """Class representing a directed graph structure."""
+ def __init__(self, stylesheet=None):
+ self.nodes = {}
+ self.edges = []
+ self.subgraphs = {}
+ self.subgraph_items = collections.defaultdict(set)
+ self.stylesheet = stylesheet
+
+ def cut_to_roots(self, roots):
+ """Reduce a graph to only the specified nodes and their children.
+
+ Args:
+ roots: A list of the nodes desired.
+
+ Returns:
+ A new Digraph.
+ """
+ g = Digraph(stylesheet=self.stylesheet)
+ id_to_name = {v: k for k, v in self.nodes.items()}
+
+ def add_node(node):
+ if node in g.nodes:
+ return
+ g.add_node(node)
+ for from_e, to_e in self.edges:
+ if from_e == self.nodes[node]:
+ to_node = id_to_name[to_e]
+ add_node(to_node)
+ g.add_edge(node, to_node)
+
+ for node in roots:
+ add_node(node)
+
+ for subgraph_name, subgraph_id in self.subgraphs.items():
+ for node in self.subgraph_items[subgraph_id]:
+ if id_to_name[node] in g.nodes:
+ g.subgraph_set(subgraph_name, id_to_name[node])
+
+ return g
+
+
+ def add_node(self, name, subgraph=None):
+ """Add a node to the graph, or do nothing if it already exists.
+
+ Args:
+ name: The node label.
+ subgraph: Optionally, the subgraph to appear in.
+ """
+ if name in self.nodes:
+ # Node already added
+ return
+ nid = 'N{}'.format(len(self.nodes) + 1)
+ self.nodes[name] = nid
+ if subgraph:
+ self.subgraph_set(subgraph, name)
+
+ def subgraph_set(self, name, node_name):
+ """Set the subgraph of a node.
+
+ Args:
+ name: The subgraph.
+ node_name: The node.
+ """
+ if name not in self.subgraphs:
+ cid = 'cluster_{}'.format(len(self.subgraphs) + 1)
+ self.subgraphs[name] = cid
+ else:
+ cid = self.subgraphs[name]
+ self.subgraph_items[cid].add(self.nodes[node_name])
+
+ def add_edge(self, from_node, to_node):
+ """Add an edge to the graph.
+
+ Args:
+ from_node: The starting node.
+ to_node: The ending node.
+ """
+ self.edges.append((self.nodes[from_node], self.nodes[to_node]))
+
+ def to_dot(self, output_file=sys.stdout):
+ """Generate a dot-format representation of the graph.
+
+ Args:
+ output_file: The file to write to.
+ """
+ output_file.write('digraph {\n')
+ if self.stylesheet:
+ output_file.write(
+ 'graph [stylesheet={}]\n'.format(dot_repr_str(self.stylesheet)))
+ output_file.write('node [shape=box, style=rounded]\n')
+ for node_label, node_id in self.nodes.items():
+ output_file.write('{} [label={}]\n'.format(
+ node_id, dot_repr_str(node_label)))
+ for subgraph_label, subgraph_id in self.subgraphs.items():
+ output_file.write('subgraph {}'.format(subgraph_id))
+ output_file.write(' {\n')
+ output_file.write('label = {}\n'.format(
+ dot_repr_str(subgraph_label)))
+ output_file.write('{}\n'.format(
+ '; '.join(self.subgraph_items[subgraph_id])))
+ output_file.write('}\n')
+ for from_nid, to_nid in self.edges:
+ output_file.write('{} -> {}\n'.format(from_nid, to_nid))
+ output_file.write('}\n')
+
+
+def add_profiles(graph, repo_name, path, basedir=None):
+ """Add profiles from a portage overlay to the graph.
+
+ Args:
+ graph: The graph to add to.
+ repo_name: The Portage "repo-name".
+ path: The path to the "profiles" directory in the overlay.
+ basedir: Used for recursive invocation by this function.
+
+ Yields:
+ Each of the profiles added to this graph in this overlay only.
+ """
+
+ if not basedir:
+ basedir = path
+ for ent in path.iterdir():
+ if ent.is_dir():
+ yield from add_profiles(graph, repo_name, ent, basedir=basedir)
+ elif ent.name == 'parent':
+ pname = '{}:{}'.format(repo_name, path.relative_to(basedir))
+ graph.add_node(pname)
+ yield pname
+ with open(ent, 'r') as f:
+ for line in f:
+ line, _, _ = line.partition('#')
+ line = line.strip()
+ if not line:
+ continue
+ if ':' in line:
+ cname = line
+ else:
+ cname = '{}:{}'.format(
+ repo_name,
+ (path / line).resolve().relative_to(basedir))
+ graph.add_node(cname)
+ graph.add_edge(pname, cname)
+ if cname.startswith('{}:'.format(repo_name)):
+ yield cname
+ elif ent.name in ('package.use', 'make.defaults'):
+ pname = '{}:{}'.format(repo_name, path.relative_to(basedir))
+ graph.add_node(pname)
+ yield pname
+
+
+def add_overlay(path, graph):
+ """Add an overlay to the graph.
+
+ Args:
+ path: The path to the overlay.
+ graph: The graph to add to.
+ """
+ with open(path / 'metadata' / 'layout.conf') as f:
+ for line in f:
+ k, part, v = line.partition('=')
+ if not part:
+ continue
+ if k.strip() == 'repo-name':
+ repo_name = v.strip()
+ break
+ else:
+ repo_name = path.name
+ subgraph = repo_name
+ if path.parent.name == 'private-overlays':
+ subgraph = 'Private Overlays'
+ elif path.parent.name == 'overlays':
+ subgraph = 'Public Overlays'
+ for profile in add_profiles(graph, repo_name, path / 'profiles'):
+ graph.subgraph_set(subgraph, profile)
+
+
+def find_overlays(path, max_depth=10, skip_dirs=()):
+ """Generator to find all portage overlays in a directory.
+
+ Args:
+ path: Path to begin search.
+ max_depth: Maximum recursion depth.
+ skip_dirs: Optional set of paths to skip.
+ """
+ if path.name == '.git':
+ return
+ if max_depth == 0:
+ return
+ for d in path.iterdir():
+ if d in skip_dirs:
+ continue
+ if d.is_dir():
+ if (d / 'metadata' / 'layout.conf').is_file():
+ yield d
+ else:
+ yield from find_overlays(d, max_depth=max_depth - 1)
+
+
+def get_default_src_dir():
+ """Find the path to ~/trunk/src."""
+ home = pathlib.Path(os.getenv('HOME'))
+ for path in (home / 'trunk' / 'src',
+ home / 'chromiumos' / 'src',
+ pathlib.Path('mnt') / 'host' / 'source' / 'src'):
+ if path.is_dir():
+ return path
+ raise OSError(
+ 'Cannot find path to ~/trunk/src. '
+ 'You may need to manually specify --src-dir.')
+
+
+def main():
+ """The main function."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--src-dir', type=pathlib.Path)
+ parser.add_argument('-o', '--output',
+ type=argparse.FileType('w'), default=sys.stdout)
+ parser.add_argument('-r', '--roots', nargs='*')
+ parser.add_argument(
+ '--stylesheet',
+ # pylint: disable=line-too-long
+ default='https://g3doc.corp.google.com/frameworks/g3doc/includes/graphviz-style.css',
+ # pylint: enable=line-too-long
+ )
+ args = parser.parse_args()
+
+ src_dir = args.src_dir
+ if not src_dir:
+ src_dir = get_default_src_dir()
+ src_dir = src_dir.resolve()
+
+ g = Digraph(stylesheet=args.stylesheet)
+ for d in find_overlays(src_dir, skip_dirs=(src_dir / 'platform',
+ src_dir / 'platform2')):
+ if not (d / 'profiles').is_dir():
+ print('WARNING: skipping {} due to missing profiles dir'.format(d),
+ file=sys.stderr)
+ continue
+ add_overlay(d, g)
+
+ if args.roots:
+ g = g.cut_to_roots(args.roots)
+ g.to_dot(args.output)
+
+
+if __name__ == '__main__':
+ main()