Merged be.doc documentation rewrite + bugfixes + new bugs
[be.git] / libbe / diff.py
1 # Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc.
2 #                         Gianluca Montecchi <gian@grys.it>
3 #                         W. Trevor King <wking@drexel.edu>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 """Compare two bug trees."""
20
21 import difflib
22 import types
23
24 import libbe
25 import libbe.bugdir
26 import libbe.bug
27 import libbe.util.tree
28 from libbe.storage.util.settings_object import setting_name_to_attr_name
29 from libbe.util.utility import time_to_str
30
31
32 class SubscriptionType (libbe.util.tree.Tree):
33     """
34     Trees of subscription types to allow users to select exactly what
35     notifications they want to subscribe to.
36     """
37     def __init__(self, type_name, *args, **kwargs):
38         libbe.util.tree.Tree.__init__(self, *args, **kwargs)
39         self.type = type_name
40     def __str__(self):
41         return self.type
42     def __cmp__(self, other):
43         return cmp(self.type, other.type)
44     def __repr__(self):
45         return '<SubscriptionType: %s>' % str(self)
46     def string_tree(self, indent=0):
47         lines = []
48         for depth,node in self.thread():
49             lines.append('%s%s' % (' '*(indent+2*depth), node))
50         return '\n'.join(lines)
51
52 BUGDIR_ID = 'DIR'
53 BUGDIR_TYPE_NEW = SubscriptionType('new')
54 BUGDIR_TYPE_MOD = SubscriptionType('mod')
55 BUGDIR_TYPE_REM = SubscriptionType('rem')
56 BUGDIR_TYPE_ALL = SubscriptionType('all',
57                       [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM])
58
59 # same name as BUGDIR_TYPE_ALL for consistency
60 BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
61
62 INVALID_TYPE = SubscriptionType('INVALID')
63
64 class InvalidType (ValueError):
65     def __init__(self, type_name, type_root):
66         msg = 'Invalid type %s for tree:\n%s' \
67             % (type_name, type_root.string_tree(4))
68         ValueError.__init__(self, msg)
69         self.type_name = type_name
70         self.type_root = type_root
71
72 def type_from_name(name, type_root, default=None, default_ok=False):
73     if name == str(type_root):
74         return type_root
75     for t in type_root.traverse():
76         if name == str(t):
77             return t
78     if default_ok:
79         return default
80     raise InvalidType(name, type_root)
81
82 class Subscription (object):
83     """
84     >>> subscriptions = [Subscription('XYZ', 'all'),
85     ...                  Subscription('DIR', 'new'),
86     ...                  Subscription('ABC', BUG_TYPE_ALL),]
87     >>> print sorted(subscriptions)
88     [<Subscription: DIR (new)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
89     """
90     def __init__(self, id, subscription_type, **kwargs):
91         if 'type_root' not in kwargs:
92             if id == BUGDIR_ID:
93                 kwargs['type_root'] = BUGDIR_TYPE_ALL
94             else:
95                 kwargs['type_root'] = BUG_TYPE_ALL
96         if type(subscription_type) in types.StringTypes:
97             subscription_type = type_from_name(subscription_type, **kwargs)
98         self.id = id
99         self.type = subscription_type
100     def __cmp__(self, other):
101         for attr in 'id', 'type':
102             value = cmp(getattr(self, attr), getattr(other, attr))
103             if value != 0:
104                 if self.id == BUGDIR_ID:
105                     return -1
106                 elif other.id == BUGDIR_ID:
107                     return 1
108                 return value
109     def __str__(self):
110         return str(self.type)
111     def __repr__(self):
112         return '<Subscription: %s (%s)>' % (self.id, self.type)
113
114 def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
115     """
116     >>> subscriptions_from_string(None)
117     [<Subscription: DIR (all)>]
118     >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all')
119     [<Subscription: DIR (new)>, <Subscription: DIR (rem)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
120     >>> subscriptions_from_string('DIR::new')
121     Traceback (most recent call last):
122       ...
123     ValueError: Invalid subscription "DIR::new", should be ID:TYPE
124     """
125     if string == None:
126         return [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
127     subscriptions = []
128     for subscription in string.split(','):
129         fields = subscription.split(':')
130         if len(fields) != 2:
131             raise ValueError('Invalid subscription "%s", should be ID:TYPE'
132                              % subscription)
133         id,type = fields
134         subscriptions.append(Subscription(id, type))
135     return subscriptions
136
137 class DiffTree (libbe.util.tree.Tree):
138     """
139     A tree holding difference data for easy report generation.
140     >>> bugdir = DiffTree('bugdir')
141     >>> bdsettings = DiffTree('settings', data='target: None -> 1.0')
142     >>> bugdir.append(bdsettings)
143     >>> bugs = DiffTree('bugs', 'bug-count: 5 -> 6')
144     >>> bugdir.append(bugs)
145     >>> new = DiffTree('new', 'new bugs: ABC, DEF')
146     >>> bugs.append(new)
147     >>> rem = DiffTree('rem', 'removed bugs: RST, UVW')
148     >>> bugs.append(rem)
149     >>> print bugdir.report_string()
150     target: None -> 1.0
151     bug-count: 5 -> 6
152       new bugs: ABC, DEF
153       removed bugs: RST, UVW
154     >>> print '\\n'.join(bugdir.paths())
155     bugdir
156     bugdir/settings
157     bugdir/bugs
158     bugdir/bugs/new
159     bugdir/bugs/rem
160     >>> bugdir.child_by_path('/') == bugdir
161     True
162     >>> bugdir.child_by_path('/bugs') == bugs
163     True
164     >>> bugdir.child_by_path('/bugs/rem') == rem
165     True
166     >>> bugdir.child_by_path('bugdir') == bugdir
167     True
168     >>> bugdir.child_by_path('bugdir/') == bugdir
169     True
170     >>> bugdir.child_by_path('bugdir/bugs') == bugs
171     True
172     >>> bugdir.child_by_path('/bugs').masked = True
173     >>> print bugdir.report_string()
174     target: None -> 1.0
175     """
176     def __init__(self, name, data=None, data_part_fn=str,
177                  requires_children=False, masked=False):
178         libbe.util.tree.Tree.__init__(self)
179         self.name = name
180         self.data = data
181         self.data_part_fn = data_part_fn
182         self.requires_children = requires_children
183         self.masked = masked
184     def paths(self, parent_path=None):
185         paths = []
186         if parent_path == None:
187             path = self.name
188         else:
189             path = '%s/%s' % (parent_path, self.name)
190         paths.append(path)
191         for child in self:
192             paths.extend(child.paths(path))
193         return paths
194     def child_by_path(self, path):
195         if hasattr(path, 'split'): # convert string path to a list of names
196             names = path.split('/')
197             if names[0] == '':
198                 names[0] = self.name # replace root with self
199             if len(names) > 1 and names[-1] == '':
200                 names = names[:-1] # strip empty tail
201         else: # it was already an array
202             names = path
203         assert len(names) > 0, path
204         if names[0] == self.name:
205             if len(names) == 1:
206                 return self
207             for child in self:
208                 if names[1] == child.name:
209                     return child.child_by_path(names[1:])
210         if len(names) == 1:
211             raise KeyError, "%s doesn't match '%s'" % (names, self.name)
212         raise KeyError, '%s points to child not in %s' % (names, [c.name for c in self])
213     def report_string(self):
214         report = self.report()
215         if report == None:
216             return ''
217         return '\n'.join(report)
218     def report(self, root=None, parent=None, depth=0):
219         if root == None:
220             root = self.make_root()
221         if self.masked == True:
222             return root
223         data_part = self.data_part(depth)
224         if self.requires_children == True \
225                 and len([c for c in self if c.masked == False]) == 0:
226             pass
227         else:
228             self.join(root, parent, data_part)
229             if data_part != None:
230                 depth += 1
231             for child in self:
232                 root = child.report(root, self, depth)
233         return root
234     def make_root(self):
235         return []
236     def join(self, root, parent, data_part):
237         if data_part != None:
238             root.append(data_part)
239     def data_part(self, depth, indent=True):
240         if self.data == None:
241             return None
242         if hasattr(self, '_cached_data_part'):
243             return self._cached_data_part
244         data_part = self.data_part_fn(self.data)
245         if indent == True:
246             data_part_lines = data_part.splitlines()
247             indent = '  '*(depth)
248             line_sep = '\n'+indent
249             data_part = indent+line_sep.join(data_part_lines)
250         self._cached_data_part = data_part
251         return data_part
252
253 class Diff (object):
254     """
255     Difference tree generator for BugDirs.
256     >>> import copy
257     >>> bd = libbe.bugdir.SimpleBugDir(memory=True)
258     >>> bd_new = copy.deepcopy(bd)
259     >>> bd_new.target = '1.0'
260     >>> a = bd_new.bug_from_uuid('a')
261     >>> rep = a.comment_root.new_reply("I'm closing this bug")
262     >>> rep.uuid = 'acom'
263     >>> rep.author = 'John Doe <j@doe.com>'
264     >>> rep.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
265     >>> a.status = 'closed'
266     >>> b = bd_new.bug_from_uuid('b')
267     >>> bd_new.remove_bug(b)
268     >>> c = bd_new.new_bug('Bug C', _uuid='c')
269     >>> d = Diff(bd, bd_new)
270     >>> r = d.report_tree()
271     >>> print '\\n'.join(r.paths())
272     bugdir
273     bugdir/settings
274     bugdir/bugs
275     bugdir/bugs/new
276     bugdir/bugs/new/c
277     bugdir/bugs/rem
278     bugdir/bugs/rem/b
279     bugdir/bugs/mod
280     bugdir/bugs/mod/a
281     bugdir/bugs/mod/a/settings
282     bugdir/bugs/mod/a/comments
283     bugdir/bugs/mod/a/comments/new
284     bugdir/bugs/mod/a/comments/new/acom
285     bugdir/bugs/mod/a/comments/rem
286     bugdir/bugs/mod/a/comments/mod
287     >>> print r.report_string()
288     Changed bug directory settings:
289       target: None -> 1.0
290     New bugs:
291       abc/c:om: Bug C
292     Removed bugs:
293       abc/b:cm: Bug B
294     Modified bugs:
295       abc/a:cm: Bug A
296         Changed bug settings:
297           status: open -> closed
298         New comments:
299           from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
300             I'm closing this bug...
301
302     You can also limit the report generation by providing a list of
303     subscriptions.
304
305     >>> subscriptions = [Subscription('DIR', BUGDIR_TYPE_NEW),
306     ...                  Subscription('b', BUG_TYPE_ALL)]
307     >>> r = d.report_tree(subscriptions)
308     >>> print r.report_string()
309     New bugs:
310       abc/c:om: Bug C
311     Removed bugs:
312       abc/b:cm: Bug B
313
314     While sending subscriptions to report_tree() makes the report
315     generation more efficient (because you may not need to compare
316     _all_ the bugs, etc.), sometimes you will have several sets of
317     subscriptions.  In that case, it's better to run full_report()
318     first, and then use report_tree() to avoid redundant comparisons.
319
320     >>> d.full_report()
321     >>> print d.report_tree([subscriptions[0]]).report_string()
322     New bugs:
323       abc/c:om: Bug C
324     >>> print d.report_tree([subscriptions[1]]).report_string()
325     Removed bugs:
326       abc/b:cm: Bug B
327
328     >>> bd.cleanup()
329     """
330     def __init__(self, old_bugdir, new_bugdir):
331         self.old_bugdir = old_bugdir
332         self.new_bugdir = new_bugdir
333
334     # data assembly methods
335
336     def _changed_bugs(self, subscriptions):
337         """
338         Search for differences in all bugs between .old_bugdir and
339         .new_bugdir.  Returns
340           (added_bugs, modified_bugs, removed_bugs)
341         where added_bugs and removed_bugs are lists of added and
342         removed bugs respectively.  modified_bugs is a list of
343         (old_bug,new_bug) pairs.
344         """
345         bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
346         new_uuids = []
347         old_uuids = []
348         for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD]:
349             if bd_type in bugdir_types:
350                 new_uuids = list(self.new_bugdir.uuids())
351                 break
352         for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_REM]:
353             if bd_type in bugdir_types:
354                 old_uuids = list(self.old_bugdir.uuids())
355                 break
356         subscribed_bugs = []
357         for s in subscriptions:
358             if s.id != BUGDIR_ID:
359                 try:
360                     bug = self.new_bugdir.bug_from_uuid(s.id)
361                 except libbe.bugdir.NoBugMatches:
362                     bug = self.old_bugdir.bug_from_uuid(s.id)
363                 subscribed_bugs.append(bug.uuid)
364         new_uuids.extend([s for s in subscribed_bugs
365                           if self.new_bugdir.has_bug(s)])
366         new_uuids = sorted(set(new_uuids))
367         old_uuids.extend([s for s in subscribed_bugs
368                           if self.old_bugdir.has_bug(s)])
369         old_uuids = sorted(set(old_uuids))
370
371         added = []
372         removed = []
373         modified = []
374         if hasattr(self.old_bugdir, 'changed'):
375             # take advantage of a RevisionedBugDir-style changed() method
376             new_ids,mod_ids,rem_ids = self.old_bugdir.changed()
377             for id in new_ids:
378                 for a_id in self.new_bugdir.storage.ancestors(id):
379                     if a_id.count('/') == 0:
380                         if a_id in [b.id.storage() for b in added]:
381                             break
382                         try:
383                             bug = self.new_bugdir.bug_from_uuid(a_id)
384                             added.append(bug)
385                         except libbe.bugdir.NoBugMatches:
386                             pass
387             for id in rem_ids:
388                 for a_id in self.old_bugdir.storage.ancestors(id):
389                     if a_id.count('/') == 0:
390                         if a_id in [b.id.storage() for b in removed]:
391                             break
392                         try:
393                             bug = self.old_bugdir.bug_from_uuid(a_id)
394                             removed.append(bug)
395                         except libbe.bugdir.NoBugMatches:
396                             pass
397             for id in mod_ids:
398                 for a_id in self.new_bugdir.storage.ancestors(id):
399                     if a_id.count('/') == 0:
400                         if a_id in [b[0].id.storage() for b in modified]:
401                             break
402                         try:
403                             new_bug = self.new_bugdir.bug_from_uuid(a_id)
404                             old_bug = self.old_bugdir.bug_from_uuid(a_id)
405                             modified.append((old_bug, new_bug))
406                         except libbe.bugdir.NoBugMatches:
407                             pass
408         else:
409             for uuid in new_uuids:
410                 new_bug = self.new_bugdir.bug_from_uuid(uuid)
411                 try:
412                     old_bug = self.old_bugdir.bug_from_uuid(uuid)
413                 except KeyError:
414                     if BUGDIR_TYPE_ALL in bugdir_types \
415                             or BUGDIR_TYPE_NEW in bugdir_types \
416                             or uuid in subscribed_bugs:
417                         added.append(new_bug)
418                     continue
419                 if BUGDIR_TYPE_ALL in bugdir_types \
420                         or BUGDIR_TYPE_MOD in bugdir_types \
421                         or uuid in subscribed_bugs:
422                     if old_bug.storage != None and old_bug.storage.is_readable():
423                         old_bug.load_comments()
424                     if new_bug.storage != None and new_bug.storage.is_readable():
425                         new_bug.load_comments()
426                     if old_bug != new_bug:
427                         modified.append((old_bug, new_bug))
428             for uuid in old_uuids:
429                 if not self.new_bugdir.has_bug(uuid):
430                     old_bug = self.old_bugdir.bug_from_uuid(uuid)
431                     removed.append(old_bug)
432         added.sort()
433         removed.sort()
434         modified.sort(self._bug_modified_cmp)
435         return (added, modified, removed)
436     def _bug_modified_cmp(self, left, right):
437         return cmp(left[1], right[1])
438     def _changed_comments(self, old, new):
439         """
440         Search for differences in all loaded comments between the bugs
441         old and new.  Returns
442           (added_comments, modified_comments, removed_comments)
443         analogous to ._changed_bugs.
444         """
445         if hasattr(self, '__changed_comments'):
446             if new.uuid in self.__changed_comments:
447                 return self.__changed_comments[new.uuid]
448         else:
449             self.__changed_comments = {}
450         added = []
451         removed = []
452         modified = []
453         old.comment_root.sort(key=lambda comm : comm.time)
454         new.comment_root.sort(key=lambda comm : comm.time)
455         old_comment_ids = [c.uuid for c in old.comments()]
456         new_comment_ids = [c.uuid for c in new.comments()]
457         for uuid in new_comment_ids:
458             new_comment = new.comment_from_uuid(uuid)
459             try:
460                 old_comment = old.comment_from_uuid(uuid)
461             except KeyError:
462                 added.append(new_comment)
463             else:
464                 if old_comment != new_comment:
465                     modified.append((old_comment, new_comment))
466         for uuid in old_comment_ids:
467             if uuid not in new_comment_ids:
468                 old_comment = old.comment_from_uuid(uuid)
469                 removed.append(old_comment)
470         self.__changed_comments[new.uuid] = (added, modified, removed)
471         return self.__changed_comments[new.uuid]
472     def _attribute_changes(self, old, new, attributes):
473         """
474         Take two objects old and new, and compare the value of *.attr
475         for attr in the list attribute names.  Returns a list of
476           (attr_name, old_value, new_value)
477         tuples.
478         """
479         change_list = []
480         for attr in attributes:
481             old_value = getattr(old, attr)
482             new_value = getattr(new, attr)
483             if old_value != new_value:
484                 change_list.append((attr, old_value, new_value))
485         if len(change_list) >= 0:
486             return change_list
487         return None
488     def _settings_properties_attribute_changes(self, old, new,
489                                               hidden_properties=[]):
490         properties = sorted(new.settings_properties)
491         for p in hidden_properties:
492             properties.remove(p)
493         attributes = [setting_name_to_attr_name(None, p)
494                       for p in properties]
495         return self._attribute_changes(old, new, attributes)
496     def _bugdir_attribute_changes(self):
497         return self._settings_properties_attribute_changes( \
498             self.old_bugdir, self.new_bugdir)
499     def _bug_attribute_changes(self, old, new):
500         return self._settings_properties_attribute_changes(old, new)
501     def _comment_attribute_changes(self, old, new):
502         return self._settings_properties_attribute_changes(old, new)
503
504     # report generation methods
505
506     def full_report(self, diff_tree=DiffTree):
507         """
508         Generate a full report for efficiency if you'll be using
509         .report_tree() with several sets of subscriptions.
510         """
511         self._cached_full_report = self.report_tree(diff_tree=diff_tree,
512                                                     allow_cached=False)
513         self._cached_full_report_diff_tree = diff_tree
514     def _sub_report(self, subscriptions):
515         """
516         Return ._cached_full_report masked for subscriptions.
517         """
518         root = self._cached_full_report
519         bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
520         subscribed_bugs = [s.id for s in subscriptions
521                            if BUG_TYPE_ALL.has_descendant( \
522                                      s.type, match_self=True)]
523         selected_by_bug = [node.name
524                            for node in root.child_by_path('bugdir/bugs')]
525         if BUGDIR_TYPE_ALL in bugdir_types:
526             for node in root.traverse():
527                 node.masked = False
528             selected_by_bug = []
529         else:
530             try:
531                 node = root.child_by_path('bugdir/settings')
532                 node.masked = True
533             except KeyError:
534                 pass
535         for name,type in (('new', BUGDIR_TYPE_NEW),
536                           ('mod', BUGDIR_TYPE_MOD),
537                           ('rem', BUGDIR_TYPE_REM)):
538             if type in bugdir_types:
539                 bugs = root.child_by_path('bugdir/bugs/%s' % name)
540                 for bug_node in bugs:
541                     for node in bug_node.traverse():
542                         node.masked = False
543                 selected_by_bug.remove(name)
544         for name in selected_by_bug:
545             bugs = root.child_by_path('bugdir/bugs/%s' % name)
546             for bug_node in bugs:
547                 if bug_node.name in subscribed_bugs:
548                     for node in bug_node.traverse():
549                         node.masked = False
550                 else:
551                     for node in bug_node.traverse():
552                         node.masked = True
553         return root
554     def report_tree(self, subscriptions=None, diff_tree=DiffTree,
555                     allow_cached=True):
556         """
557         Pretty bare to make it easy to adjust to specific cases.  You
558         can pass in a DiffTree subclass via diff_tree to override the
559         default report assembly process.
560         """
561         if allow_cached == True \
562                 and hasattr(self, '_cached_full_report') \
563                 and diff_tree == self._cached_full_report_diff_tree:
564             return self._sub_report(subscriptions)
565         if subscriptions == None:
566             subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
567         bugdir_settings = sorted(self.new_bugdir.settings_properties)
568         root = diff_tree('bugdir')
569         bugdir_subscriptions = [s.type for s in subscriptions
570                                 if s.id == BUGDIR_ID]
571         if BUGDIR_TYPE_ALL in bugdir_subscriptions:
572             bugdir_attribute_changes = self._bugdir_attribute_changes()
573             if len(bugdir_attribute_changes) > 0:
574                 bugdir = diff_tree('settings', bugdir_attribute_changes,
575                                    self.bugdir_attribute_change_string)
576                 root.append(bugdir)
577         bug_root = diff_tree('bugs')
578         root.append(bug_root)
579         add,mod,rem = self._changed_bugs(subscriptions)
580         bnew = diff_tree('new', 'New bugs:', requires_children=True)
581         bug_root.append(bnew)
582         for bug in add:
583             b = diff_tree(bug.uuid, bug, self.bug_add_string)
584             bnew.append(b)
585         brem = diff_tree('rem', 'Removed bugs:', requires_children=True)
586         bug_root.append(brem)
587         for bug in rem:
588             b = diff_tree(bug.uuid, bug, self.bug_rem_string)
589             brem.append(b)
590         bmod = diff_tree('mod', 'Modified bugs:', requires_children=True)
591         bug_root.append(bmod)
592         for old,new in mod:
593             b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
594             bmod.append(b)
595             bug_attribute_changes = self._bug_attribute_changes(old, new)
596             if len(bug_attribute_changes) > 0:
597                 bset = diff_tree('settings', bug_attribute_changes,
598                                  self.bug_attribute_change_string)
599                 b.append(bset)
600             if old.summary != new.summary:
601                 data = (old.summary, new.summary)
602                 bsum = diff_tree('summary', data, self.bug_summary_change_string)
603                 b.append(bsum)
604             cr = diff_tree('comments')
605             b.append(cr)
606             a,m,d = self._changed_comments(old, new)
607             cnew = diff_tree('new', 'New comments:', requires_children=True)
608             for comment in a:
609                 c = diff_tree(comment.uuid, comment, self.comment_add_string)
610                 cnew.append(c)
611             crem = diff_tree('rem', 'Removed comments:',requires_children=True)
612             for comment in d:
613                 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
614                 crem.append(c)
615             cmod = diff_tree('mod','Modified comments:',requires_children=True)
616             for o,n in m:
617                 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
618                 cmod.append(c)
619                 comm_attribute_changes = self._comment_attribute_changes(o, n)
620                 if len(comm_attribute_changes) > 0:
621                     cset = diff_tree('settings', comm_attribute_changes,
622                                      self.comment_attribute_change_string)
623                 if o.body != n.body:
624                     data = (o.body, n.body)
625                     cbody = diff_tree('cbody', data,
626                                       self.comment_body_change_string)
627                     c.append(cbody)
628             cr.extend([cnew, crem, cmod])
629         return root
630
631     # change data -> string methods.
632     # Feel free to play with these in subclasses.
633
634     def attribute_change_string(self, attribute_changes, indent=0):
635         indent_string = '  '*indent
636         change_strings = [u'%s: %s -> %s' % f for f in attribute_changes]
637         for i,change_string in enumerate(change_strings):
638             change_strings[i] = indent_string+change_string
639         return u'\n'.join(change_strings)
640     def bugdir_attribute_change_string(self, attribute_changes):
641         return 'Changed bug directory settings:\n%s' % \
642             self.attribute_change_string(attribute_changes, indent=1)
643     def bug_attribute_change_string(self, attribute_changes):
644         return 'Changed bug settings:\n%s' % \
645             self.attribute_change_string(attribute_changes, indent=1)
646     def comment_attribute_change_string(self, attribute_changes):
647         return 'Changed comment settings:\n%s' % \
648             self.attribute_change_string(attribute_changes, indent=1)
649     def bug_add_string(self, bug):
650         return bug.string(shortlist=True)
651     def bug_rem_string(self, bug):
652         return bug.string(shortlist=True)
653     def bug_mod_string(self, bugs):
654         old_bug,new_bug = bugs
655         return new_bug.string(shortlist=True)
656     def bug_summary_change_string(self, summaries):
657         old_summary,new_summary = summaries
658         return 'summary changed:\n  %s\n  %s' % (old_summary, new_summary)
659     def _comment_summary_string(self, comment):
660         return 'from %s on %s' % (comment.author, time_to_str(comment.time))
661     def comment_add_string(self, comment):
662         summary = self._comment_summary_string(comment)
663         first_line = comment.body.splitlines()[0]
664         return '%s\n  %s...' % (summary, first_line)
665     def comment_rem_string(self, comment):
666         summary = self._comment_summary_string(comment)
667         first_line = comment.body.splitlines()[0]
668         return '%s\n  %s...' % (summary, first_line)
669     def comment_mod_string(self, comments):
670         old_comment,new_comment = comments
671         return self._comment_summary_string(new_comment)
672     def comment_body_change_string(self, bodies):
673         old_body,new_body = bodies
674         return ''.join(difflib.unified_diff(
675                 old_body.splitlines(True),
676                 new_body.splitlines(True),
677                 'before', 'after'))