Major rewrite of libbe.diff introduces DiffTree and Diff classes.
authorW. Trevor King <wking@drexel.edu>
Mon, 27 Jul 2009 09:14:49 +0000 (05:14 -0400)
committerW. Trevor King <wking@drexel.edu>
Mon, 27 Jul 2009 09:14:49 +0000 (05:14 -0400)
To make the interface proposed by becommands/subscribers.py easier to
implement, I've moved the libbe.diff functionality into classes.  Now
it should be easy two tweak the output as desired by subclassing these
classes.  The basic idea is that Diff.report_tree() generates a
diff_tree tree of changes between two bugdirs, where diff_tree is some
subclass of DiffTree.  Each type of change has a default .*_string()
method producing a string summary of the change.  DiffTree.report()
moves through and generates a report by joining all those summary
strings to a single root, and DiffTree.report_string() serialized the
report to produce e.g. the output of becommands/diff.py.

becommands/diff.py
libbe/diff.py

index 07b3b1c7961b5d005aa558cbe219a5e16fb7c2b1..1ab21357ade8c9f0adfb26865dfe0643389fba1e 100644 (file)
@@ -33,10 +33,21 @@ def execute(args, manipulate_encodings=True):
     >>> if bd.rcs.versioned == True:
     ...     execute([original], manipulate_encodings=False)
     ... else:
-    ...     print "a:cm: Bug A\\nstatus: open -> closed\\n"
-    Modified bug reports:
-    a:cm: Bug A
-      status: open -> closed
+    ...     print "Modified bugs:\\n  a:cm: Bug A\\n    Changed bug settings:\\n      status: open -> closed"
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+    >>> if bd.rcs.versioned == True:
+    ...     execute(["--modified", original], manipulate_encodings=False)
+    ... else:
+    ...     print "a"
+    a
+    >>> if bd.rcs.versioned == False:
+    ...     execute([original], manipulate_encodings=False)
+    ... else:
+    ...     print "This directory is not revision-controlled."
+    This directory is not revision-controlled.
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -55,23 +66,23 @@ def execute(args, manipulate_encodings=True):
         if revision == None: # get the most recent revision
             revision = bd.rcs.revision_id(-1)
         old_bd = bd.duplicate_bugdir(revision)
-        r,m,a = diff.bug_diffs(old_bd, bd)
-        
-        optbugs = []
+        d = diff.Diff(old_bd, bd)
+        tree = d.report_tree()
+
+        uuids = []
         if options.all == True:
             options.new = options.modified = options.removed = True
         if options.new == True:
-            optbugs.extend(a)
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/new")])
         if options.modified == True:
-            optbugs.extend([new for old,new in m])
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/mod")])
         if options.removed == True:
-            optbugs.extend(r)
-        if len(optbugs) > 0:
-            for bug in optbugs:
-                print bug.uuid
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/rem")])
+        if (options.new or options.modified or options.removed) == True:
+            print "\n".join(uuids)
         else :
-            rep = diff.diff_report((r,m,a), old_bd, bd).encode(bd.encoding)
-            if len(rep) > 0:
+            rep = tree.report_string()
+            if rep != None:
                 print rep
         bd.remove_duplicate_bugdir()
 
@@ -88,7 +99,7 @@ def get_parser():
         long = "--%s" % s[1]
         help = s[2]
         parser.add_option(short, long, action="store_true",
-                          dest=attr, help=help)
+                          default=False, dest=attr, help=help)
     return parser
 
 longhelp="""
index ba48efc568d34d852b6bb51d2d028aad0465d119..59e7c6643c3ba8df06c0dc8abfda7f1cf3be98c5 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 """Compare two bug trees"""
-from libbe import cmdutil, bugdir, bug
+from libbe import bugdir, bug, settings_object, tree
 from libbe.utility import time_to_str
+import difflib
 import doctest
 
-def bug_diffs(old_bugdir, new_bugdir):
-    added = []
-    removed = []
-    modified = []
-    for uuid in old_bugdir.list_uuids():
-        old_bug = old_bugdir.bug_from_uuid(uuid)
-        try:
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            old_bug.load_comments()
-            new_bug.load_comments()
-            if old_bug != new_bug:
-                modified.append((old_bug, new_bug))
-        except KeyError:
-            removed.append(old_bug)
-    for uuid in new_bugdir.list_uuids():
-        if not old_bugdir.has_bug(uuid):
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            added.append(new_bug)
-    return (removed, modified, added)
+class DiffTree (tree.Tree):
+    """
+    A tree holding difference data for easy report generation.
+    >>> all = DiffTree("all")
+    >>> bugdir = DiffTree("bugdir", data="target: None -> 1.0")
+    >>> all.append(bugdir)
+    >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+    >>> all.append(bugs)
+    >>> new = DiffTree("new", "new bugs: ABC, DEF")
+    >>> bugs.append(new)
+    >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+    >>> bugs.append(rem)
+    >>> print all.report_string()
+    target: None -> 1.0
+    bug-count: 5 -> 6
+      new bugs: ABC, DEF
+      removed bugs: RST, UVW
+    >>> print "\\n".join(all.paths())
+    all
+    all/bugdir
+    all/bugs
+    all/bugs/new
+    all/bugs/rem
+    >>> all.child_by_path("/") == all
+    True
+    >>> all.child_by_path("/bugs") == bugs
+    True
+    >>> all.child_by_path("/bugs/rem") == rem
+    True
+    >>> all.child_by_path("all") == all
+    True
+    >>> all.child_by_path("all/") == all
+    True
+    >>> all.child_by_path("all/bugs") == bugs
+    True
+    >>> all.child_by_path("/bugs").masked = True
+    >>> print all.report_string()
+    target: None -> 1.0
+    """
+    def __init__(self, name, data=None, data_string_fn=str,
+                 requires_children=False, masked=False):
+        tree.Tree.__init__(self)
+        self.name = name
+        self.data = data
+        self.data_string_fn = data_string_fn
+        self.requires_children = requires_children
+        self.masked = masked
+    def paths(self, parent_path=None):
+        paths = []
+        if parent_path == None:
+            path = self.name
+        else:
+            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] == "":
+                names[0] = self.name # replace root with self
+            if len(names) > 1 and names[-1] == "":
+                names = names[:-1] # strip empty tail
+        else: # it was already an array
+            names = path
+        assert len(names) > 0, path
+        if names[0] == self.name:
+            if len(names) == 1:
+                return self
+            for child in self:
+                if names[1] == child.name:
+                    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])
+    def report_string(self):
+        return "\n".join(self.report())
+    def report(self, root=None, depth=0):
+        if root == None:
+            root = self.make_root()
+        if self.masked == True:
+            return None
+        data_string = self.data_string(depth)
+        if self.data == None:
+            pass
+        elif self.requires_children == True and len(self) == 0:
+            pass
+        else:
+            self.join(root, data_string)
+            depth += 1
+        for child in self:
+            child.report(root, depth)
+        return root
+    def make_root(self):
+        return []
+    def join(self, root, part):
+        if part != None:
+            root.append(part)
+    def data_string(self, depth, indent=True):
+        data_string = self.data_string_fn(self.data)
+        if indent == True:
+            data_string_lines = data_string.splitlines()
+            indent = "  "*(depth)
+            line_sep = "\n"+indent
+            data_string = indent+line_sep.join(data_string_lines)
+        return data_string
 
-def diff_report(bug_diffs_data, old_bugdir, new_bugdir):
-    bugs_removed,bugs_modified,bugs_added = bug_diffs_data
-    def modified_cmp(left, right):
-        return bug.cmp_severity(left[1], right[1])
+class Diff (object):
+    """
+    Difference tree generator for BugDirs.
+    >>> import copy
+    >>> bd = bugdir.simple_bug_dir(sync_with_disk=False)
+    >>> bd.user_id = "John Doe <j@doe.com>"
+    >>> bd_new = copy.deepcopy(bd)
+    >>> 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")
+    >>> bd_new.remove_bug(b)
+    >>> c = bd_new.new_bug("c", "Bug C")
+    >>> d = Diff(bd, bd_new)
+    >>> r = d.report_tree()
+    >>> print "\\n".join(r.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/new/c
+    bugdir/bugs/rem
+    bugdir/bugs/rem/b
+    bugdir/bugs/mod
+    bugdir/bugs/mod/a
+    bugdir/bugs/mod/a/settings
+    bugdir/bugs/mod/a/comments
+    bugdir/bugs/mod/a/comments/new
+    bugdir/bugs/mod/a/comments/new/acom
+    bugdir/bugs/mod/a/comments/rem
+    bugdir/bugs/mod/a/comments/mod
+    >>> print r.report_string()
+    Changed bug directory settings:
+      target: None -> 1.0
+    New bugs:
+      c:om: Bug C
+    Removed bugs:
+      b:cm: Bug B
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+        New comments:
+          from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+    """
+    def __init__(self, old_bugdir, new_bugdir):
+        self.old_bugdir = old_bugdir
+        self.new_bugdir = new_bugdir
 
-    bugs_added.sort(bug.cmp_severity)
-    bugs_removed.sort(bug.cmp_severity)
-    bugs_modified.sort(modified_cmp)
-    lines = []
-    
-    if old_bugdir.settings != new_bugdir.settings:
-        bugdir_settings = sorted(new_bugdir.settings_properties)
-        bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
-        change_list = change_lines(old_bugdir, new_bugdir, bugdir_settings)
-        if len(change_list) >  0:
-            lines.append("Modified bug directory:")
-            change_strings = ["%s: %s -> %s" % f for f in change_list]
-            lines.extend(change_strings)
-            lines.append("")
-    if len(bugs_added) > 0:
-        lines.append("New bug reports:")
-        for bg in bugs_added:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    if len(bugs_modified) > 0:
-        printed = False
-        for old_bug, new_bug in bugs_modified:
-            change_str = bug_changes(old_bug, new_bug)
-            if change_str is None:
-                continue
-            if not printed:
-                printed = True
-                lines.append("Modified bug reports:")
-            lines.extend(change_str.splitlines())
-        if printed == True:
-            lines.append("")
-    if len(bugs_removed) > 0:
-        lines.append("Removed bug reports:")
-        for bg in bugs_removed:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    
-    return "\n".join(lines).rstrip("\n")
+    # data assembly methods
 
-def change_lines(old, new, attributes):
-    change_list = []    
-    for attr in attributes:
-        old_attr = getattr(old, attr)
-        new_attr = getattr(new, attr)
-        if old_attr != new_attr:
-            change_list.append((attr, old_attr, new_attr))
-    if len(change_list) >= 0:
-        return change_list
-    else:
+    def _changed_bugs(self):
+        """
+        Search for differences in all bugs between .old_bugdir and
+        .new_bugdir.  Returns
+          (added_bugs, modified_bugs, removed_bugs)
+        where added_bugs and removed_bugs are lists of added and
+        removed bugs respectively.  modified_bugs is a list of
+        (old_bug,new_bug) pairs.
+        """
+        if hasattr(self, "__changed_bugs"):
+            return self.__changed_bugs
+        added = []
+        removed = []
+        modified = []
+        for uuid in self.new_bugdir.list_uuids():
+            new_bug = self.new_bugdir.bug_from_uuid(uuid)
+            try:
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+            except KeyError:
+                added.append(new_bug)
+            else:
+                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 self.old_bugdir.list_uuids():
+            if not self.new_bugdir.has_bug(uuid):
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                removed.append(old_bug)
+        added.sort(bug.cmp_severity)
+        removed.sort(bug.cmp_severity)
+        modified.sort(self._bug_modified_cmp)
+        self.__changed_bugs = (added, modified, removed)
+        return self.__changed_bugs
+    def _bug_modified_cmp(self, left, right):
+        return bug.cmp_severity(left[1], right[1])
+    def _changed_comments(self, old, new):
+        """
+        Search for differences in all loaded comments between the bugs
+        old and new.  Returns
+          (added_comments, modified_comments, removed_comments)
+        analogous to ._changed_bugs.
+        """
+        if hasattr(self, "__changed_comments"):
+            if new.uuid in self.__changed_comments:
+                return self.__changed_comments[new.uuid]
+        else:
+            self.__changed_comments = {}
+        added = []
+        removed = []
+        modified = []
+        old.comment_root.sort(key=lambda comm : comm.time)
+        new.comment_root.sort(key=lambda comm : comm.time)
+        old_comment_ids = [c.uuid for c in old.comments()]
+        new_comment_ids = [c.uuid for c in new.comments()]
+        for uuid in new_comment_ids:
+            new_comment = new.comment_from_uuid(uuid)
+            try:
+                old_comment = old.comment_from_uuid(uuid)
+            except KeyError:
+                added.append(new_comment)
+            else:
+                if old_comment != new_comment:
+                    modified.append((old_comment, new_comment))
+        for uuid in old_comment_ids:
+            if uuid not in new_comment_ids:
+                new_comment = new.comment_from_uuid(uuid)
+                removed.append(new_comment)
+        self.__changed_comments[new.uuid] = (added, modified, removed)
+        return self.__changed_comments[new.uuid]
+    def _attribute_changes(self, old, new, attributes):
+        """
+        Take two objects old and new, and compare the value of *.attr
+        for attr in the list attribute names.  Returns a list of
+          (attr_name, old_value, new_value)
+        tuples.
+        """
+        change_list = []
+        for attr in attributes:
+            old_value = getattr(old, attr)
+            new_value = getattr(new, attr)
+            if old_value != new_value:
+                change_list.append((attr, old_value, new_value))
+        if len(change_list) >= 0:
+            return change_list
         return None
+    def _settings_properties_attribute_changes(self, old, new,
+                                              hidden_properties=[]):
+        properties = sorted(new.settings_properties)
+        for p in hidden_properties:
+            properties.remove(p)
+        attributes = [settings_object.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,
+            ["rcs_name"]) # tweaked by bugdir.duplicate_bugdir
+    def _bug_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
+    def _comment_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
 
-def bug_changes(old, new):
-    bug_settings = sorted(new.settings_properties)
-    change_list = change_lines(old, new, bug_settings)
-    change_strings = ["%s: %s -> %s" % f for f in change_list]
+    # report generation methods
 
-    old_comment_ids = [c.uuid for c in old.comments()]
-    new_comment_ids = [c.uuid for c in new.comments()]
-    for comment_id in new_comment_ids:
-        if comment_id not in old_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id), "new")
-            change_strings.append(summary)
-    for comment_id in old_comment_ids:
-        if comment_id not in new_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id),
-                                      "removed")
-            change_strings.append(summary)
+    def report_tree(self, diff_tree=DiffTree):
+        """
+        Pretty bare to make it easy to adjust to specific cases.  You
+        can pass in a DiffTree subclass via diff_tree to override the
+        default report assembly process.
+        """
+        if hasattr(self, "__report_tree"):
+            return self.__report_tree
+        bugdir_settings = sorted(self.new_bugdir.settings_properties)
+        bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
+        root = diff_tree("bugdir")
+        bugdir_attribute_changes = self._bugdir_attribute_changes()
+        if len(bugdir_attribute_changes) > 0:
+            bugdir = diff_tree("settings", bugdir_attribute_changes,
+                               self.bugdir_attribute_change_string)
+            root.append(bugdir)
+        bug_root = diff_tree("bugs")
+        root.append(bug_root)
+        add,mod,rem = self._changed_bugs()
+        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)
+        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)
+        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,
+                                 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)
+                b.append(bsum)
+            cr = diff_tree("comments")
+            b.append(cr)
+            a,m,d = self._changed_comments(old, new)
+            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)
+            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)
+            for o,n in m:
+                c = diff_tree(n.uuid, self._comment_attribute_changes(o, n),
+                             self.comment_attribute_change_string)
+                cmod.append(c)
+                if o.body != n.body:
+                    data = (o.body, n.body)
+                    cbody = diff_tree("cbody", data,
+                                      self.comment_body_change_string)
+                    c.append(cbody)
+            cr.extend([cnew, crem, cmod])
+        self.__report_tree = root
+        return self.__report_tree
 
-    if len(change_strings) == 0:
-        return None
-    return "%s\n  %s" % (new.string(shortlist=True),
-                         "  \n".join(change_strings))
+    # change data -> string methods.
+    # 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]
+        for i,change_string in enumerate(change_strings):
+            change_strings[i] = indent_string+change_string
+        return u"\n".join(change_strings)
+    def bugdir_attribute_change_string(self, attribute_changes):
+        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" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def comment_attribute_change_string(self, attribute_changes):
+        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)
+    def bug_rem_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_mod_string(self, bugs):
+        old_bug,new_bug = bugs
+        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)
+    def _comment_summary_string(self, comment):
+        return "from %s on %s" % (comment.author, time_to_str(comment.time))
+    def comment_add_string(self, comment):
+        return self._comment_summary_string(comment)
+    def comment_rem_string(self, comment):
+        return self._comment_summary_string(comment)
+    def comment_body_change_string(self, bodies):
+        old_body,new_body = bodies
+        return difflib.unified_diff(old_body, new_body)
 
-def comment_summary(comment, status):
-    return "%8s comment from %s on %s" % (status, comment.From, 
-                                          time_to_str(comment.time))
 
 suite = doctest.DocTestSuite()