~abentley/bzrtools/bzrtools.dev

« back to all changes in this revision

Viewing changes to graph.py

  • Committer: Aaron Bentley
  • Date: 2012-01-20 02:00:40 UTC
  • Revision ID: aaron@aaronbentley.com-20120120020040-7y4c93fhnnahidxg
Remove rspush

Show diffs side-by-side

added added

removed removed

Lines of Context:
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
 
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
 
3
21
from bzrlib.branch import Branch
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
 
                })
 
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
            }
19
118
 
20
119
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
 
 
43
120
def can_skip(rev_id, descendants, ancestors):
44
121
    if rev_id not in descendants:
45
122
        return False
 
123
    elif rev_id not in ancestors:
 
124
        return False
46
125
    elif len(ancestors[rev_id]) != 1:
47
126
        return False
48
 
    elif len(descendants[ancestors[rev_id][0]]) != 1:
 
127
    elif len(descendants[list(ancestors[rev_id])[0]]) != 1:
49
128
        return False
50
129
    elif len(descendants[rev_id]) != 1:
51
130
        return False
52
131
    else:
53
132
        return True
54
133
 
55
 
def compact_descendants(descendants, ancestors):
56
 
    new_descendants={}
 
134
def compact_ancestors(descendants, ancestors, exceptions=()):
 
135
    new_ancestors={}
57
136
    skip = set()
58
 
    for me, my_descendants in descendants.iteritems():
 
137
    for me, my_parents in ancestors.iteritems():
59
138
        if me in skip:
60
139
            continue
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)
 
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)
129
165
    except NoSuchRevision:
130
166
        try:
131
 
            committer = '-'.join(rev_id.split('-')[:-2])\
132
 
                .strip(' ')
 
167
            committer = '-'.join(rev_id.split('-')[:-2]).strip(' ')
 
168
            if committer == '':
 
169
                return None, None, None, None
133
170
        except ValueError:
134
 
            committer = '' 
 
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')
135
179
    if '@' in committer:
136
180
        try:
137
181
            committer = mail_map[committer]
141
185
        committer = committer_alias[committer]
142
186
    except KeyError:
143
187
        pass
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
 
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
164
258
        except ValueError:
165
259
            n_rev = None
166
260
        try:
167
 
            m_rev = m_history.index(node) + 1
 
261
            m_rev = self.m_history.index(node) + 1
168
262
        except ValueError:
169
263
            m_rev = None
170
264
        if (n_rev, m_rev) == (None, None):
171
 
            name = node[-4:]
 
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:]
172
270
            cluster = None
173
271
        elif n_rev == m_rev:
174
272
            name = "rR%d" % n_rev
183
281
            color = "#ff9900"
184
282
        elif (None, None) == (n_rev, m_rev):
185
283
            cluster = None
186
 
            if node in common:
 
284
            if node in self.common:
187
285
                color = "#6699ff"
188
286
            else:
189
 
                color = None
 
287
                color = "white"
190
288
        elif n_rev is not None:
191
289
            cluster = "my_history"
192
290
            color = "#ffff00"
194
292
            assert m_rev is not None
195
293
            cluster = "other_history"
196
294
            color = "#ff0000"
197
 
        if node == base:
198
 
            color = "#33ff99"
 
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'
199
303
 
200
304
        label = [name]
201
 
        committer = get_committer(node, branch)
 
305
        committer, message, nick, date = get_rev_info(node,
 
306
                                                      self.branch.repository)
202
307
        if committer is not None:
203
308
            label.append(committer)
204
309
 
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
 
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
221
363
 
222
364
 
223
365
def write_ancestry_file(branch, filename, collapse=True, antialias=True,
224
 
                        merge_branch=None):
225
 
    b = Branch.open_containing(branch)
226
 
    if merge_branch is None:
227
 
        relations = graph_ancestry(b, collapse)
 
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]
228
370
    else:
229
 
        m = Branch.open_containing(merge_branch)
230
 
        relations = graph_merge_pick(b, m)
 
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()
231
384
 
232
385
    ext = filename.split('.')[-1]
233
 
    if antialias and ext in RSVG_OUTPUT_TYPES:
 
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)
234
392
        try:
235
 
            invoke_dot_aa(dot_output(relations), filename, ext)
 
393
            invoke_dot_aa(output, filename, ext)
 
394
            done = True
236
395
        except NoDot, e:
237
396
            raise BzrCommandError("Can't find 'dot'.  Please ensure Graphviz"\
238
397
                " is installed correctly.")
239
398
        except NoRsvg, e:
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:
 
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:
251
420
        print "Unknown file extension: %s" % ext
252