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