~abentley/bzrtools/bzrtools.dev

« back to all changes in this revision

Viewing changes to graph.py

  • Committer: Aaron Bentley
  • Date: 2005-09-20 15:02:04 UTC
  • Revision ID: abentley@panoramicfeedback.com-20050920150204-2abd154eca9213bc
Moved extention lists to dotgraph

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005, 2008 Aaron Bentley
2
 
# <aaron@aaronbentley.com>
3
 
#
4
 
#    This program is free software; you can redistribute it and/or modify
5
 
#    it under the terms of the GNU General Public License as published by
6
 
#    the Free Software Foundation; either version 2 of the License, or
7
 
#    (at your option) any later version.
8
 
#
9
 
#    This program is distributed in the hope that it will be useful,
10
 
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 
#    GNU General Public License for more details.
13
 
#
14
 
#    You should have received a copy of the GNU General Public License
15
 
#    along with this program; if not, write to the Free Software
16
 
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17
 
 
18
 
 
19
 
import time
20
 
 
 
1
from dotgraph import Node, dot_output, invoke_dot, invoke_dot_aa, NoDot, NoRsvg
 
2
from dotgraph import mail_map, RSVG_OUTPUT_TYPES, DOT_OUTPUT_TYPES
21
3
from bzrlib.branch import Branch
22
 
from bzrlib.errors import BzrCommandError, NoSuchRevision
23
 
from bzrlib.revision import NULL_REVISION
24
 
 
25
 
from bzrtools import short_committer
26
 
from dotgraph import (
27
 
    dot_output,
28
 
    DOT_OUTPUT_TYPES,
29
 
    Edge,
30
 
    invoke_dot,
31
 
    invoke_dot_aa,
32
 
    invoke_dot_html,
33
 
    Node,
34
 
    NoDot,
35
 
    NoRsvg,
36
 
    RSVG_OUTPUT_TYPES,
37
 
    )
38
 
 
39
 
 
40
 
def max_distance(node, ancestors, distances, root_descendants):
41
 
    """Calculate the max distance to an ancestor.
42
 
    Return None if not all possible ancestors have known distances"""
43
 
    best = None
44
 
    if node in distances:
45
 
        best = distances[node]
46
 
    for ancestor in ancestors[node]:
47
 
        # skip ancestors we will never traverse:
48
 
        if root_descendants is not None and ancestor not in root_descendants:
49
 
            continue
50
 
        # An ancestor which is not listed in ancestors will never be in
51
 
        # distances, so we pretend it never existed.
52
 
        if ancestor not in ancestors:
53
 
            continue
54
 
        if ancestor not in distances:
55
 
            return None
56
 
        if best is None or distances[ancestor]+1 > best:
57
 
            best = distances[ancestor] + 1
58
 
    return best
59
 
 
60
 
 
61
 
def node_distances(graph, ancestors, start, root_descendants=None):
62
 
    """Produce a list of nodes, sorted by distance from a start node.
63
 
    This is an algorithm devised by Aaron Bentley, because applying Dijkstra
64
 
    backwards seemed too complicated.
65
 
 
66
 
    For each node, we walk its descendants.  If all the descendant's ancestors
67
 
    have a max-distance-to-start, (excluding ones that can never reach start),
68
 
    we calculate their max-distance-to-start, and schedule their descendants.
69
 
 
70
 
    So when a node's last parent acquires a distance, it will acquire a
71
 
    distance on the next iteration.
72
 
 
73
 
    Once we know the max distances for all nodes, we can return a list sorted
74
 
    by distance, farthest first.
75
 
    """
76
 
    distances = {start: 0}
77
 
    lines = set([start])
78
 
    while len(lines) > 0:
79
 
        new_lines = set()
80
 
        for line in lines:
81
 
            line_descendants = graph[line]
82
 
            for descendant in line_descendants:
83
 
                distance = max_distance(descendant, ancestors, distances,
84
 
                                        root_descendants)
85
 
                if distance is None:
86
 
                    continue
87
 
                distances[descendant] = distance
88
 
                new_lines.add(descendant)
89
 
        lines = new_lines
90
 
    return distances
91
 
 
92
 
 
93
 
def nodes_by_distance(distances):
94
 
    """Return a list of nodes sorted by distance"""
95
 
    def by_distance(n):
96
 
        return distances[n],n
97
 
 
98
 
    node_list = distances.keys()
99
 
    node_list.sort(key=by_distance, reverse=True)
100
 
    return node_list
101
 
 
102
 
 
103
 
def select_farthest(distances, common):
104
 
    """Return the farthest common node, or None if no node qualifies."""
105
 
    node_list = nodes_by_distance(distances)
106
 
    for node in node_list:
107
 
        if node in common:
108
 
            return node
109
 
 
110
 
 
111
 
mail_map = {'aaron.bentley@utoronto.ca'     : 'Aaron Bentley',
112
 
            'abentley@panoramicfeedback.com': 'Aaron Bentley',
113
 
            'abentley@lappy'                : 'Aaron Bentley',
114
 
            'john@arbash-meinel.com'        : 'John Arbash Meinel',
115
 
            'mbp@sourcefrog.net'            : 'Martin Pool',
116
 
            'robertc@robertcollins.net'     : 'Robert Collins',
117
 
            }
 
4
from bzrlib.errors import BzrCommandError, NoCommonRoot, NoSuchRevision
 
5
from bzrlib.fetch import greedy_fetch
 
6
from bzrlib.graph import node_distances, select_farthest
 
7
from bzrlib.revision import combined_graph, MultipleRevisionSources
 
8
import bzrlib.errors
 
9
import re
 
10
import os.path
 
11
 
 
12
mail_map.update({'aaron.bentley@utoronto.ca'     : 'Aaron Bentley',
 
13
                 'abentley@panoramicfeedback.com': 'Aaron Bentley',
 
14
                 'abentley@lappy'                : 'Aaron Bentley',
 
15
                 'john@arbash-meinel.com'        : 'John Arbash Meinel',
 
16
                 'mbp@sourcefrog.net'            : 'Martin Pool',
 
17
                 'robertc@robertcollins.net'     : 'Robert Collins',
 
18
                })
118
19
 
119
20
committer_alias = {'abentley': 'Aaron Bentley'}
 
21
def add_relations(rev_id):
 
22
    if rev_id in ancestors:
 
23
        return
 
24
    print rev_id
 
25
    if rev_id not in nodes:
 
26
        nodes[rev_id] = Node("n%d" % counter, label = rev_id)
 
27
        counter += 1
 
28
    revision = branch.get_revision(rev_id)
 
29
    ancestors [rev_id] = []
 
30
    for p in (p.revision_id for p in revision.parents):
 
31
        add_relations(p)
 
32
        if p not in descendants:
 
33
            descendants[p] = []
 
34
        descendants[p].append(rev_id)
 
35
        ancestors [rev_id].append(rev_id)
 
36
 
 
37
def short_committer(committer):
 
38
    new_committer = re.sub('<.*>', '', committer).strip(' ')
 
39
    if len(new_committer) < 2:
 
40
        return committer
 
41
    return new_committer
 
42
 
120
43
def can_skip(rev_id, descendants, ancestors):
121
44
    if rev_id not in descendants:
122
45
        return False
123
 
    elif rev_id not in ancestors:
124
 
        return False
125
46
    elif len(ancestors[rev_id]) != 1:
126
47
        return False
127
 
    elif len(descendants[list(ancestors[rev_id])[0]]) != 1:
 
48
    elif len(descendants[ancestors[rev_id][0]]) != 1:
128
49
        return False
129
50
    elif len(descendants[rev_id]) != 1:
130
51
        return False
131
52
    else:
132
53
        return True
133
54
 
134
 
def compact_ancestors(descendants, ancestors, exceptions=()):
135
 
    new_ancestors={}
 
55
def compact_descendants(descendants, ancestors):
 
56
    new_descendants={}
136
57
    skip = set()
137
 
    for me, my_parents in ancestors.iteritems():
 
58
    for me, my_descendants in descendants.iteritems():
138
59
        if me in skip:
139
60
            continue
140
 
        new_ancestors[me] = {}
141
 
        for parent in my_parents:
142
 
            new_parent = parent
143
 
            distance = 0
144
 
            while can_skip(new_parent, descendants, ancestors):
145
 
                if new_parent in exceptions:
146
 
                    break
147
 
                skip.add(new_parent)
148
 
                if new_parent in new_ancestors:
149
 
                    del new_ancestors[new_parent]
150
 
                new_parent = list(ancestors[new_parent])[0]
151
 
                distance += 1
152
 
            new_ancestors[me][new_parent] = distance
153
 
    return new_ancestors
154
 
 
155
 
def get_rev_info(rev_id, source):
156
 
    """Return the committer, message, nick and date of a revision."""
157
 
    committer = None
158
 
    message = None
159
 
    date = None
160
 
    nick = None
161
 
    if rev_id == 'null:':
162
 
        return None, 'Null Revision', None, None
163
 
    try:
164
 
        rev = source.get_revision(rev_id)
 
61
        new_descendants[me] = []
 
62
        for descendant in my_descendants:
 
63
            new_descendant = descendant
 
64
            while can_skip(new_descendant, descendants, ancestors):
 
65
                skip.add(new_descendant)
 
66
                if new_descendant in new_descendants:
 
67
                    del new_descendants[new_descendant]
 
68
                new_descendant = descendants[new_descendant][0]
 
69
            new_descendants[me].append(new_descendant)
 
70
    return new_descendants    
 
71
 
 
72
 
 
73
def graph_ancestry(branch, collapse=True):
 
74
    nodes = {}
 
75
    q = ((i+1, n) for (i, n) in enumerate(branch.revision_history()))
 
76
    r = 1
 
77
    try:
 
78
        branch_name = os.path.basename(branch.base)
 
79
    except AttributeError:
 
80
        branch_name = "main"
 
81
    for (revno, rev_id) in q:
 
82
        nodes[rev_id] = Node("R%d" % revno, color="#ffff00", rev_id=rev_id, 
 
83
                             cluster=branch_name)
 
84
 
 
85
    ancestors = {} 
 
86
    descendants = {}
 
87
    counter = 0
 
88
    lines = [branch.last_patch()]
 
89
    while len(lines) > 0:
 
90
        new_lines = set()
 
91
        for rev_id in lines:
 
92
            if rev_id not in nodes:
 
93
                nodes[rev_id] = Node("n%d" % counter, label=rev_id, 
 
94
                                     rev_id=rev_id)
 
95
                counter+=1
 
96
                
 
97
            try:
 
98
                revision = branch.get_revision(rev_id)
 
99
            except bzrlib.errors.NoSuchRevision:
 
100
                nodes[rev_id].node_style.append('dotted')
 
101
                continue
 
102
            if nodes[rev_id].committer is None:
 
103
                nodes[rev_id].committer = short_committer(revision.committer)
 
104
            parent_ids = [r.revision_id for r in revision.parents]
 
105
            ancestors [rev_id] = parent_ids
 
106
            for parent in parent_ids:
 
107
                if parent not in ancestors:
 
108
                    new_lines.add(parent)
 
109
                    descendants[parent] = []
 
110
                descendants[parent].append(rev_id)
 
111
        lines = new_lines
 
112
    node_relations = []
 
113
 
 
114
    for node in nodes.itervalues():
 
115
        node.label = node.get_label()
 
116
    if collapse:
 
117
        visible_descendants = compact_descendants(descendants, ancestors)
 
118
    else:
 
119
        visible_descendants = descendants
 
120
                
 
121
    for key, values in visible_descendants.iteritems():
 
122
        for value in values:
 
123
            node_relations.append((nodes[key], nodes[value]))
 
124
    return node_relations
 
125
 
 
126
def get_committer(rev_id, source):
 
127
    try:
 
128
        committer = short_committer(source.get_revision(rev_id).committer)
165
129
    except NoSuchRevision:
166
130
        try:
167
 
            committer = '-'.join(rev_id.split('-')[:-2]).strip(' ')
168
 
            if committer == '':
169
 
                return None, None, None, None
 
131
            committer = '-'.join(rev_id.split('-')[:-2])\
 
132
                .strip(' ')
170
133
        except ValueError:
171
 
            return None, None, None, None
172
 
    else:
173
 
        committer = short_committer(rev.committer)
174
 
        if rev.message is not None:
175
 
            message = rev.message.split('\n')[0]
176
 
        gmtime = time.gmtime(rev.timestamp + (rev.timezone or 0))
177
 
        date = time.strftime('%Y/%m/%d', gmtime)
178
 
        nick = rev.properties.get('branch-nick')
 
134
            committer = '' 
179
135
    if '@' in committer:
180
136
        try:
181
137
            committer = mail_map[committer]
185
141
        committer = committer_alias[committer]
186
142
    except KeyError:
187
143
        pass
188
 
    return committer, message, nick, date
189
 
 
190
 
class Grapher(object):
191
 
 
192
 
    def __init__(self, branch, other_branch=None):
193
 
        object.__init__(self)
194
 
        self.branch = branch
195
 
        self.other_branch = other_branch
196
 
        if other_branch is not None:
197
 
            other_repo = other_branch.repository
198
 
            revision_b = self.other_branch.last_revision()
199
 
        else:
200
 
            other_repo = None
201
 
            revision_b = None
202
 
        self.graph = self.branch.repository.get_graph(other_repo)
203
 
        revision_a = self.branch.last_revision()
204
 
        self.scan_graph(revision_a, revision_b)
205
 
        self.n_history = branch.revision_history()
206
 
        self.n_revnos = branch.get_revision_id_to_revno_map()
207
 
        self.distances = node_distances(self.descendants, self.ancestors,
208
 
                                        self.root)
209
 
        if other_branch is not None:
210
 
            self.base = select_farthest(self.distances, self.common)
211
 
            self.m_history = other_branch.revision_history()
212
 
            self.m_revnos = other_branch.get_revision_id_to_revno_map()
213
 
            self.new_base = self.graph.find_unique_lca(revision_a,
214
 
                                                       revision_b)
215
 
            self.lcas = self.graph.find_lca(revision_a, revision_b)
216
 
        else:
217
 
            self.base = None
218
 
            self.new_base = None
219
 
            self.lcas = set()
220
 
            self.m_history = []
221
 
            self.m_revnos = {}
222
 
 
223
 
    def scan_graph(self, revision_a, revision_b):
224
 
        a_ancestors = dict(self.graph.iter_ancestry([revision_a]))
225
 
        self.ancestors = a_ancestors
226
 
        self.root = NULL_REVISION
227
 
        if revision_b is not None:
228
 
            b_ancestors = dict(self.graph.iter_ancestry([revision_b]))
229
 
            self.common = set(a_ancestors.keys())
230
 
            self.common.intersection_update(b_ancestors)
231
 
            self.ancestors.update(b_ancestors)
232
 
        else:
233
 
            self.common = []
234
 
            revision_b = None
235
 
        self.descendants = {}
236
 
        ghosts = set()
237
 
        for revision, parents in self.ancestors.iteritems():
238
 
            self.descendants.setdefault(revision, [])
239
 
            if parents is None:
240
 
                ghosts.add(revision)
241
 
                parents = [NULL_REVISION]
242
 
            for parent in parents:
243
 
                self.descendants.setdefault(parent, []).append(revision)
244
 
        for ghost in ghosts:
245
 
            self.ancestors[ghost] = [NULL_REVISION]
246
 
 
247
 
    @staticmethod
248
 
    def _get_revno_str(prefix, revno_map, revision_id):
249
 
        try:
250
 
            revno = revno_map[revision_id]
251
 
        except KeyError:
252
 
            return None
253
 
        return '%s%s' % (prefix, '.'.join(str(n) for n in revno))
254
 
 
255
 
    def dot_node(self, node, num):
256
 
        try:
257
 
            n_rev = self.n_history.index(node) + 1
 
144
    return committer
 
145
 
 
146
 
 
147
def graph_merge_pick(branch, other_branch):
 
148
    greedy_fetch(branch, other_branch)
 
149
    revision_a = branch.last_patch()
 
150
    revision_b = other_branch.last_patch()
 
151
    try:
 
152
        root, ancestors, descendants, common = \
 
153
            combined_graph(revision_a, revision_b, branch)
 
154
    except bzrlib.errors.NoCommonRoot:
 
155
        raise bzrlib.errors.NoCommonAncestor(revision_a, revision_b)
 
156
    distances = node_distances(descendants, ancestors, root)
 
157
    base = select_farthest(distances, common)
 
158
    n_history = branch.revision_history()
 
159
    m_history = []
 
160
    dot_nodes = {}
 
161
    def dot_node(node, num):
 
162
        try:
 
163
            n_rev = n_history.index(node) + 1
258
164
        except ValueError:
259
165
            n_rev = None
260
166
        try:
261
 
            m_rev = self.m_history.index(node) + 1
 
167
            m_rev = m_history.index(node) + 1
262
168
        except ValueError:
263
169
            m_rev = None
264
170
        if (n_rev, m_rev) == (None, None):
265
 
            name = self._get_revno_str('r', self.n_revnos, node)
266
 
            if name is None:
267
 
                name = self._get_revno_str('R', self.m_revnos, node)
268
 
            if name is None:
269
 
                name = node[-5:]
 
171
            name = node[-4:]
270
172
            cluster = None
271
173
        elif n_rev == m_rev:
272
174
            name = "rR%d" % n_rev
281
183
            color = "#ff9900"
282
184
        elif (None, None) == (n_rev, m_rev):
283
185
            cluster = None
284
 
            if node in self.common:
 
186
            if node in common:
285
187
                color = "#6699ff"
286
188
            else:
287
 
                color = "white"
 
189
                color = None
288
190
        elif n_rev is not None:
289
191
            cluster = "my_history"
290
192
            color = "#ffff00"
292
194
            assert m_rev is not None
293
195
            cluster = "other_history"
294
196
            color = "#ff0000"
295
 
        if node in self.lcas:
296
 
            color = "#9933cc"
297
 
        if node == self.base:
298
 
            color = "#669933"
299
 
            if node == self.new_base:
300
 
                color = "#33ff33"
301
 
        if node == self.new_base:
302
 
            color = '#33cc99'
 
197
        if node == base:
 
198
            color = "#33ff99"
303
199
 
304
200
        label = [name]
305
 
        committer, message, nick, date = get_rev_info(node,
306
 
                                                      self.branch.repository)
 
201
        committer = get_committer(node, branch)
307
202
        if committer is not None:
308
203
            label.append(committer)
309
204
 
310
 
        if nick is not None:
311
 
            label.append(nick)
312
 
 
313
 
        if date is not None:
314
 
            label.append(date)
315
 
 
316
 
        if node in self.distances:
317
 
            rank = self.distances[node]
318
 
            label.append('d%d' % self.distances[node])
319
 
        else:
320
 
            rank = None
321
 
 
322
 
        d_node = Node("n%d" % num, color=color, label="\\n".join(label),
323
 
                    rev_id=node, cluster=cluster, message=message,
324
 
                    date=date)
325
 
        d_node.rank = rank
326
 
 
327
 
        if node not in self.ancestors:
328
 
            d_node.node_style.append('dotted')
329
 
 
330
 
        return d_node
331
 
 
332
 
    def get_relations(self, collapse=False, max_distance=None):
333
 
        dot_nodes = {}
334
 
        node_relations = []
335
 
        num = 0
336
 
        if collapse:
337
 
            exceptions = self.lcas.union([self.base, self.new_base])
338
 
            visible_ancestors = compact_ancestors(self.descendants,
339
 
                                                  self.ancestors,
340
 
                                                  exceptions)
341
 
        else:
342
 
            visible_ancestors = {}
343
 
            for revision, parents in self.ancestors.iteritems():
344
 
                visible_ancestors[revision] = dict((p, 0) for p in parents)
345
 
        if max_distance is not None:
346
 
            min_distance = max(self.distances.values()) - max_distance
347
 
            visible_ancestors = dict((n, p) for n, p in
348
 
                                     visible_ancestors.iteritems() if
349
 
                                     self.distances[n] >= min_distance)
350
 
        for node, parents in visible_ancestors.iteritems():
351
 
            if node not in dot_nodes:
352
 
                dot_nodes[node] = self.dot_node(node, num)
353
 
                num += 1
354
 
            for parent, skipped in parents.iteritems():
355
 
                if parent not in dot_nodes:
356
 
                    dot_nodes[parent] = self.dot_node(parent, num)
357
 
                    num += 1
358
 
                edge = Edge(dot_nodes[parent], dot_nodes[node])
359
 
                if skipped != 0:
360
 
                    edge.label = "%d" % skipped
361
 
                node_relations.append(edge)
362
 
        return node_relations
 
205
        if node in distances:
 
206
            label.append('d%d' % distances[node])
 
207
        return Node("n%d" % num, color=color, label="\\n".join(label), 
 
208
                    rev_id=node, cluster=cluster)
 
209
            
 
210
            
 
211
    for num,node in enumerate(descendants):
 
212
        dot_nodes[node] = dot_node(node, num)
 
213
 
 
214
    node_relations = []
 
215
    for node, parents in ancestors.iteritems():
 
216
        if node not in dot_nodes:
 
217
            dot_nodes[node] = dot_node(node, 100000)
 
218
        for parent in parents:
 
219
            node_relations.append((dot_nodes[parent], dot_nodes[node]))
 
220
    return node_relations
363
221
 
364
222
 
365
223
def write_ancestry_file(branch, filename, collapse=True, antialias=True,
366
 
                        merge_branch=None, ranking="forced", max_distance=None):
367
 
    b = Branch.open_containing(branch)[0]
368
 
    if merge_branch is not None:
369
 
        m = Branch.open_containing(merge_branch)[0]
 
224
                        merge_branch=None):
 
225
    b = Branch.open_containing(branch)
 
226
    if merge_branch is None:
 
227
        relations = graph_ancestry(b, collapse)
370
228
    else:
371
 
        m = None
372
 
    b.lock_write()
373
 
    try:
374
 
        if m is not None:
375
 
            m.lock_read()
376
 
        try:
377
 
            grapher = Grapher(b, m)
378
 
            relations = grapher.get_relations(collapse, max_distance)
379
 
        finally:
380
 
            if m is not None:
381
 
                m.unlock()
382
 
    finally:
383
 
        b.unlock()
 
229
        m = Branch.open_containing(merge_branch)
 
230
        relations = graph_merge_pick(b, m)
384
231
 
385
232
    ext = filename.split('.')[-1]
386
 
    output = dot_output(relations, ranking)
387
 
    done = False
388
 
    if ext not in RSVG_OUTPUT_TYPES:
389
 
        antialias = False
390
 
    if antialias:
391
 
        output = list(output)
 
233
    if antialias and ext in RSVG_OUTPUT_TYPES:
392
234
        try:
393
 
            invoke_dot_aa(output, filename, ext)
394
 
            done = True
 
235
            invoke_dot_aa(dot_output(relations), filename, ext)
395
236
        except NoDot, e:
396
237
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
397
238
                " is installed correctly.")
398
239
        except NoRsvg, e:
399
 
            print "Not antialiasing because rsvg (from librsvg-bin) is not"\
400
 
                " installed."
401
 
            antialias = False
402
 
    if ext in DOT_OUTPUT_TYPES and not antialias and not done:
403
 
        try:
404
 
            invoke_dot(output, filename, ext)
405
 
            done = True
406
 
        except NoDot, e:
407
 
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
408
 
                " is installed correctly.")
409
 
    elif ext == 'dot' and not done:
410
 
        my_file = file(filename, 'wb')
411
 
        for fragment in output:
412
 
            my_file.write(fragment.encode('utf-8'))
413
 
    elif ext == 'html':
414
 
        try:
415
 
            invoke_dot_html(output, filename)
416
 
        except NoDot, e:
417
 
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
418
 
                " is installed correctly.")
419
 
    elif not done:
 
240
            raise BzrCommandError("Can't find 'rsvg'.  Please ensure "\
 
241
                "librsvg-bin is installed correctly, or use --noantialias.")
 
242
    elif ext in DOT_OUTPUT_TYPES:
 
243
        try:
 
244
            invoke_dot(dot_output(relations), filename, ext)
 
245
        except NoDot, e:
 
246
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
 
247
                " is installed correctly, or use --noantialias")
 
248
    elif ext=='dot':
 
249
        file(filename, 'wb').write("".join(list(dot_output(relations))))
 
250
    else:
420
251
        print "Unknown file extension: %s" % ext
 
252