1 # Copyright (C) 2005-2011 Aaron Bentley <abentley@panoramicfeedback.com>
2 # Gianluca Montecchi <gian@grys.it>
3 # W. Trevor King <wking@drexel.edu>
5 # This file is part of Bugs Everywhere.
7 # Bugs Everywhere is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation, either version 2 of the License, or (at your
10 # option) any later version.
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 # General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
20 """Tools for comparing two :class:`libbe.bug.BugDir`\s.
29 import libbe.util.tree
30 from libbe.storage.util.settings_object import setting_name_to_attr_name
31 from libbe.util.utility import time_to_str
34 class SubscriptionType (libbe.util.tree.Tree):
35 """Trees of subscription types to allow users to select exactly what
36 notifications they want to subscribe to.
38 def __init__(self, type_name, *args, **kwargs):
39 libbe.util.tree.Tree.__init__(self, *args, **kwargs)
43 def __cmp__(self, other):
44 return cmp(self.type, other.type)
46 return '<SubscriptionType: %s>' % str(self)
47 def string_tree(self, indent=0):
49 for depth,node in self.thread():
50 lines.append('%s%s' % (' '*(indent+2*depth), node))
51 return '\n'.join(lines)
54 BUGDIR_TYPE_NEW = SubscriptionType('new')
55 BUGDIR_TYPE_MOD = SubscriptionType('mod')
56 BUGDIR_TYPE_REM = SubscriptionType('rem')
57 BUGDIR_TYPE_ALL = SubscriptionType('all',
58 [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM])
60 # same name as BUGDIR_TYPE_ALL for consistency
61 BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
63 INVALID_TYPE = SubscriptionType('INVALID')
65 class InvalidType (ValueError):
66 def __init__(self, type_name, type_root):
67 msg = 'Invalid type %s for tree:\n%s' \
68 % (type_name, type_root.string_tree(4))
69 ValueError.__init__(self, msg)
70 self.type_name = type_name
71 self.type_root = type_root
73 def type_from_name(name, type_root, default=None, default_ok=False):
74 if name == str(type_root):
76 for t in type_root.traverse():
81 raise InvalidType(name, type_root)
83 class Subscription (object):
84 """A user subscription.
89 >>> subscriptions = [Subscription('XYZ', 'all'),
90 ... Subscription('DIR', 'new'),
91 ... Subscription('ABC', BUG_TYPE_ALL),]
92 >>> print sorted(subscriptions)
93 [<Subscription: DIR (new)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
95 def __init__(self, id, subscription_type, **kwargs):
96 if 'type_root' not in kwargs:
98 kwargs['type_root'] = BUGDIR_TYPE_ALL
100 kwargs['type_root'] = BUG_TYPE_ALL
101 if type(subscription_type) in types.StringTypes:
102 subscription_type = type_from_name(subscription_type, **kwargs)
104 self.type = subscription_type
105 def __cmp__(self, other):
106 for attr in 'id', 'type':
107 value = cmp(getattr(self, attr), getattr(other, attr))
109 if self.id == BUGDIR_ID:
111 elif other.id == BUGDIR_ID:
115 return str(self.type)
117 return '<Subscription: %s (%s)>' % (self.id, self.type)
119 def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
120 """Provide a simple way for non-Python interfaces to read in subscriptions.
125 >>> subscriptions_from_string(None)
126 [<Subscription: DIR (all)>]
127 >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all')
128 [<Subscription: DIR (new)>, <Subscription: DIR (rem)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
129 >>> subscriptions_from_string('DIR::new')
130 Traceback (most recent call last):
132 ValueError: Invalid subscription "DIR::new", should be ID:TYPE
135 return [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
137 for subscription in string.split(','):
138 fields = subscription.split(':')
140 raise ValueError('Invalid subscription "%s", should be ID:TYPE'
143 subscriptions.append(Subscription(id, type))
146 class DiffTree (libbe.util.tree.Tree):
147 """A tree holding difference data for easy report generation.
152 >>> bugdir = DiffTree('bugdir')
153 >>> bdsettings = DiffTree('settings', data='target: None -> 1.0')
154 >>> bugdir.append(bdsettings)
155 >>> bugs = DiffTree('bugs', 'bug-count: 5 -> 6')
156 >>> bugdir.append(bugs)
157 >>> new = DiffTree('new', 'new bugs: ABC, DEF')
159 >>> rem = DiffTree('rem', 'removed bugs: RST, UVW')
161 >>> print bugdir.report_string()
165 removed bugs: RST, UVW
166 >>> print '\\n'.join(bugdir.paths())
172 >>> bugdir.child_by_path('/') == bugdir
174 >>> bugdir.child_by_path('/bugs') == bugs
176 >>> bugdir.child_by_path('/bugs/rem') == rem
178 >>> bugdir.child_by_path('bugdir') == bugdir
180 >>> bugdir.child_by_path('bugdir/') == bugdir
182 >>> bugdir.child_by_path('bugdir/bugs') == bugs
184 >>> bugdir.child_by_path('/bugs').masked = True
185 >>> print bugdir.report_string()
188 def __init__(self, name, data=None, data_part_fn=str,
189 requires_children=False, masked=False):
190 libbe.util.tree.Tree.__init__(self)
193 self.data_part_fn = data_part_fn
194 self.requires_children = requires_children
196 def paths(self, parent_path=None):
198 if parent_path == None:
201 path = '%s/%s' % (parent_path, self.name)
204 paths.extend(child.paths(path))
206 def child_by_path(self, path):
207 if hasattr(path, 'split'): # convert string path to a list of names
208 names = path.split('/')
210 names[0] = self.name # replace root with self
211 if len(names) > 1 and names[-1] == '':
212 names = names[:-1] # strip empty tail
213 else: # it was already an array
215 assert len(names) > 0, path
216 if names[0] == self.name:
220 if names[1] == child.name:
221 return child.child_by_path(names[1:])
223 raise KeyError, "%s doesn't match '%s'" % (names, self.name)
224 raise KeyError, '%s points to child not in %s' % (names, [c.name for c in self])
225 def report_string(self):
226 report = self.report()
229 return '\n'.join(report)
230 def report(self, root=None, parent=None, depth=0):
232 root = self.make_root()
233 if self.masked == True:
235 data_part = self.data_part(depth)
236 if self.requires_children == True \
237 and len([c for c in self if c.masked == False]) == 0:
240 self.join(root, parent, data_part)
241 if data_part != None:
244 root = child.report(root, self, depth)
248 def join(self, root, parent, data_part):
249 if data_part != None:
250 root.append(data_part)
251 def data_part(self, depth, indent=True):
252 if self.data == None:
254 if hasattr(self, '_cached_data_part'):
255 return self._cached_data_part
256 data_part = self.data_part_fn(self.data)
258 data_part_lines = data_part.splitlines()
260 line_sep = '\n'+indent
261 data_part = indent+line_sep.join(data_part_lines)
262 self._cached_data_part = data_part
266 """Difference tree generator for BugDirs.
272 >>> bd = libbe.bugdir.SimpleBugDir(memory=True)
273 >>> bd_new = copy.deepcopy(bd)
274 >>> bd_new.target = '1.0'
275 >>> a = bd_new.bug_from_uuid('a')
276 >>> rep = a.comment_root.new_reply("I'm closing this bug")
277 >>> rep.uuid = 'acom'
278 >>> rep.author = 'John Doe <j@doe.com>'
279 >>> rep.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
280 >>> a.status = 'closed'
281 >>> b = bd_new.bug_from_uuid('b')
282 >>> bd_new.remove_bug(b)
283 >>> c = bd_new.new_bug('Bug C', _uuid='c')
284 >>> d = Diff(bd, bd_new)
285 >>> r = d.report_tree()
286 >>> print '\\n'.join(r.paths())
296 bugdir/bugs/mod/a/settings
297 bugdir/bugs/mod/a/comments
298 bugdir/bugs/mod/a/comments/new
299 bugdir/bugs/mod/a/comments/new/acom
300 bugdir/bugs/mod/a/comments/rem
301 bugdir/bugs/mod/a/comments/mod
302 >>> print r.report_string()
303 Changed bug directory settings:
311 Changed bug settings:
312 status: open -> closed
314 from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
315 I'm closing this bug...
317 You can also limit the report generation by providing a list of
320 >>> subscriptions = [Subscription('DIR', BUGDIR_TYPE_NEW),
321 ... Subscription('b', BUG_TYPE_ALL)]
322 >>> r = d.report_tree(subscriptions)
323 >>> print r.report_string()
329 While sending subscriptions to report_tree() makes the report
330 generation more efficient (because you may not need to compare
331 _all_ the bugs, etc.), sometimes you will have several sets of
332 subscriptions. In that case, it's better to run full_report()
333 first, and then use report_tree() to avoid redundant comparisons.
336 >>> print d.report_tree([subscriptions[0]]).report_string()
339 >>> print d.report_tree([subscriptions[1]]).report_string()
345 def __init__(self, old_bugdir, new_bugdir):
346 self.old_bugdir = old_bugdir
347 self.new_bugdir = new_bugdir
349 # data assembly methods
351 def _changed_bugs(self, subscriptions):
353 Search for differences in all bugs between .old_bugdir and
355 (added_bugs, modified_bugs, removed_bugs)
356 where added_bugs and removed_bugs are lists of added and
357 removed bugs respectively. modified_bugs is a list of
358 (old_bug,new_bug) pairs.
360 bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
363 for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD]:
364 if bd_type in bugdir_types:
365 new_uuids = list(self.new_bugdir.uuids())
367 for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_REM]:
368 if bd_type in bugdir_types:
369 old_uuids = list(self.old_bugdir.uuids())
372 for s in subscriptions:
373 if s.id != BUGDIR_ID:
375 bug = self.new_bugdir.bug_from_uuid(s.id)
376 except libbe.bugdir.NoBugMatches:
377 bug = self.old_bugdir.bug_from_uuid(s.id)
378 subscribed_bugs.append(bug.uuid)
379 new_uuids.extend([s for s in subscribed_bugs
380 if self.new_bugdir.has_bug(s)])
381 new_uuids = sorted(set(new_uuids))
382 old_uuids.extend([s for s in subscribed_bugs
383 if self.old_bugdir.has_bug(s)])
384 old_uuids = sorted(set(old_uuids))
389 if hasattr(self.old_bugdir, 'changed'):
390 # take advantage of a RevisionedBugDir-style changed() method
391 new_ids,mod_ids,rem_ids = self.old_bugdir.changed()
393 for a_id in self.new_bugdir.storage.ancestors(id):
394 if a_id.count('/') == 0:
395 if a_id in [b.id.storage() for b in added]:
398 bug = self.new_bugdir.bug_from_uuid(a_id)
400 except libbe.bugdir.NoBugMatches:
403 for a_id in self.old_bugdir.storage.ancestors(id):
404 if a_id.count('/') == 0:
405 if a_id in [b.id.storage() for b in removed]:
408 bug = self.old_bugdir.bug_from_uuid(a_id)
410 except libbe.bugdir.NoBugMatches:
413 for a_id in self.new_bugdir.storage.ancestors(id):
414 if a_id.count('/') == 0:
415 if a_id in [b[0].id.storage() for b in modified]:
418 new_bug = self.new_bugdir.bug_from_uuid(a_id)
419 old_bug = self.old_bugdir.bug_from_uuid(a_id)
420 modified.append((old_bug, new_bug))
421 except libbe.bugdir.NoBugMatches:
424 for uuid in new_uuids:
425 new_bug = self.new_bugdir.bug_from_uuid(uuid)
427 old_bug = self.old_bugdir.bug_from_uuid(uuid)
429 if BUGDIR_TYPE_ALL in bugdir_types \
430 or BUGDIR_TYPE_NEW in bugdir_types \
431 or uuid in subscribed_bugs:
432 added.append(new_bug)
434 if BUGDIR_TYPE_ALL in bugdir_types \
435 or BUGDIR_TYPE_MOD in bugdir_types \
436 or uuid in subscribed_bugs:
437 if old_bug.storage != None and old_bug.storage.is_readable():
438 old_bug.load_comments()
439 if new_bug.storage != None and new_bug.storage.is_readable():
440 new_bug.load_comments()
441 if old_bug != new_bug:
442 modified.append((old_bug, new_bug))
443 for uuid in old_uuids:
444 if not self.new_bugdir.has_bug(uuid):
445 old_bug = self.old_bugdir.bug_from_uuid(uuid)
446 removed.append(old_bug)
449 modified.sort(self._bug_modified_cmp)
450 return (added, modified, removed)
451 def _bug_modified_cmp(self, left, right):
452 return cmp(left[1], right[1])
453 def _changed_comments(self, old, new):
455 Search for differences in all loaded comments between the bugs
457 (added_comments, modified_comments, removed_comments)
458 analogous to ._changed_bugs.
460 if hasattr(self, '__changed_comments'):
461 if new.uuid in self.__changed_comments:
462 return self.__changed_comments[new.uuid]
464 self.__changed_comments = {}
468 old.comment_root.sort(key=lambda comm : comm.time)
469 new.comment_root.sort(key=lambda comm : comm.time)
470 old_comment_ids = [c.uuid for c in old.comments()]
471 new_comment_ids = [c.uuid for c in new.comments()]
472 for uuid in new_comment_ids:
473 new_comment = new.comment_from_uuid(uuid)
475 old_comment = old.comment_from_uuid(uuid)
477 added.append(new_comment)
479 if old_comment != new_comment:
480 modified.append((old_comment, new_comment))
481 for uuid in old_comment_ids:
482 if uuid not in new_comment_ids:
483 old_comment = old.comment_from_uuid(uuid)
484 removed.append(old_comment)
485 self.__changed_comments[new.uuid] = (added, modified, removed)
486 return self.__changed_comments[new.uuid]
487 def _attribute_changes(self, old, new, attributes):
489 Take two objects old and new, and compare the value of *.attr
490 for attr in the list attribute names. Returns a list of
491 (attr_name, old_value, new_value)
495 for attr in attributes:
496 old_value = getattr(old, attr)
497 new_value = getattr(new, attr)
498 if old_value != new_value:
499 change_list.append((attr, old_value, new_value))
500 if len(change_list) >= 0:
503 def _settings_properties_attribute_changes(self, old, new,
504 hidden_properties=[]):
505 properties = sorted(new.settings_properties)
506 for p in hidden_properties:
508 attributes = [setting_name_to_attr_name(None, p)
510 return self._attribute_changes(old, new, attributes)
511 def _bugdir_attribute_changes(self):
512 return self._settings_properties_attribute_changes( \
513 self.old_bugdir, self.new_bugdir)
514 def _bug_attribute_changes(self, old, new):
515 return self._settings_properties_attribute_changes(old, new)
516 def _comment_attribute_changes(self, old, new):
517 return self._settings_properties_attribute_changes(old, new)
519 # report generation methods
521 def full_report(self, diff_tree=DiffTree):
523 Generate a full report for efficiency if you'll be using
524 .report_tree() with several sets of subscriptions.
526 self._cached_full_report = self.report_tree(diff_tree=diff_tree,
528 self._cached_full_report_diff_tree = diff_tree
529 def _sub_report(self, subscriptions):
531 Return ._cached_full_report masked for subscriptions.
533 root = self._cached_full_report
534 bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
535 subscribed_bugs = [s.id for s in subscriptions
536 if BUG_TYPE_ALL.has_descendant( \
537 s.type, match_self=True)]
538 selected_by_bug = [node.name
539 for node in root.child_by_path('bugdir/bugs')]
540 if BUGDIR_TYPE_ALL in bugdir_types:
541 for node in root.traverse():
546 node = root.child_by_path('bugdir/settings')
550 for name,type in (('new', BUGDIR_TYPE_NEW),
551 ('mod', BUGDIR_TYPE_MOD),
552 ('rem', BUGDIR_TYPE_REM)):
553 if type in bugdir_types:
554 bugs = root.child_by_path('bugdir/bugs/%s' % name)
555 for bug_node in bugs:
556 for node in bug_node.traverse():
558 selected_by_bug.remove(name)
559 for name in selected_by_bug:
560 bugs = root.child_by_path('bugdir/bugs/%s' % name)
561 for bug_node in bugs:
562 if bug_node.name in subscribed_bugs:
563 for node in bug_node.traverse():
566 for node in bug_node.traverse():
569 def report_tree(self, subscriptions=None, diff_tree=DiffTree,
572 Pretty bare to make it easy to adjust to specific cases. You
573 can pass in a DiffTree subclass via diff_tree to override the
574 default report assembly process.
576 if allow_cached == True \
577 and hasattr(self, '_cached_full_report') \
578 and diff_tree == self._cached_full_report_diff_tree:
579 return self._sub_report(subscriptions)
580 if subscriptions == None:
581 subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
582 bugdir_settings = sorted(self.new_bugdir.settings_properties)
583 root = diff_tree('bugdir')
584 bugdir_subscriptions = [s.type for s in subscriptions
585 if s.id == BUGDIR_ID]
586 if BUGDIR_TYPE_ALL in bugdir_subscriptions:
587 bugdir_attribute_changes = self._bugdir_attribute_changes()
588 if len(bugdir_attribute_changes) > 0:
589 bugdir = diff_tree('settings', bugdir_attribute_changes,
590 self.bugdir_attribute_change_string)
592 bug_root = diff_tree('bugs')
593 root.append(bug_root)
594 add,mod,rem = self._changed_bugs(subscriptions)
595 bnew = diff_tree('new', 'New bugs:', requires_children=True)
596 bug_root.append(bnew)
598 b = diff_tree(bug.uuid, bug, self.bug_add_string)
600 brem = diff_tree('rem', 'Removed bugs:', requires_children=True)
601 bug_root.append(brem)
603 b = diff_tree(bug.uuid, bug, self.bug_rem_string)
605 bmod = diff_tree('mod', 'Modified bugs:', requires_children=True)
606 bug_root.append(bmod)
608 b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
610 bug_attribute_changes = self._bug_attribute_changes(old, new)
611 if len(bug_attribute_changes) > 0:
612 bset = diff_tree('settings', bug_attribute_changes,
613 self.bug_attribute_change_string)
615 if old.summary != new.summary:
616 data = (old.summary, new.summary)
617 bsum = diff_tree('summary', data, self.bug_summary_change_string)
619 cr = diff_tree('comments')
621 a,m,d = self._changed_comments(old, new)
622 cnew = diff_tree('new', 'New comments:', requires_children=True)
624 c = diff_tree(comment.uuid, comment, self.comment_add_string)
626 crem = diff_tree('rem', 'Removed comments:',requires_children=True)
628 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
630 cmod = diff_tree('mod','Modified comments:',requires_children=True)
632 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
634 comm_attribute_changes = self._comment_attribute_changes(o, n)
635 if len(comm_attribute_changes) > 0:
636 cset = diff_tree('settings', comm_attribute_changes,
637 self.comment_attribute_change_string)
639 data = (o.body, n.body)
640 cbody = diff_tree('cbody', data,
641 self.comment_body_change_string)
643 cr.extend([cnew, crem, cmod])
646 # change data -> string methods.
647 # Feel free to play with these in subclasses.
649 def attribute_change_string(self, attribute_changes, indent=0):
650 indent_string = ' '*indent
651 change_strings = [u'%s: %s -> %s' % f for f in attribute_changes]
652 for i,change_string in enumerate(change_strings):
653 change_strings[i] = indent_string+change_string
654 return u'\n'.join(change_strings)
655 def bugdir_attribute_change_string(self, attribute_changes):
656 return 'Changed bug directory settings:\n%s' % \
657 self.attribute_change_string(attribute_changes, indent=1)
658 def bug_attribute_change_string(self, attribute_changes):
659 return 'Changed bug settings:\n%s' % \
660 self.attribute_change_string(attribute_changes, indent=1)
661 def comment_attribute_change_string(self, attribute_changes):
662 return 'Changed comment settings:\n%s' % \
663 self.attribute_change_string(attribute_changes, indent=1)
664 def bug_add_string(self, bug):
665 return bug.string(shortlist=True)
666 def bug_rem_string(self, bug):
667 return bug.string(shortlist=True)
668 def bug_mod_string(self, bugs):
669 old_bug,new_bug = bugs
670 return new_bug.string(shortlist=True)
671 def bug_summary_change_string(self, summaries):
672 old_summary,new_summary = summaries
673 return 'summary changed:\n %s\n %s' % (old_summary, new_summary)
674 def _comment_summary_string(self, comment):
675 return 'from %s on %s' % (comment.author, time_to_str(comment.time))
676 def comment_add_string(self, comment):
677 summary = self._comment_summary_string(comment)
678 first_line = comment.body.splitlines()[0]
679 return '%s\n %s...' % (summary, first_line)
680 def comment_rem_string(self, comment):
681 summary = self._comment_summary_string(comment)
682 first_line = comment.body.splitlines()[0]
683 return '%s\n %s...' % (summary, first_line)
684 def comment_mod_string(self, comments):
685 old_comment,new_comment = comments
686 return self._comment_summary_string(new_comment)
687 def comment_body_change_string(self, bodies):
688 old_body,new_body = bodies
689 return ''.join(difflib.unified_diff(
690 old_body.splitlines(True),
691 new_body.splitlines(True),