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