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