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