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