1 # Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
2 # Gianluca Montecchi <gian@grys.it>
3 # W. Trevor King <wking@drexel.edu>
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.
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.
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.
19 """Compare two bug trees."""
25 from libbe import bugdir, bug, settings_object, tree
26 from libbe.utility import time_to_str
27 if libbe.TESTING == True:
31 class SubscriptionType (tree.Tree):
33 Trees of subscription types to allow users to select exactly what
34 notifications they want to subscribe to.
36 def __init__(self, type_name, *args, **kwargs):
37 tree.Tree.__init__(self, *args, **kwargs)
41 def __cmp__(self, other):
42 return cmp(self.type, other.type)
44 return "<SubscriptionType: %s>" % str(self)
45 def string_tree(self, indent=0):
47 for depth,node in self.thread():
48 lines.append("%s%s" % (" "*(indent+2*depth), node))
49 return "\n".join(lines)
52 BUGDIR_TYPE_NEW = SubscriptionType("new")
53 BUGDIR_TYPE_MOD = SubscriptionType("mod")
54 BUGDIR_TYPE_REM = SubscriptionType("rem")
55 BUGDIR_TYPE_ALL = SubscriptionType("all",
56 [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM])
58 # same name as BUGDIR_TYPE_ALL for consistency
59 BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
61 INVALID_TYPE = SubscriptionType("INVALID")
63 class InvalidType (ValueError):
64 def __init__(self, type_name, type_root):
65 msg = "Invalid type %s for tree:\n%s" \
66 % (type_name, type_root.string_tree(4))
67 ValueError.__init__(self, msg)
68 self.type_name = type_name
69 self.type_root = type_root
71 def type_from_name(name, type_root, default=None, default_ok=False):
72 if name == str(type_root):
74 for t in type_root.traverse():
79 raise InvalidType(name, type_root)
81 class Subscription (object):
83 >>> subscriptions = [Subscription('XYZ', 'all'),
84 ... Subscription('DIR', 'new'),
85 ... Subscription('ABC', BUG_TYPE_ALL),]
86 >>> print sorted(subscriptions)
87 [<Subscription: DIR (new)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
89 def __init__(self, id, subscription_type, **kwargs):
90 if 'type_root' not in kwargs:
92 kwargs['type_root'] = BUGDIR_TYPE_ALL
94 kwargs['type_root'] = BUG_TYPE_ALL
95 if type(subscription_type) in types.StringTypes:
96 subscription_type = type_from_name(subscription_type, **kwargs)
98 self.type = subscription_type
99 def __cmp__(self, other):
100 for attr in 'id', 'type':
101 value = cmp(getattr(self, attr), getattr(other, attr))
103 if self.id == BUGDIR_ID:
105 elif other.id == BUGDIR_ID:
109 return str(self.type)
111 return "<Subscription: %s (%s)>" % (self.id, self.type)
113 def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
115 >>> subscriptions_from_string(None)
116 [<Subscription: DIR (all)>]
117 >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all')
118 [<Subscription: DIR (new)>, <Subscription: DIR (rem)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
119 >>> subscriptions_from_string('DIR::new')
120 Traceback (most recent call last):
122 ValueError: Invalid subscription "DIR::new", should be ID:TYPE
125 return [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
127 for subscription in string.split(','):
128 fields = subscription.split(':')
130 raise ValueError('Invalid subscription "%s", should be ID:TYPE'
133 subscriptions.append(Subscription(id, type))
136 class DiffTree (tree.Tree):
138 A tree holding difference data for easy report generation.
139 >>> bugdir = DiffTree("bugdir")
140 >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
141 >>> bugdir.append(bdsettings)
142 >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
143 >>> bugdir.append(bugs)
144 >>> new = DiffTree("new", "new bugs: ABC, DEF")
146 >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
148 >>> print bugdir.report_string()
152 removed bugs: RST, UVW
153 >>> print "\\n".join(bugdir.paths())
159 >>> bugdir.child_by_path("/") == bugdir
161 >>> bugdir.child_by_path("/bugs") == bugs
163 >>> bugdir.child_by_path("/bugs/rem") == rem
165 >>> bugdir.child_by_path("bugdir") == bugdir
167 >>> bugdir.child_by_path("bugdir/") == bugdir
169 >>> bugdir.child_by_path("bugdir/bugs") == bugs
171 >>> bugdir.child_by_path("/bugs").masked = True
172 >>> print bugdir.report_string()
175 def __init__(self, name, data=None, data_part_fn=str,
176 requires_children=False, masked=False):
177 tree.Tree.__init__(self)
180 self.data_part_fn = data_part_fn
181 self.requires_children = requires_children
183 def paths(self, parent_path=None):
185 if parent_path == None:
188 path = "%s/%s" % (parent_path, self.name)
191 paths.extend(child.paths(path))
193 def child_by_path(self, path):
194 if hasattr(path, "split"): # convert string path to a list of names
195 names = path.split("/")
197 names[0] = self.name # replace root with self
198 if len(names) > 1 and names[-1] == "":
199 names = names[:-1] # strip empty tail
200 else: # it was already an array
202 assert len(names) > 0, path
203 if names[0] == self.name:
207 if names[1] == child.name:
208 return child.child_by_path(names[1:])
210 raise KeyError, "%s doesn't match '%s'" % (names, self.name)
211 raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
212 def report_string(self):
213 report = self.report()
216 return '\n'.join(report)
217 def report(self, root=None, parent=None, depth=0):
219 root = self.make_root()
220 if self.masked == True:
222 data_part = self.data_part(depth)
223 if self.requires_children == True \
224 and len([c for c in self if c.masked == False]) == 0:
227 self.join(root, parent, data_part)
228 if data_part != None:
231 root = child.report(root, self, depth)
235 def join(self, root, parent, data_part):
236 if data_part != None:
237 root.append(data_part)
238 def data_part(self, depth, indent=True):
239 if self.data == None:
241 if hasattr(self, "_cached_data_part"):
242 return self._cached_data_part
243 data_part = self.data_part_fn(self.data)
245 data_part_lines = data_part.splitlines()
247 line_sep = "\n"+indent
248 data_part = indent+line_sep.join(data_part_lines)
249 self._cached_data_part = data_part
254 Difference tree generator for BugDirs.
256 >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
257 >>> bd.user_id = "John Doe <j@doe.com>"
258 >>> bd_new = copy.deepcopy(bd)
259 >>> bd_new.target = "1.0"
260 >>> a = bd_new.bug_from_uuid("a")
261 >>> rep = a.comment_root.new_reply("I'm closing this bug")
262 >>> rep.uuid = "acom"
263 >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
264 >>> a.status = "closed"
265 >>> b = bd_new.bug_from_uuid("b")
266 >>> bd_new.remove_bug(b)
267 >>> c = bd_new.new_bug("c", "Bug C")
268 >>> d = Diff(bd, bd_new)
269 >>> r = d.report_tree()
270 >>> print "\\n".join(r.paths())
280 bugdir/bugs/mod/a/settings
281 bugdir/bugs/mod/a/comments
282 bugdir/bugs/mod/a/comments/new
283 bugdir/bugs/mod/a/comments/new/acom
284 bugdir/bugs/mod/a/comments/rem
285 bugdir/bugs/mod/a/comments/mod
286 >>> print r.report_string()
287 Changed bug directory settings:
295 Changed bug settings:
296 status: open -> closed
298 from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
299 I'm closing this bug...
301 You can also limit the report generation by providing a list of
304 >>> subscriptions = [Subscription('DIR', BUGDIR_TYPE_NEW),
305 ... Subscription('b', BUG_TYPE_ALL)]
306 >>> r = d.report_tree(subscriptions)
307 >>> print r.report_string()
313 While sending subscriptions to report_tree() makes the report
314 generation more efficient (because you may not need to compare
315 _all_ the bugs, etc.), sometimes you will have several sets of
316 subscriptions. In that case, it's better to run full_report()
317 first, and then use report_tree() to avoid redundant comparisons.
320 >>> print d.report_tree([subscriptions[0]]).report_string()
323 >>> print d.report_tree([subscriptions[1]]).report_string()
329 def __init__(self, old_bugdir, new_bugdir):
330 self.old_bugdir = old_bugdir
331 self.new_bugdir = new_bugdir
333 # data assembly methods
335 def _changed_bugs(self, subscriptions):
337 Search for differences in all bugs between .old_bugdir and
339 (added_bugs, modified_bugs, removed_bugs)
340 where added_bugs and removed_bugs are lists of added and
341 removed bugs respectively. modified_bugs is a list of
342 (old_bug,new_bug) pairs.
344 bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
347 for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD]:
348 if bd_type in bugdir_types:
349 new_uuids = list(self.new_bugdir.uuids())
351 for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_REM]:
352 if bd_type in bugdir_types:
353 old_uuids = list(self.old_bugdir.uuids())
355 subscribed_bugs = [s.id for s in subscriptions
356 if BUG_TYPE_ALL.has_descendant( \
357 s.type, match_self=True)]
358 new_uuids.extend([s for s in subscribed_bugs
359 if self.new_bugdir.has_bug(s)])
360 new_uuids = sorted(set(new_uuids))
361 old_uuids.extend([s for s in subscribed_bugs
362 if self.old_bugdir.has_bug(s)])
363 old_uuids = sorted(set(old_uuids))
367 for uuid in new_uuids:
368 new_bug = self.new_bugdir.bug_from_uuid(uuid)
370 old_bug = self.old_bugdir.bug_from_uuid(uuid)
372 if BUGDIR_TYPE_ALL in bugdir_types \
373 or BUGDIR_TYPE_NEW in bugdir_types \
374 or uuid in subscribed_bugs:
375 added.append(new_bug)
377 if BUGDIR_TYPE_ALL in bugdir_types \
378 or BUGDIR_TYPE_MOD in bugdir_types \
379 or uuid in subscribed_bugs:
380 if old_bug.sync_with_disk == True:
381 old_bug.load_comments()
382 if new_bug.sync_with_disk == True:
383 new_bug.load_comments()
384 if old_bug != new_bug:
385 modified.append((old_bug, new_bug))
386 for uuid in old_uuids:
387 if not self.new_bugdir.has_bug(uuid):
388 old_bug = self.old_bugdir.bug_from_uuid(uuid)
389 removed.append(old_bug)
392 modified.sort(self._bug_modified_cmp)
393 return (added, modified, removed)
394 def _bug_modified_cmp(self, left, right):
395 return cmp(left[1], right[1])
396 def _changed_comments(self, old, new):
398 Search for differences in all loaded comments between the bugs
400 (added_comments, modified_comments, removed_comments)
401 analogous to ._changed_bugs.
403 if hasattr(self, "__changed_comments"):
404 if new.uuid in self.__changed_comments:
405 return self.__changed_comments[new.uuid]
407 self.__changed_comments = {}
411 old.comment_root.sort(key=lambda comm : comm.time)
412 new.comment_root.sort(key=lambda comm : comm.time)
413 old_comment_ids = [c.uuid for c in old.comments()]
414 new_comment_ids = [c.uuid for c in new.comments()]
415 for uuid in new_comment_ids:
416 new_comment = new.comment_from_uuid(uuid)
418 old_comment = old.comment_from_uuid(uuid)
420 added.append(new_comment)
422 if old_comment != new_comment:
423 modified.append((old_comment, new_comment))
424 for uuid in old_comment_ids:
425 if uuid not in new_comment_ids:
426 old_comment = old.comment_from_uuid(uuid)
427 removed.append(old_comment)
428 self.__changed_comments[new.uuid] = (added, modified, removed)
429 return self.__changed_comments[new.uuid]
430 def _attribute_changes(self, old, new, attributes):
432 Take two objects old and new, and compare the value of *.attr
433 for attr in the list attribute names. Returns a list of
434 (attr_name, old_value, new_value)
438 for attr in attributes:
439 old_value = getattr(old, attr)
440 new_value = getattr(new, attr)
441 if old_value != new_value:
442 change_list.append((attr, old_value, new_value))
443 if len(change_list) >= 0:
446 def _settings_properties_attribute_changes(self, old, new,
447 hidden_properties=[]):
448 properties = sorted(new.settings_properties)
449 for p in hidden_properties:
451 attributes = [settings_object.setting_name_to_attr_name(None, p)
453 return self._attribute_changes(old, new, attributes)
454 def _bugdir_attribute_changes(self):
455 return self._settings_properties_attribute_changes( \
456 self.old_bugdir, self.new_bugdir,
457 ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
458 def _bug_attribute_changes(self, old, new):
459 return self._settings_properties_attribute_changes(old, new)
460 def _comment_attribute_changes(self, old, new):
461 return self._settings_properties_attribute_changes(old, new)
463 # report generation methods
465 def full_report(self, diff_tree=DiffTree):
467 Generate a full report for efficiency if you'll be using
468 .report_tree() with several sets of subscriptions.
470 self._cached_full_report = self.report_tree(diff_tree=diff_tree,
472 self._cached_full_report_diff_tree = diff_tree
473 def _sub_report(self, subscriptions):
475 Return ._cached_full_report masked for subscriptions.
477 root = self._cached_full_report
478 bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
479 subscribed_bugs = [s.id for s in subscriptions
480 if BUG_TYPE_ALL.has_descendant( \
481 s.type, match_self=True)]
482 selected_by_bug = [node.name
483 for node in root.child_by_path('bugdir/bugs')]
484 if BUGDIR_TYPE_ALL in bugdir_types:
485 for node in root.traverse():
490 node = root.child_by_path('bugdir/settings')
494 for name,type in (('new', BUGDIR_TYPE_NEW),
495 ('mod', BUGDIR_TYPE_MOD),
496 ('rem', BUGDIR_TYPE_REM)):
497 if type in bugdir_types:
498 bugs = root.child_by_path('bugdir/bugs/%s' % name)
499 for bug_node in bugs:
500 for node in bug_node.traverse():
502 selected_by_bug.remove(name)
503 for name in selected_by_bug:
504 bugs = root.child_by_path('bugdir/bugs/%s' % name)
505 for bug_node in bugs:
506 if bug_node.name in subscribed_bugs:
507 for node in bug_node.traverse():
510 for node in bug_node.traverse():
513 def report_tree(self, subscriptions=None, diff_tree=DiffTree,
516 Pretty bare to make it easy to adjust to specific cases. You
517 can pass in a DiffTree subclass via diff_tree to override the
518 default report assembly process.
520 if allow_cached == True \
521 and hasattr(self, '_cached_full_report') \
522 and diff_tree == self._cached_full_report_diff_tree:
523 return self._sub_report(subscriptions)
524 if subscriptions == None:
525 subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
526 bugdir_settings = sorted(self.new_bugdir.settings_properties)
527 bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
528 root = diff_tree("bugdir")
529 bugdir_subscriptions = [s.type for s in subscriptions
530 if s.id == BUGDIR_ID]
531 if BUGDIR_TYPE_ALL in bugdir_subscriptions:
532 bugdir_attribute_changes = self._bugdir_attribute_changes()
533 if len(bugdir_attribute_changes) > 0:
534 bugdir = diff_tree("settings", bugdir_attribute_changes,
535 self.bugdir_attribute_change_string)
537 bug_root = diff_tree("bugs")
538 root.append(bug_root)
539 add,mod,rem = self._changed_bugs(subscriptions)
540 bnew = diff_tree("new", "New bugs:", requires_children=True)
541 bug_root.append(bnew)
543 b = diff_tree(bug.uuid, bug, self.bug_add_string)
545 brem = diff_tree("rem", "Removed bugs:", requires_children=True)
546 bug_root.append(brem)
548 b = diff_tree(bug.uuid, bug, self.bug_rem_string)
550 bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
551 bug_root.append(bmod)
553 b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
555 bug_attribute_changes = self._bug_attribute_changes(old, new)
556 if len(bug_attribute_changes) > 0:
557 bset = diff_tree("settings", bug_attribute_changes,
558 self.bug_attribute_change_string)
560 if old.summary != new.summary:
561 data = (old.summary, new.summary)
562 bsum = diff_tree("summary", data, self.bug_summary_change_string)
564 cr = diff_tree("comments")
566 a,m,d = self._changed_comments(old, new)
567 cnew = diff_tree("new", "New comments:", requires_children=True)
569 c = diff_tree(comment.uuid, comment, self.comment_add_string)
571 crem = diff_tree("rem", "Removed comments:",requires_children=True)
573 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
575 cmod = diff_tree("mod","Modified comments:",requires_children=True)
577 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
579 comm_attribute_changes = self._comment_attribute_changes(o, n)
580 if len(comm_attribute_changes) > 0:
581 cset = diff_tree("settings", comm_attribute_changes,
582 self.comment_attribute_change_string)
584 data = (o.body, n.body)
585 cbody = diff_tree("cbody", data,
586 self.comment_body_change_string)
588 cr.extend([cnew, crem, cmod])
591 # change data -> string methods.
592 # Feel free to play with these in subclasses.
594 def attribute_change_string(self, attribute_changes, indent=0):
595 indent_string = " "*indent
596 change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
597 for i,change_string in enumerate(change_strings):
598 change_strings[i] = indent_string+change_string
599 return u"\n".join(change_strings)
600 def bugdir_attribute_change_string(self, attribute_changes):
601 return "Changed bug directory settings:\n%s" % \
602 self.attribute_change_string(attribute_changes, indent=1)
603 def bug_attribute_change_string(self, attribute_changes):
604 return "Changed bug settings:\n%s" % \
605 self.attribute_change_string(attribute_changes, indent=1)
606 def comment_attribute_change_string(self, attribute_changes):
607 return "Changed comment settings:\n%s" % \
608 self.attribute_change_string(attribute_changes, indent=1)
609 def bug_add_string(self, bug):
610 return bug.string(shortlist=True)
611 def bug_rem_string(self, bug):
612 return bug.string(shortlist=True)
613 def bug_mod_string(self, bugs):
614 old_bug,new_bug = bugs
615 return new_bug.string(shortlist=True)
616 def bug_summary_change_string(self, summaries):
617 old_summary,new_summary = summaries
618 return "summary changed:\n %s\n %s" % (old_summary, new_summary)
619 def _comment_summary_string(self, comment):
620 return "from %s on %s" % (comment.author, time_to_str(comment.time))
621 def comment_add_string(self, comment):
622 summary = self._comment_summary_string(comment)
623 first_line = comment.body.splitlines()[0]
624 return "%s\n %s..." % (summary, first_line)
625 def comment_rem_string(self, comment):
626 summary = self._comment_summary_string(comment)
627 first_line = comment.body.splitlines()[0]
628 return "%s\n %s..." % (summary, first_line)
629 def comment_mod_string(self, comments):
630 old_comment,new_comment = comments
631 return self._comment_summary_string(new_comment)
632 def comment_body_change_string(self, bodies):
633 old_body,new_body = bodies
634 return difflib.unified_diff(old_body, new_body)
637 if libbe.TESTING == True:
638 suite = doctest.DocTestSuite()