Bumped to version 1.0.1
[be.git] / libbe / diff.py
index fb2b249d3cc50ee819b79975a48c621a03b49cdc..4c24073d891c4799ad17bcc937def561efe032ee 100644 (file)
@@ -1,66 +1,71 @@
-# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Copyright (C) 2005-2011 Aaron Bentley <abentley@panoramicfeedback.com>
+#                         Chris Ball <cjb@laptop.org>
 #                         Gianluca Montecchi <gian@grys.it>
 #                         W. Trevor King <wking@drexel.edu>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This file is part of Bugs Everywhere.
 #
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
+# Bugs Everywhere is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 2 of the License, or (at your
+# option) any later version.
 #
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+# Bugs Everywhere is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
 
-"""Compare two bug trees."""
+"""Tools for comparing two :class:`libbe.bug.BugDir`\s.
+"""
 
 import difflib
 import types
 
 import libbe
-from libbe import bugdir, bug, settings_object, tree
-from libbe.utility import time_to_str
-if libbe.TESTING == True:
-    import doctest
+import libbe.bugdir
+import libbe.bug
+import libbe.util.tree
+from libbe.storage.util.settings_object import setting_name_to_attr_name
+from libbe.util.utility import time_to_str
 
 
-class SubscriptionType (tree.Tree):
-    """
-    Trees of subscription types to allow users to select exactly what
+class SubscriptionType (libbe.util.tree.Tree):
+    """Trees of subscription types to allow users to select exactly what
     notifications they want to subscribe to.
     """
     def __init__(self, type_name, *args, **kwargs):
-        tree.Tree.__init__(self, *args, **kwargs)
+        libbe.util.tree.Tree.__init__(self, *args, **kwargs)
         self.type = type_name
     def __str__(self):
         return self.type
+    def __cmp__(self, other):
+        return cmp(self.type, other.type)
     def __repr__(self):
-        return "<SubscriptionType: %s>" % str(self)
+        return '<SubscriptionType: %s>' % str(self)
     def string_tree(self, indent=0):
         lines = []
         for depth,node in self.thread():
-            lines.append("%s%s" % (" "*(indent+2*depth), node))
-        return "\n".join(lines)
-
-BUGDIR_ID = "DIR"
-BUGDIR_TYPE_NEW = SubscriptionType("new")
-BUGDIR_TYPE_MOD = SubscriptionType("mod")
-BUGDIR_TYPE_REM = SubscriptionType("rem")
-BUGDIR_TYPE_ALL = SubscriptionType("all",
+            lines.append('%s%s' % (' '*(indent+2*depth), node))
+        return '\n'.join(lines)
+
+BUGDIR_ID = 'DIR'
+BUGDIR_TYPE_NEW = SubscriptionType('new')
+BUGDIR_TYPE_MOD = SubscriptionType('mod')
+BUGDIR_TYPE_REM = SubscriptionType('rem')
+BUGDIR_TYPE_ALL = SubscriptionType('all',
                       [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM])
 
 # same name as BUGDIR_TYPE_ALL for consistency
 BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
 
-INVALID_TYPE = SubscriptionType("INVALID")
+INVALID_TYPE = SubscriptionType('INVALID')
 
 class InvalidType (ValueError):
     def __init__(self, type_name, type_root):
-        msg = "Invalid type %s for tree:\n%s" \
+        msg = 'Invalid type %s for tree:\n%s' \
             % (type_name, type_root.string_tree(4))
         ValueError.__init__(self, msg)
         self.type_name = type_name
@@ -77,7 +82,11 @@ def type_from_name(name, type_root, default=None, default_ok=False):
     raise InvalidType(name, type_root)
 
 class Subscription (object):
-    """
+    """A user subscription.
+
+    Examples
+    --------
+
     >>> subscriptions = [Subscription('XYZ', 'all'),
     ...                  Subscription('DIR', 'new'),
     ...                  Subscription('ABC', BUG_TYPE_ALL),]
@@ -87,9 +96,9 @@ class Subscription (object):
     def __init__(self, id, subscription_type, **kwargs):
         if 'type_root' not in kwargs:
             if id == BUGDIR_ID:
-                kwargs['type_root'] = BUGDIR_TYPE_ALL 
+                kwargs['type_root'] = BUGDIR_TYPE_ALL
             else:
-                kwargs['type_root'] = BUG_TYPE_ALL 
+                kwargs['type_root'] = BUG_TYPE_ALL
         if type(subscription_type) in types.StringTypes:
             subscription_type = type_from_name(subscription_type, **kwargs)
         self.id = id
@@ -106,10 +115,14 @@ class Subscription (object):
     def __str__(self):
         return str(self.type)
     def __repr__(self):
-        return "<Subscription: %s (%s)>" % (self.id, self.type)
+        return '<Subscription: %s (%s)>' % (self.id, self.type)
 
 def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
-    """
+    """Provide a simple way for non-Python interfaces to read in subscriptions.
+
+    Examples
+    --------
+
     >>> subscriptions_from_string(None)
     [<Subscription: DIR (all)>]
     >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all')
@@ -131,48 +144,51 @@ def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
         subscriptions.append(Subscription(id, type))
     return subscriptions
 
-class DiffTree (tree.Tree):
-    """
-    A tree holding difference data for easy report generation.
-    >>> bugdir = DiffTree("bugdir")
-    >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
+class DiffTree (libbe.util.tree.Tree):
+    """A tree holding difference data for easy report generation.
+
+    Examples
+    --------
+
+    >>> bugdir = DiffTree('bugdir')
+    >>> bdsettings = DiffTree('settings', data='target: None -> 1.0')
     >>> bugdir.append(bdsettings)
-    >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+    >>> bugs = DiffTree('bugs', 'bug-count: 5 -> 6')
     >>> bugdir.append(bugs)
-    >>> new = DiffTree("new", "new bugs: ABC, DEF")
+    >>> new = DiffTree('new', 'new bugs: ABC, DEF')
     >>> bugs.append(new)
-    >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+    >>> rem = DiffTree('rem', 'removed bugs: RST, UVW')
     >>> bugs.append(rem)
     >>> print bugdir.report_string()
     target: None -> 1.0
     bug-count: 5 -> 6
       new bugs: ABC, DEF
       removed bugs: RST, UVW
-    >>> print "\\n".join(bugdir.paths())
+    >>> print '\\n'.join(bugdir.paths())
     bugdir
     bugdir/settings
     bugdir/bugs
     bugdir/bugs/new
     bugdir/bugs/rem
-    >>> bugdir.child_by_path("/") == bugdir
+    >>> bugdir.child_by_path('/') == bugdir
     True
-    >>> bugdir.child_by_path("/bugs") == bugs
+    >>> bugdir.child_by_path('/bugs') == bugs
     True
-    >>> bugdir.child_by_path("/bugs/rem") == rem
+    >>> bugdir.child_by_path('/bugs/rem') == rem
     True
-    >>> bugdir.child_by_path("bugdir") == bugdir
+    >>> bugdir.child_by_path('bugdir') == bugdir
     True
-    >>> bugdir.child_by_path("bugdir/") == bugdir
+    >>> bugdir.child_by_path('bugdir/') == bugdir
     True
-    >>> bugdir.child_by_path("bugdir/bugs") == bugs
+    >>> bugdir.child_by_path('bugdir/bugs') == bugs
     True
-    >>> bugdir.child_by_path("/bugs").masked = True
+    >>> bugdir.child_by_path('/bugs').masked = True
     >>> print bugdir.report_string()
     target: None -> 1.0
     """
     def __init__(self, name, data=None, data_part_fn=str,
                  requires_children=False, masked=False):
-        tree.Tree.__init__(self)
+        libbe.util.tree.Tree.__init__(self)
         self.name = name
         self.data = data
         self.data_part_fn = data_part_fn
@@ -183,17 +199,17 @@ class DiffTree (tree.Tree):
         if parent_path == None:
             path = self.name
         else:
-            path = "%s/%s" % (parent_path, self.name)
+            path = '%s/%s' % (parent_path, self.name)
         paths.append(path)
         for child in self:
             paths.extend(child.paths(path))
         return paths
     def child_by_path(self, path):
-        if hasattr(path, "split"): # convert string path to a list of names
-            names = path.split("/")
-            if names[0] == "":
+        if hasattr(path, 'split'): # convert string path to a list of names
+            names = path.split('/')
+            if names[0] == '':
                 names[0] = self.name # replace root with self
-            if len(names) > 1 and names[-1] == "":
+            if len(names) > 1 and names[-1] == '':
                 names = names[:-1] # strip empty tail
         else: # it was already an array
             names = path
@@ -206,14 +222,17 @@ class DiffTree (tree.Tree):
                     return child.child_by_path(names[1:])
         if len(names) == 1:
             raise KeyError, "%s doesn't match '%s'" % (names, self.name)
-        raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
+        raise KeyError, '%s points to child not in %s' % (names, [c.name for c in self])
     def report_string(self):
-        return "\n".join(self.report())
+        report = self.report()
+        if report == None:
+            return ''
+        return '\n'.join(report)
     def report(self, root=None, parent=None, depth=0):
         if root == None:
             root = self.make_root()
         if self.masked == True:
-            return None
+            return root
         data_part = self.data_part(depth)
         if self.requires_children == True \
                 and len([c for c in self if c.masked == False]) == 0:
@@ -222,8 +241,8 @@ class DiffTree (tree.Tree):
             self.join(root, parent, data_part)
             if data_part != None:
                 depth += 1
-        for child in self:
-            child.report(root, self, depth)
+            for child in self:
+                root = child.report(root, self, depth)
         return root
     def make_root(self):
         return []
@@ -233,36 +252,39 @@ class DiffTree (tree.Tree):
     def data_part(self, depth, indent=True):
         if self.data == None:
             return None
-        if hasattr(self, "_cached_data_part"):
+        if hasattr(self, '_cached_data_part'):
             return self._cached_data_part
         data_part = self.data_part_fn(self.data)
         if indent == True:
             data_part_lines = data_part.splitlines()
-            indent = "  "*(depth)
-            line_sep = "\n"+indent
+            indent = '  '*(depth)
+            line_sep = '\n'+indent
             data_part = indent+line_sep.join(data_part_lines)
         self._cached_data_part = data_part
         return data_part
 
 class Diff (object):
-    """
-    Difference tree generator for BugDirs.
+    """Difference tree generator for BugDirs.
+
+    Examples
+    --------
+
     >>> import copy
-    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
-    >>> bd.user_id = "John Doe <j@doe.com>"
+    >>> bd = libbe.bugdir.SimpleBugDir(memory=True)
     >>> bd_new = copy.deepcopy(bd)
-    >>> bd_new.target = "1.0"
-    >>> a = bd_new.bug_from_uuid("a")
+    >>> bd_new.target = '1.0'
+    >>> a = bd_new.bug_from_uuid('a')
     >>> rep = a.comment_root.new_reply("I'm closing this bug")
-    >>> rep.uuid = "acom"
-    >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
-    >>> a.status = "closed"
-    >>> b = bd_new.bug_from_uuid("b")
+    >>> rep.uuid = 'acom'
+    >>> rep.author = 'John Doe <j@doe.com>'
+    >>> rep.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
+    >>> a.status = 'closed'
+    >>> b = bd_new.bug_from_uuid('b')
     >>> bd_new.remove_bug(b)
-    >>> c = bd_new.new_bug("c", "Bug C")
+    >>> c = bd_new.new_bug('Bug C', _uuid='c')
     >>> d = Diff(bd, bd_new)
     >>> r = d.report_tree()
-    >>> print "\\n".join(r.paths())
+    >>> print '\\n'.join(r.paths())
     bugdir
     bugdir/settings
     bugdir/bugs
@@ -282,11 +304,11 @@ class Diff (object):
     Changed bug directory settings:
       target: None -> 1.0
     New bugs:
-      c:om: Bug C
+      abc/c:om: Bug C
     Removed bugs:
-      b:cm: Bug B
+      abc/b:cm: Bug B
     Modified bugs:
-      a:cm: Bug A
+      abc/a:cm: Bug A
         Changed bug settings:
           status: open -> closed
         New comments:
@@ -301,9 +323,9 @@ class Diff (object):
     >>> r = d.report_tree(subscriptions)
     >>> print r.report_string()
     New bugs:
-      c:om: Bug C
+      abc/c:om: Bug C
     Removed bugs:
-      b:cm: Bug B
+      abc/b:cm: Bug B
 
     While sending subscriptions to report_tree() makes the report
     generation more efficient (because you may not need to compare
@@ -314,10 +336,10 @@ class Diff (object):
     >>> d.full_report()
     >>> print d.report_tree([subscriptions[0]]).report_string()
     New bugs:
-      c:om: Bug C
+      abc/c:om: Bug C
     >>> print d.report_tree([subscriptions[1]]).report_string()
     Removed bugs:
-      b:cm: Bug B
+      abc/b:cm: Bug B
 
     >>> bd.cleanup()
     """
@@ -347,41 +369,82 @@ class Diff (object):
             if bd_type in bugdir_types:
                 old_uuids = list(self.old_bugdir.uuids())
                 break
-        subscribed_bugs = [s.id for s in subscriptions
-                           if BUG_TYPE_ALL.has_descendant( \
-                                     s.type, match_self=True)]
+        subscribed_bugs = []
+        for s in subscriptions:
+            if s.id != BUGDIR_ID:
+                try:
+                    bug = self.new_bugdir.bug_from_uuid(s.id)
+                except libbe.bugdir.NoBugMatches:
+                    bug = self.old_bugdir.bug_from_uuid(s.id)
+                subscribed_bugs.append(bug.uuid)
         new_uuids.extend([s for s in subscribed_bugs
                           if self.new_bugdir.has_bug(s)])
         new_uuids = sorted(set(new_uuids))
         old_uuids.extend([s for s in subscribed_bugs
                           if self.old_bugdir.has_bug(s)])
         old_uuids = sorted(set(old_uuids))
+
         added = []
         removed = []
         modified = []
-        for uuid in new_uuids:
-            new_bug = self.new_bugdir.bug_from_uuid(uuid)
-            try:
-                old_bug = self.old_bugdir.bug_from_uuid(uuid)
-            except KeyError:
+        if hasattr(self.old_bugdir, 'changed'):
+            # take advantage of a RevisionedBugDir-style changed() method
+            new_ids,mod_ids,rem_ids = self.old_bugdir.changed()
+            for id in new_ids:
+                for a_id in self.new_bugdir.storage.ancestors(id):
+                    if a_id.count('/') == 0:
+                        if a_id in [b.id.storage() for b in added]:
+                            break
+                        try:
+                            bug = self.new_bugdir.bug_from_uuid(a_id)
+                            added.append(bug)
+                        except libbe.bugdir.NoBugMatches:
+                            pass
+            for id in rem_ids:
+                for a_id in self.old_bugdir.storage.ancestors(id):
+                    if a_id.count('/') == 0:
+                        if a_id in [b.id.storage() for b in removed]:
+                            break
+                        try:
+                            bug = self.old_bugdir.bug_from_uuid(a_id)
+                            removed.append(bug)
+                        except libbe.bugdir.NoBugMatches:
+                            pass
+            for id in mod_ids:
+                for a_id in self.new_bugdir.storage.ancestors(id):
+                    if a_id.count('/') == 0:
+                        if a_id in [b[0].id.storage() for b in modified]:
+                            break
+                        try:
+                            new_bug = self.new_bugdir.bug_from_uuid(a_id)
+                            old_bug = self.old_bugdir.bug_from_uuid(a_id)
+                            modified.append((old_bug, new_bug))
+                        except libbe.bugdir.NoBugMatches:
+                            pass
+        else:
+            for uuid in new_uuids:
+                new_bug = self.new_bugdir.bug_from_uuid(uuid)
+                try:
+                    old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                except KeyError:
+                    if BUGDIR_TYPE_ALL in bugdir_types \
+                            or BUGDIR_TYPE_NEW in bugdir_types \
+                            or uuid in subscribed_bugs:
+                        added.append(new_bug)
+                    continue
                 if BUGDIR_TYPE_ALL in bugdir_types \
-                        or BUGDIR_TYPE_NEW in bugdir_types \
+                        or BUGDIR_TYPE_MOD in bugdir_types \
                         or uuid in subscribed_bugs:
-                    added.append(new_bug)
-                continue
-            if BUGDIR_TYPE_ALL in bugdir_types \
-                    or BUGDIR_TYPE_MOD in bugdir_types \
-                    or uuid in subscribed_bugs:
-                if old_bug.sync_with_disk == True:
-                    old_bug.load_comments()
-                if new_bug.sync_with_disk == True:
-                    new_bug.load_comments()
-                if old_bug != new_bug:
-                    modified.append((old_bug, new_bug))
-        for uuid in old_uuids:
-            if not self.new_bugdir.has_bug(uuid):
-                old_bug = self.old_bugdir.bug_from_uuid(uuid)
-                removed.append(old_bug)
+                    if old_bug.storage != None and old_bug.storage.is_readable():
+                        old_bug.load_comments()
+                    if new_bug.storage != None and new_bug.storage.is_readable():
+                        new_bug.load_comments()
+                    if old_bug != new_bug:
+                        modified.append((old_bug, new_bug))
+            for uuid in old_uuids:
+                if not self.new_bugdir.has_bug(uuid):
+                    old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                    removed.append(old_bug)
         added.sort()
         removed.sort()
         modified.sort(self._bug_modified_cmp)
@@ -395,7 +458,7 @@ class Diff (object):
           (added_comments, modified_comments, removed_comments)
         analogous to ._changed_bugs.
         """
-        if hasattr(self, "__changed_comments"):
+        if hasattr(self, '__changed_comments'):
             if new.uuid in self.__changed_comments:
                 return self.__changed_comments[new.uuid]
         else:
@@ -443,13 +506,12 @@ class Diff (object):
         properties = sorted(new.settings_properties)
         for p in hidden_properties:
             properties.remove(p)
-        attributes = [settings_object.setting_name_to_attr_name(None, p)
+        attributes = [setting_name_to_attr_name(None, p)
                       for p in properties]
         return self._attribute_changes(old, new, attributes)
     def _bugdir_attribute_changes(self):
         return self._settings_properties_attribute_changes( \
-            self.old_bugdir, self.new_bugdir,
-            ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
+            self.old_bugdir, self.new_bugdir)
     def _bug_attribute_changes(self, old, new):
         return self._settings_properties_attribute_changes(old, new)
     def _comment_attribute_changes(self, old, new):
@@ -481,8 +543,11 @@ class Diff (object):
                 node.masked = False
             selected_by_bug = []
         else:
-            node = root.child_by_path('bugdir/settings')
-            node.masked = True
+            try:
+                node = root.child_by_path('bugdir/settings')
+                node.masked = True
+            except KeyError:
+                pass
         for name,type in (('new', BUGDIR_TYPE_NEW),
                           ('mod', BUGDIR_TYPE_MOD),
                           ('rem', BUGDIR_TYPE_REM)):
@@ -516,65 +581,64 @@ class Diff (object):
         if subscriptions == None:
             subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
         bugdir_settings = sorted(self.new_bugdir.settings_properties)
-        bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
-        root = diff_tree("bugdir")
+        root = diff_tree('bugdir')
         bugdir_subscriptions = [s.type for s in subscriptions
                                 if s.id == BUGDIR_ID]
         if BUGDIR_TYPE_ALL in bugdir_subscriptions:
             bugdir_attribute_changes = self._bugdir_attribute_changes()
             if len(bugdir_attribute_changes) > 0:
-                bugdir = diff_tree("settings", bugdir_attribute_changes,
+                bugdir = diff_tree('settings', bugdir_attribute_changes,
                                    self.bugdir_attribute_change_string)
                 root.append(bugdir)
-        bug_root = diff_tree("bugs")
+        bug_root = diff_tree('bugs')
         root.append(bug_root)
         add,mod,rem = self._changed_bugs(subscriptions)
-        bnew = diff_tree("new", "New bugs:", requires_children=True)
+        bnew = diff_tree('new', 'New bugs:', requires_children=True)
         bug_root.append(bnew)
         for bug in add:
             b = diff_tree(bug.uuid, bug, self.bug_add_string)
             bnew.append(b)
-        brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+        brem = diff_tree('rem', 'Removed bugs:', requires_children=True)
         bug_root.append(brem)
         for bug in rem:
             b = diff_tree(bug.uuid, bug, self.bug_rem_string)
             brem.append(b)
-        bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+        bmod = diff_tree('mod', 'Modified bugs:', requires_children=True)
         bug_root.append(bmod)
         for old,new in mod:
             b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
             bmod.append(b)
             bug_attribute_changes = self._bug_attribute_changes(old, new)
             if len(bug_attribute_changes) > 0:
-                bset = diff_tree("settings", bug_attribute_changes,
+                bset = diff_tree('settings', bug_attribute_changes,
                                  self.bug_attribute_change_string)
                 b.append(bset)
             if old.summary != new.summary:
                 data = (old.summary, new.summary)
-                bsum = diff_tree("summary", data, self.bug_summary_change_string)
+                bsum = diff_tree('summary', data, self.bug_summary_change_string)
                 b.append(bsum)
-            cr = diff_tree("comments")
+            cr = diff_tree('comments')
             b.append(cr)
             a,m,d = self._changed_comments(old, new)
-            cnew = diff_tree("new", "New comments:", requires_children=True)
+            cnew = diff_tree('new', 'New comments:', requires_children=True)
             for comment in a:
                 c = diff_tree(comment.uuid, comment, self.comment_add_string)
                 cnew.append(c)
-            crem = diff_tree("rem", "Removed comments:",requires_children=True)
+            crem = diff_tree('rem', 'Removed comments:',requires_children=True)
             for comment in d:
                 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
                 crem.append(c)
-            cmod = diff_tree("mod","Modified comments:",requires_children=True)
+            cmod = diff_tree('mod','Modified comments:',requires_children=True)
             for o,n in m:
                 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
                 cmod.append(c)
                 comm_attribute_changes = self._comment_attribute_changes(o, n)
                 if len(comm_attribute_changes) > 0:
-                    cset = diff_tree("settings", comm_attribute_changes,
+                    cset = diff_tree('settings', comm_attribute_changes,
                                      self.comment_attribute_change_string)
                 if o.body != n.body:
                     data = (o.body, n.body)
-                    cbody = diff_tree("cbody", data,
+                    cbody = diff_tree('cbody', data,
                                       self.comment_body_change_string)
                     c.append(cbody)
             cr.extend([cnew, crem, cmod])
@@ -584,19 +648,19 @@ class Diff (object):
     # Feel free to play with these in subclasses.
 
     def attribute_change_string(self, attribute_changes, indent=0):
-        indent_string = "  "*indent
-        change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+        indent_string = '  '*indent
+        change_strings = [u'%s: %s -> %s' % f for f in attribute_changes]
         for i,change_string in enumerate(change_strings):
             change_strings[i] = indent_string+change_string
-        return u"\n".join(change_strings)
+        return u'\n'.join(change_strings)
     def bugdir_attribute_change_string(self, attribute_changes):
-        return "Changed bug directory settings:\n%s" % \
+        return 'Changed bug directory settings:\n%s' % \
             self.attribute_change_string(attribute_changes, indent=1)
     def bug_attribute_change_string(self, attribute_changes):
-        return "Changed bug settings:\n%s" % \
+        return 'Changed bug settings:\n%s' % \
             self.attribute_change_string(attribute_changes, indent=1)
     def comment_attribute_change_string(self, attribute_changes):
-        return "Changed comment settings:\n%s" % \
+        return 'Changed comment settings:\n%s' % \
             self.attribute_change_string(attribute_changes, indent=1)
     def bug_add_string(self, bug):
         return bug.string(shortlist=True)
@@ -607,24 +671,23 @@ class Diff (object):
         return new_bug.string(shortlist=True)
     def bug_summary_change_string(self, summaries):
         old_summary,new_summary = summaries
-        return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
+        return 'summary changed:\n  %s\n  %s' % (old_summary, new_summary)
     def _comment_summary_string(self, comment):
-        return "from %s on %s" % (comment.author, time_to_str(comment.time))
+        return 'from %s on %s' % (comment.author, time_to_str(comment.time))
     def comment_add_string(self, comment):
         summary = self._comment_summary_string(comment)
         first_line = comment.body.splitlines()[0]
-        return "%s\n  %s..." % (summary, first_line)
+        return '%s\n  %s...' % (summary, first_line)
     def comment_rem_string(self, comment):
         summary = self._comment_summary_string(comment)
         first_line = comment.body.splitlines()[0]
-        return "%s\n  %s..." % (summary, first_line)
+        return '%s\n  %s...' % (summary, first_line)
     def comment_mod_string(self, comments):
         old_comment,new_comment = comments
         return self._comment_summary_string(new_comment)
     def comment_body_change_string(self, bodies):
         old_body,new_body = bodies
-        return difflib.unified_diff(old_body, new_body)
-
-
-if libbe.TESTING == True:
-    suite = doctest.DocTestSuite()
+        return ''.join(difflib.unified_diff(
+                old_body.splitlines(True),
+                new_body.splitlines(True),
+                'before', 'after'))