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