Added cmp functions to libbe.comment, and fleshed them out in libbe.bug.
[be.git] / libbe / diff.py
1 # Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
2 #                         W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17 """Compare two bug trees"""
18 from libbe import bugdir, bug, settings_object, tree
19 from libbe.utility import time_to_str
20 import difflib
21 import doctest
22
23 class DiffTree (tree.Tree):
24     """
25     A tree holding difference data for easy report generation.
26     >>> all = DiffTree("all")
27     >>> bugdir = DiffTree("bugdir", data="target: None -> 1.0")
28     >>> all.append(bugdir)
29     >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
30     >>> all.append(bugs)
31     >>> new = DiffTree("new", "new bugs: ABC, DEF")
32     >>> bugs.append(new)
33     >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
34     >>> bugs.append(rem)
35     >>> print all.report_string()
36     target: None -> 1.0
37     bug-count: 5 -> 6
38       new bugs: ABC, DEF
39       removed bugs: RST, UVW
40     >>> print "\\n".join(all.paths())
41     all
42     all/bugdir
43     all/bugs
44     all/bugs/new
45     all/bugs/rem
46     >>> all.child_by_path("/") == all
47     True
48     >>> all.child_by_path("/bugs") == bugs
49     True
50     >>> all.child_by_path("/bugs/rem") == rem
51     True
52     >>> all.child_by_path("all") == all
53     True
54     >>> all.child_by_path("all/") == all
55     True
56     >>> all.child_by_path("all/bugs") == bugs
57     True
58     >>> all.child_by_path("/bugs").masked = True
59     >>> print all.report_string()
60     target: None -> 1.0
61     """
62     def __init__(self, name, data=None, data_string_fn=str,
63                  requires_children=False, masked=False):
64         tree.Tree.__init__(self)
65         self.name = name
66         self.data = data
67         self.data_string_fn = data_string_fn
68         self.requires_children = requires_children
69         self.masked = masked
70     def paths(self, parent_path=None):
71         paths = []
72         if parent_path == None:
73             path = self.name
74         else:
75             path = "%s/%s" % (parent_path, self.name)
76         paths.append(path)
77         for child in self:
78             paths.extend(child.paths(path))
79         return paths
80     def child_by_path(self, path):
81         if hasattr(path, "split"): # convert string path to a list of names
82             names = path.split("/")
83             if names[0] == "":
84                 names[0] = self.name # replace root with self
85             if len(names) > 1 and names[-1] == "":
86                 names = names[:-1] # strip empty tail
87         else: # it was already an array
88             names = path
89         assert len(names) > 0, path
90         if names[0] == self.name:
91             if len(names) == 1:
92                 return self
93             for child in self:
94                 if names[1] == child.name:
95                     return child.child_by_path(names[1:])
96         if len(names) == 1:
97             raise KeyError, "%s doesn't match '%s'" % (names, self.name)
98         raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
99     def report_string(self):
100         return "\n".join(self.report())
101     def report(self, root=None, depth=0):
102         if root == None:
103             root = self.make_root()
104         if self.masked == True:
105             return None
106         data_string = self.data_string(depth)
107         if self.data == None:
108             pass
109         elif self.requires_children == True and len(self) == 0:
110             pass
111         else:
112             self.join(root, data_string)
113             depth += 1
114         for child in self:
115             child.report(root, depth)
116         return root
117     def make_root(self):
118         return []
119     def join(self, root, part):
120         if part != None:
121             root.append(part)
122     def data_string(self, depth, indent=True):
123         if hasattr(self, "_cached_data_string"):
124             return self._cached_data_string
125         data_string = self.data_string_fn(self.data)
126         if indent == True:
127             data_string_lines = data_string.splitlines()
128             indent = "  "*(depth)
129             line_sep = "\n"+indent
130             data_string = indent+line_sep.join(data_string_lines)
131         self._cached_data_string = data_string
132         return data_string
133
134 class Diff (object):
135     """
136     Difference tree generator for BugDirs.
137     >>> import copy
138     >>> bd = bugdir.simple_bug_dir(sync_with_disk=False)
139     >>> bd.user_id = "John Doe <j@doe.com>"
140     >>> bd_new = copy.deepcopy(bd)
141     >>> bd_new.target = "1.0"
142     >>> a = bd_new.bug_from_uuid("a")
143     >>> rep = a.comment_root.new_reply("I'm closing this bug")
144     >>> rep.uuid = "acom"
145     >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
146     >>> a.status = "closed"
147     >>> b = bd_new.bug_from_uuid("b")
148     >>> bd_new.remove_bug(b)
149     >>> c = bd_new.new_bug("c", "Bug C")
150     >>> d = Diff(bd, bd_new)
151     >>> r = d.report_tree()
152     >>> print "\\n".join(r.paths())
153     bugdir
154     bugdir/settings
155     bugdir/bugs
156     bugdir/bugs/new
157     bugdir/bugs/new/c
158     bugdir/bugs/rem
159     bugdir/bugs/rem/b
160     bugdir/bugs/mod
161     bugdir/bugs/mod/a
162     bugdir/bugs/mod/a/settings
163     bugdir/bugs/mod/a/comments
164     bugdir/bugs/mod/a/comments/new
165     bugdir/bugs/mod/a/comments/new/acom
166     bugdir/bugs/mod/a/comments/rem
167     bugdir/bugs/mod/a/comments/mod
168     >>> print r.report_string()
169     Changed bug directory settings:
170       target: None -> 1.0
171     New bugs:
172       c:om: Bug C
173     Removed bugs:
174       b:cm: Bug B
175     Modified bugs:
176       a:cm: Bug A
177         Changed bug settings:
178           status: open -> closed
179         New comments:
180           from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
181     """
182     def __init__(self, old_bugdir, new_bugdir):
183         self.old_bugdir = old_bugdir
184         self.new_bugdir = new_bugdir
185
186     # data assembly methods
187
188     def _changed_bugs(self):
189         """
190         Search for differences in all bugs between .old_bugdir and
191         .new_bugdir.  Returns
192           (added_bugs, modified_bugs, removed_bugs)
193         where added_bugs and removed_bugs are lists of added and
194         removed bugs respectively.  modified_bugs is a list of
195         (old_bug,new_bug) pairs.
196         """
197         if hasattr(self, "__changed_bugs"):
198             return self.__changed_bugs
199         added = []
200         removed = []
201         modified = []
202         for uuid in self.new_bugdir.list_uuids():
203             new_bug = self.new_bugdir.bug_from_uuid(uuid)
204             try:
205                 old_bug = self.old_bugdir.bug_from_uuid(uuid)
206             except KeyError:
207                 added.append(new_bug)
208             else:
209                 if old_bug.sync_with_disk == True:
210                     old_bug.load_comments()
211                 if new_bug.sync_with_disk == True:
212                     new_bug.load_comments()
213                 if old_bug != new_bug:
214                     modified.append((old_bug, new_bug))
215         for uuid in self.old_bugdir.list_uuids():
216             if not self.new_bugdir.has_bug(uuid):
217                 old_bug = self.old_bugdir.bug_from_uuid(uuid)
218                 removed.append(old_bug)
219         added.sort()
220         removed.sort()
221         modified.sort(self._bug_modified_cmp)
222         self.__changed_bugs = (added, modified, removed)
223         return self.__changed_bugs
224     def _bug_modified_cmp(self, left, right):
225         return cmp(left[1], right[1])
226     def _changed_comments(self, old, new):
227         """
228         Search for differences in all loaded comments between the bugs
229         old and new.  Returns
230           (added_comments, modified_comments, removed_comments)
231         analogous to ._changed_bugs.
232         """
233         if hasattr(self, "__changed_comments"):
234             if new.uuid in self.__changed_comments:
235                 return self.__changed_comments[new.uuid]
236         else:
237             self.__changed_comments = {}
238         added = []
239         removed = []
240         modified = []
241         old.comment_root.sort(key=lambda comm : comm.time)
242         new.comment_root.sort(key=lambda comm : comm.time)
243         old_comment_ids = [c.uuid for c in old.comments()]
244         new_comment_ids = [c.uuid for c in new.comments()]
245         for uuid in new_comment_ids:
246             new_comment = new.comment_from_uuid(uuid)
247             try:
248                 old_comment = old.comment_from_uuid(uuid)
249             except KeyError:
250                 added.append(new_comment)
251             else:
252                 if old_comment != new_comment:
253                     modified.append((old_comment, new_comment))
254         for uuid in old_comment_ids:
255             if uuid not in new_comment_ids:
256                 new_comment = new.comment_from_uuid(uuid)
257                 removed.append(new_comment)
258         self.__changed_comments[new.uuid] = (added, modified, removed)
259         return self.__changed_comments[new.uuid]
260     def _attribute_changes(self, old, new, attributes):
261         """
262         Take two objects old and new, and compare the value of *.attr
263         for attr in the list attribute names.  Returns a list of
264           (attr_name, old_value, new_value)
265         tuples.
266         """
267         change_list = []
268         for attr in attributes:
269             old_value = getattr(old, attr)
270             new_value = getattr(new, attr)
271             if old_value != new_value:
272                 change_list.append((attr, old_value, new_value))
273         if len(change_list) >= 0:
274             return change_list
275         return None
276     def _settings_properties_attribute_changes(self, old, new,
277                                               hidden_properties=[]):
278         properties = sorted(new.settings_properties)
279         for p in hidden_properties:
280             properties.remove(p)
281         attributes = [settings_object.setting_name_to_attr_name(None, p)
282                       for p in properties]
283         return self._attribute_changes(old, new, attributes)
284     def _bugdir_attribute_changes(self):
285         return self._settings_properties_attribute_changes( \
286             self.old_bugdir, self.new_bugdir,
287             ["rcs_name"]) # tweaked by bugdir.duplicate_bugdir
288     def _bug_attribute_changes(self, old, new):
289         return self._settings_properties_attribute_changes(old, new)
290     def _comment_attribute_changes(self, old, new):
291         return self._settings_properties_attribute_changes(old, new)
292
293     # report generation methods
294
295     def report_tree(self, diff_tree=DiffTree):
296         """
297         Pretty bare to make it easy to adjust to specific cases.  You
298         can pass in a DiffTree subclass via diff_tree to override the
299         default report assembly process.
300         """
301         if hasattr(self, "__report_tree"):
302             return self.__report_tree
303         bugdir_settings = sorted(self.new_bugdir.settings_properties)
304         bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
305         root = diff_tree("bugdir")
306         bugdir_attribute_changes = self._bugdir_attribute_changes()
307         if len(bugdir_attribute_changes) > 0:
308             bugdir = diff_tree("settings", bugdir_attribute_changes,
309                                self.bugdir_attribute_change_string)
310             root.append(bugdir)
311         bug_root = diff_tree("bugs")
312         root.append(bug_root)
313         add,mod,rem = self._changed_bugs()
314         bnew = diff_tree("new", "New bugs:", requires_children=True)
315         bug_root.append(bnew)
316         for bug in add:
317             b = diff_tree(bug.uuid, bug, self.bug_add_string)
318             bnew.append(b)
319         brem = diff_tree("rem", "Removed bugs:", requires_children=True)
320         bug_root.append(brem)
321         for bug in rem:
322             b = diff_tree(bug.uuid, bug, self.bug_rem_string)
323             brem.append(b)
324         bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
325         bug_root.append(bmod)
326         for old,new in mod:
327             b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
328             bmod.append(b)
329             bug_attribute_changes = self._bug_attribute_changes(old, new)
330             if len(bug_attribute_changes) > 0:
331                 bset = diff_tree("settings", bug_attribute_changes,
332                                  self.bug_attribute_change_string)
333                 b.append(bset)
334             if old.summary != new.summary:
335                 data = (old.summary, new.summary)
336                 bsum = diff_tree("summary", data, self.bug_summary_change_string)
337                 b.append(bsum)
338             cr = diff_tree("comments")
339             b.append(cr)
340             a,m,d = self._changed_comments(old, new)
341             cnew = diff_tree("new", "New comments:", requires_children=True)
342             for comment in a:
343                 c = diff_tree(comment.uuid, comment, self.comment_add_string)
344                 cnew.append(c)
345             crem = diff_tree("rem", "Removed comments:",requires_children=True)
346             for comment in d:
347                 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
348                 crem.append(c)
349             cmod = diff_tree("mod","Modified comments:",requires_children=True)
350             for o,n in m:
351                 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
352                 cmod.append(c)
353                 comm_attribute_changes = self._comment_attribute_changes(o, n)
354                 if len(comm_attribute_changes) > 0:
355                     cset = diff_tree("settings", comm_attribute_changes,
356                                      self.comment_attribute_change_string)
357                 if o.body != n.body:
358                     data = (o.body, n.body)
359                     cbody = diff_tree("cbody", data,
360                                       self.comment_body_change_string)
361                     c.append(cbody)
362             cr.extend([cnew, crem, cmod])
363         self.__report_tree = root
364         return self.__report_tree
365
366     # change data -> string methods.
367     # Feel free to play with these in subclasses.
368
369     def attribute_change_string(self, attribute_changes, indent=0):
370         indent_string = "  "*indent
371         change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
372         for i,change_string in enumerate(change_strings):
373             change_strings[i] = indent_string+change_string
374         return u"\n".join(change_strings)
375     def bugdir_attribute_change_string(self, attribute_changes):
376         return "Changed bug directory settings:\n%s" % \
377             self.attribute_change_string(attribute_changes, indent=1)
378     def bug_attribute_change_string(self, attribute_changes):
379         return "Changed bug settings:\n%s" % \
380             self.attribute_change_string(attribute_changes, indent=1)
381     def comment_attribute_change_string(self, attribute_changes):
382         return "Changed comment settings:\n%s" % \
383             self.attribute_change_string(attribute_changes, indent=1)
384     def bug_add_string(self, bug):
385         return bug.string(shortlist=True)
386     def bug_rem_string(self, bug):
387         return bug.string(shortlist=True)
388     def bug_mod_string(self, bugs):
389         old_bug,new_bug = bugs
390         return new_bug.string(shortlist=True)
391     def bug_summary_change_string(self, summaries):
392         old_summary,new_summary = summaries
393         return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
394     def _comment_summary_string(self, comment):
395         return "from %s on %s" % (comment.author, time_to_str(comment.time))
396     def comment_add_string(self, comment):
397         summary = self._comment_summary_string(comment)
398         first_line = comment.body.splitlines()[0]
399         return "%s\n  %s..." % (summary, first_line)
400     def comment_rem_string(self, comment):
401         return self._comment_summary_string(comment)
402     def comment_mod_string(self, comments):
403         old_comment,new_comment = comments
404         return self._comment_summary_string(new_comment)
405     def comment_body_change_string(self, bodies):
406         old_body,new_body = bodies
407         return difflib.unified_diff(old_body, new_body)
408
409
410 suite = doctest.DocTestSuite()