Matt Torok 3320f12f76 brew-graph: improve formatting.
This commit adds additional formatting options to the graph. Most noticeable is a top-to-bottom layout (rather than the previous left-to-right), and nicer fonts on everything.

More subtly, the ranking mechanism has been updated so that the "Safe to Remove" cluster is always at the highest rank (fixing a bug where non-leaf nodes could have been placed next to it,) and added better margins and padding. The rank separation was also decreased for a more compact graph.

Under the hood, the GraphViz output code was updated to support attributes with a list of sub-attributes (for example, 'graph [fontsize="11", fontname="Helvetica"]') and to not put quotes around HTML-like labels in clusters.

Closes Homebrew/homebrew#26651.

Signed-off-by: Mike McQuaid <mike@mikemcquaid.com>
2014-02-12 11:40:40 +00:00

365 lines
10 KiB
Python
Executable File

#!/usr/bin/env python
"""
$ brew install graphviz
$ brew graph | dot -Tsvg -ohomebrew.html
$ open homebrew.html
"""
from __future__ import with_statement
from contextlib import contextmanager
import re
from subprocess import Popen, PIPE
import sys
def run(command, print_command=False):
"Run a command, returning the exit code and output."
if print_command: print command
p = Popen(command, stdout=PIPE)
output, errput = p.communicate()
return p.returncode, output
def _quote_id(id):
return '"' + id.replace('"', '\"') + '"'
def format_attribs(attrib):
if len(attrib) == 0:
return ''
values = ['%s="%s"' % (k, attrib[k]) for k in attrib]
return '[' + ','.join(values) + ']'
class Output(object):
def __init__(self, fd=sys.stdout, tabstyle=" "):
self.fd = fd
self.tabstyle = tabstyle
self.tablevel = 0
def close(self):
self.fd = None
def out(self, s):
self.tabout()
self.fd.write(s)
def outln(self, s=None):
if s is not None:
self.tabout()
self.fd.write(s)
self.fd.write('\n')
@contextmanager
def indented(self):
self.indent()
yield self
self.dedent()
def indent(self):
self.tablevel += 1
def dedent(self):
if self.tablevel == 0:
raise Exception('No existing indent level.')
self.tablevel -= 1
def tabout(self):
if self.tablevel:
self.fd.write(self.tabstyle * self.tablevel)
class NodeContainer(object):
def __init__(self):
self.nodes = list()
self.node_defaults = dict()
# Stack of node attribs
self._node_styles = list()
def _node_style(self):
if (len(self._node_styles) > 0):
return self._node_styles[-1]
else:
return dict()
def _push_node_style(self, attrib):
self._node_styles.append(attrib)
def _pop_node_style(self):
return self._node_styles.pop()
@contextmanager
def node_styles(self, attrib):
self._push_node_style(attrib)
yield
self._pop_node_style()
def node(self, nodeid, label, attrib=None):
_attrib = dict(self._node_style())
if attrib is not None:
_attrib.update(attrib)
n = Node(nodeid, label, _attrib)
self.nodes.append(n)
return n
def nodes_to_dot(self, out):
if len(self.node_defaults) > 0:
out.outln("node " + format_attribs(self.node_defaults) + ";")
if len(self.nodes) == 0:
return
id_width = max([len(_quote_id(n.id)) for n in self.nodes])
for node in self.nodes:
node.to_dot(out, id_width)
class Node(object):
def __init__(self, nodeid, label, attrib=None):
self.id = nodeid
self.label = label
self.attrib = attrib if attrib is not None else dict()
def as_dot(self, id_width=1):
_attribs = dict(self.attrib)
_attribs['label'] = self.label
return '%-*s %s' % (id_width, _quote_id(self.id), format_attribs(_attribs))
def to_dot(self, out, id_width=1):
out.outln(self.as_dot(id_width))
class ClusterContainer(object):
def __init__(self):
self.clusters = list()
def cluster(self, clusterid, label, attrib=None):
c = Cluster(clusterid, label, self, attrib)
self.clusters.append(c)
return c
class Cluster(NodeContainer, ClusterContainer):
def __init__(self, clusterid, label, parentcluster=None, attrib=None):
NodeContainer.__init__(self)
ClusterContainer.__init__(self)
self.id = clusterid
self.label = label
self.attrib = attrib if attrib is not None else dict()
self.parentcluster = parentcluster
def cluster_id(self):
return _quote_id("cluster_" + self.id)
def to_dot(self, out):
out.outln("subgraph %s {" % self.cluster_id())
with out.indented():
# If the label is an HTML-like string (starts and end with '<' and '>', respectively),
# don't put quotes around it (or GraphViz won't recognize it.)
if self.label[0] == '<' and self.label[-1] == '>':
out.outln('label = %s;' % self.label)
else:
out.outln('label = "%s"' % self.label)
for k in self.attrib:
if isinstance(self.attrib[k], dict):
out.outln('%s %s;' % (k, format_attribs(self.attrib[k])))
else:
out.outln('%s = "%s";' % (k, self.attrib[k]))
for cluster in self.clusters:
cluster.to_dot(out)
self.nodes_to_dot(out)
out.outln("}")
class Edge(object):
def __init__(self, source, target, attrib=None):
if attrib is None:
attrib = dict()
self.source = source
self.target = target
self.attrib = attrib
def to_dot(self, out):
out.outln(self.as_dot())
def as_dot(self):
return " ".join((_quote_id(self.source), "->", _quote_id(self.target), format_attribs(self.attrib)))
class EdgeContainer(object):
def __init__(self):
self.edges = list()
self.edge_defaults = dict()
# Stack of edge attribs
self._edge_styles = list()
def _edge_style(self):
if (len(self._edge_styles) > 0):
return self._edge_styles[-1]
else:
return dict()
def _push_edge_style(self, attrib):
self._edge_styles.append(attrib)
def _pop_edge_style(self):
return self._edge_styles.pop()
@contextmanager
def edge_styles(self, attrib):
self._push_edge_style(attrib)
yield
self._pop_edge_style()
def link(self, source, target, attrib=None):
_attrib = dict(self._edge_style())
if attrib is not None:
_attrib.update(attrib)
e = Edge(source, target, _attrib)
self.edges.append(e)
return e
def edges_to_dot(self, out):
if len(self.edge_defaults) > 0:
out.outln("edge " + format_attribs(self.edge_defaults) + ";")
if len(self.edges) == 0:
return
for edge in self.edges:
edge.to_dot(out)
class Graph(NodeContainer, EdgeContainer, ClusterContainer):
"""
Contains the nodes, edges, and subgraph definitions for a graph to be
turned into a Graphviz DOT file.
"""
def __init__(self, label=None, attrib=None):
NodeContainer.__init__(self)
EdgeContainer.__init__(self)
ClusterContainer.__init__(self)
self.label = label if label is not None else "Default Label"
self.attrib = attrib if attrib is not None else dict()
def dot(self, fd=sys.stdout):
try:
self.o = Output(fd)
self._dot()
finally:
self.o.close()
def _dot(self):
self.o.outln("digraph G {")
with self.o.indented():
self.o.outln('label = "%s"' % self.label)
for k in self.attrib:
# If the value of the attrib is a dictionary, write it out in special array form
if isinstance(self.attrib[k], dict):
self.o.outln('%s %s;' % (k, format_attribs(self.attrib[k])))
else:
self.o.outln('%s = "%s";' % (k, self.attrib[k]))
self.nodes_to_dot(self.o)
for cluster in self.clusters:
self.o.outln()
cluster.to_dot(self.o)
self.o.outln()
self.edges_to_dot(self.o)
self.o.outln("}")
def main():
cmd = ["brew", "deps"]
if sys.argv[1:]:
if '--all' in sys.argv[1:]:
show = 'all'
cmd.extend(['--all'])
else:
show = 'one'
hideOrphaned = False
cmd.extend(sys.argv[1:])
else:
show = 'installed'
cmd.extend(['--installed'])
code, output = run(cmd)
output = output.strip()
depgraph = list()
for f in output.split("\n"):
stuff = f.split(":",2)
if len(stuff) < 2:
continue
name = stuff[0]
deps = stuff[1].strip()
if not deps:
deps = list()
else:
deps = deps.split(" ")
depgraph.append((name, deps))
# We need newrank = True to make sure clusters respect rank = "source". Otherwise, we may get
# random nodes next to "Safe to Remove" cluster, despite them not being a part of that cluster.
hb = Graph("Homebrew Dependencies", attrib={
'labelloc':'t', 'rankdir': 'TB' , 'ranksep':'3', 'newrank': True,
'graph': {'fontname': 'Futura-Medium', 'fontsize': 48},
'node': {'fontname': 'HelveticaNeue', 'fontsize': 14}
})
# Independent formulas (those that are not dependended on by any other formula) get placed in
# their own subgraph so we can align them together on the left.
if show == 'installed':
# We use a HTML-like label to give the label a little bit of padding at the top
sub = hb.cluster("independent", "<<font point-size=\"15\"><br/></font>Safe to Remove>",
attrib={'rank': 'source', 'style': 'filled', 'fillcolor': '#F0F0F0', 'color': 'invis',
'margin': '25,1', 'graph': {'fontname': 'Helvetica-LightOblique', 'fontsize': 24}})
else:
sub = hb
seen = set()
def addNode(graph, name):
if name not in seen:
graph.node(name, name, attrib={'shape': 'box'})
seen.add(name)
return True
return False
independent = set()
for f in depgraph:
# Filter out orphan formulas when showing all, to cut down on noise
if show == 'all' and len(f[1]) == 0:
continue
independent.add(f[0])
for d in f[1]:
independent.discard(d)
hb.link(f[0], d)
# Children we can add right away because we don't care where they go
addNode(hb, d)
# For all installed formulas, place them in the 'indep' subgraph iff they
# are not depended on by other formulas, i.e. are root nodes.
for d in independent:
addNode(sub, d)
hb.dot()
if __name__ == "__main__":
main()