1 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
2 # Thomas Habets <thomas@habets.pp.se>
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.
20 Define the Bug class for representing bugs.
29 try: # import core module, Python >= 2.5
30 from xml.etree import ElementTree
31 except ImportError: # look for non-core module
32 from elementtree import ElementTree
33 import xml.sax.saxutils
36 from beuuid import uuid_gen
37 from properties import Property, doc_property, local_property, \
38 defaulting_property, checked_property, cached_property, \
39 primed_property, change_hook_property, settings_property
40 import settings_object
46 class DiskAccessRequired (Exception):
47 def __init__(self, goal):
48 msg = "Cannot %s without accessing the disk" % goal
49 Exception.__init__(self, msg)
51 ### Define and describe valid bug categories
52 # Use a tuple of (category, description) tuples since we don't have
53 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
55 # in order of increasing severity. (name, description) pairs
57 ("wishlist","A feature that could improve usefulness, but not a bug."),
58 ("minor","The standard bug level."),
59 ("serious","A bug that requires workarounds."),
60 ("critical","A bug that prevents some features from working at all."),
61 ("fatal","A bug that makes the package unusable."))
63 # in order of increasing resolution
64 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
66 ("unconfirmed","A possible bug which lacks independent existance confirmation."),
67 ("open","A working bug that has not been assigned to a developer."),
68 ("assigned","A working bug that has been assigned to a developer."),
69 ("test","The code has been adjusted, but the fix is still being tested."))
70 inactive_status_def = (
71 ("closed", "The bug is no longer relevant."),
72 ("fixed", "The bug should no longer occur."),
73 ("wontfix","It's not a bug, it's a feature."))
76 ### Convert the description tuples to more useful formats
79 severity_description = {}
81 def load_severities(severity_def):
82 global severity_values
83 global severity_description
85 if severity_def == None:
87 severity_values = tuple([val for val,description in severity_def])
88 severity_description = dict(severity_def)
90 for i,severity in enumerate(severity_values):
91 severity_index[severity] = i
92 load_severities(severity_def)
94 active_status_values = []
95 inactive_status_values = []
97 status_description = {}
99 def load_status(active_status_def, inactive_status_def):
100 global active_status_values
101 global inactive_status_values
103 global status_description
105 if active_status_def == None:
106 active_status_def = globals()["active_status_def"]
107 if inactive_status_def == None:
108 inactive_status_def = globals()["inactive_status_def"]
109 active_status_values = tuple([val for val,description in active_status_def])
110 inactive_status_values = tuple([val for val,description in inactive_status_def])
111 status_values = active_status_values + inactive_status_values
112 status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
114 for i,status in enumerate(status_values):
115 status_index[status] = i
116 load_status(active_status_def, inactive_status_def)
119 class Bug(settings_object.SavedSettingsObject):
127 There are two formats for time, int and string. Setting either
128 one will adjust the other appropriately. The string form is the
129 one stored in the bug's settings file on disk.
130 >>> print type(b.time)
132 >>> print type(b.time_string)
135 >>> print b.time_string
136 Thu, 01 Jan 1970 00:00:00 +0000
137 >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
140 >>> print b.settings["time"]
141 Thu, 01 Jan 1970 00:01:00 +0000
143 settings_properties = []
144 required_saved_properties = []
145 _prop_save_settings = settings_object.prop_save_settings
146 _prop_load_settings = settings_object.prop_load_settings
147 def _versioned_property(settings_properties=settings_properties,
148 required_saved_properties=required_saved_properties,
150 if "settings_properties" not in kwargs:
151 kwargs["settings_properties"] = settings_properties
152 if "required_saved_properties" not in kwargs:
153 kwargs["required_saved_properties"]=required_saved_properties
154 return settings_object.versioned_property(**kwargs)
156 @_versioned_property(name="severity",
157 doc="A measure of the bug's importance",
159 check_fn=lambda s: s in severity_values,
161 def severity(): return {}
163 @_versioned_property(name="status",
164 doc="The bug's current status",
166 check_fn=lambda s: s in status_values,
168 def status(): return {}
172 return self.status in active_status_values
174 @_versioned_property(name="target",
175 doc="The deadline for fixing this bug")
176 def target(): return {}
178 @_versioned_property(name="creator",
179 doc="The user who entered the bug into the system")
180 def creator(): return {}
182 @_versioned_property(name="reporter",
183 doc="The user who reported the bug")
184 def reporter(): return {}
186 @_versioned_property(name="assigned",
187 doc="The developer in charge of the bug")
188 def assigned(): return {}
190 @_versioned_property(name="time",
191 doc="An RFC 2822 timestamp for bug creation")
192 def time_string(): return {}
195 if self.time_string == None:
197 return utility.str_to_time(self.time_string)
198 def _set_time(self, value):
199 self.time_string = utility.time_to_str(value)
200 time = property(fget=_get_time,
202 doc="An integer version of .time_string")
204 def _extra_strings_check_fn(value):
205 return utility.iterable_full_of_strings(value, \
206 alternative=settings_object.EMPTY)
207 def _extra_strings_change_hook(self, old, new):
208 self.extra_strings.sort() # to make merging easier
209 self._prop_save_settings(old, new)
210 @_versioned_property(name="extra_strings",
211 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
213 check_fn=_extra_strings_check_fn,
214 change_hook=_extra_strings_change_hook,
216 def extra_strings(): return {}
218 @_versioned_property(name="summary",
219 doc="A one-line bug description")
220 def summary(): return {}
222 def _get_comment_root(self, load_full=False):
223 if self.sync_with_disk:
224 return comment.loadComments(self, load_full=load_full)
226 return comment.Comment(self, uuid=comment.INVALID_UUID)
229 @cached_property(generator=_get_comment_root)
230 @local_property("comment_root")
231 @doc_property(doc="The trunk of the comment tree. We use a dummy root comment by default, because there can be several comment threads rooted on the same parent bug. To simplify comment interaction, we condense these threads into a single thread with a Comment dummy root.")
232 def comment_root(): return {}
235 if hasattr(self.bugdir, "vcs"):
236 return self.bugdir.vcs
239 @cached_property(generator=_get_vcs)
240 @local_property("vcs")
241 @doc_property(doc="A revision control system instance.")
244 def __init__(self, bugdir=None, uuid=None, from_disk=False,
245 load_comments=False, summary=None):
246 settings_object.SavedSettingsObject.__init__(self)
249 if from_disk == True:
250 self.sync_with_disk = True
252 self.sync_with_disk = False
254 self.uuid = uuid_gen()
255 self.time = int(time.time()) # only save to second precision
257 self.creator = self.vcs.get_user_id()
258 self.summary = summary
261 return "Bug(uuid=%r)" % self.uuid
264 return self.string(shortlist=True)
266 def __cmp__(self, other):
267 return cmp_full(self, other)
269 # serializing methods
271 def _setting_attr_string(self, setting):
272 value = getattr(self, setting)
275 if type(value) not in types.StringTypes:
279 def xml(self, indent=0, shortname=None, show_comments=False):
280 if shortname == None:
281 if self.bugdir == None:
282 shortname = self.uuid
284 shortname = self.bugdir.bug_shortname(self)
286 if self.time == None:
289 timestring = utility.time_to_str(self.time)
291 info = [('uuid', self.uuid),
292 ('short-name', shortname),
293 ('severity', self.severity),
294 ('status', self.status),
295 ('assigned', self.assigned),
296 ('target', self.target),
297 ('reporter', self.reporter),
298 ('creator', self.creator),
299 ('created', timestring),
300 ('summary', self.summary)]
304 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
305 for estr in self.extra_strings:
306 lines.append(' <extra-string>%s</extra-string>' % estr)
307 if show_comments == True:
308 comout = self.comment_root.xml_thread(indent=indent+2,
310 bug_shortname=shortname)
313 lines.append('</bug>')
316 return istring + sep.join(lines).rstrip('\n')
318 def from_xml(self, xml_string, verbose=True):
320 Note: If a bug uuid is given, set .alt_id to it's value.
321 >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()")
322 >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
323 >>> bugA.creator = u'Fran\xe7ois'
324 >>> bugA.extra_strings += ['TAG: very helpful']
325 >>> commA = bugA.comment_root.new_reply(body='comment A')
326 >>> commB = bugA.comment_root.new_reply(body='comment B')
327 >>> commC = commA.new_reply(body='comment C')
328 >>> xml = bugA.xml(shortname="bug-1", show_comments=True)
330 >>> bugB.from_xml(xml, verbose=True)
331 >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
333 >>> bugB.uuid = bugB.alt_id
334 >>> for comm in bugB.comments():
335 ... comm.uuid = comm.alt_id
336 ... comm.alt_id = None
337 >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
339 >>> bugB.explicit_attrs # doctest: +NORMALIZE_WHITESPACE
340 ['severity', 'status', 'creator', 'created', 'summary']
341 >>> len(list(bugB.comments()))
344 if type(xml_string) == types.UnicodeType:
345 xml_string = xml_string.strip().encode('unicode_escape')
346 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
349 bug = ElementTree.XML(xml_string)
351 raise utility.InvalidXML( \
352 'bug', bug, 'root element must be <comment>')
353 tags=['uuid','short-name','severity','status','assigned','target',
354 'reporter', 'creator','created','summary','extra-string']
355 self.explicit_attrs = []
359 for child in bug.getchildren():
360 if child.tag == 'short-name':
362 elif child.tag == 'comment':
363 comm = comment.Comment(bug=self)
365 comments.append(comm)
367 elif child.tag in tags:
368 if child.text == None or len(child.text) == 0:
369 text = settings_object.EMPTY
371 text = xml.sax.saxutils.unescape(child.text)
372 text = text.decode('unicode_escape').strip()
373 if child.tag == 'uuid':
375 continue # don't set the bug's uuid tag.
376 elif child.tag == 'extra-string':
378 continue # don't set the bug's extra_string yet.
379 attr_name = child.tag.replace('-','_')
380 self.explicit_attrs.append(attr_name)
381 setattr(self, attr_name, text)
382 elif verbose == True:
383 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
384 % (child.tag, comment.tag)
385 if uuid != self.uuid:
386 if not hasattr(self, 'alt_id') or self.alt_id == None:
388 self.extra_strings = estrs
389 self.add_comments(comments)
391 def add_comment(self, comment, *args, **kwargs):
393 Add a comment too the current bug, under the parent specified
394 by comment.in_reply_to.
395 Note: If a bug uuid is given, set .alt_id to it's value.
396 >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()')
397 >>> bugA.creator = 'Jack'
398 >>> commA = bugA.comment_root.new_reply(body='comment A')
399 >>> commA.uuid = 'commA'
400 >>> commB = comment.Comment(body='comment B')
401 >>> commB.uuid = 'commB'
402 >>> bugA.add_comment(commB)
403 >>> commC = comment.Comment(body='comment C')
404 >>> commC.uuid = 'commC'
405 >>> commC.in_reply_to = commA.uuid
406 >>> bugA.add_comment(commC)
407 >>> print bugA.xml(shortname="bug-1", show_comments=True) # doctest: +ELLIPSIS
410 <short-name>bug-1</short-name>
411 <severity>minor</severity>
412 <status>open</status>
413 <creator>Jack</creator>
414 <created>...</created>
415 <summary>Need to test Bug.add_comment()</summary>
418 <short-name>bug-1:1</short-name>
421 <content-type>text/plain</content-type>
422 <body>comment A</body>
426 <short-name>bug-1:2</short-name>
427 <in-reply-to>commA</in-reply-to>
430 <content-type>text/plain</content-type>
431 <body>comment C</body>
435 <short-name>bug-1:3</short-name>
438 <content-type>text/plain</content-type>
439 <body>comment B</body>
443 self.add_comments([comment], **kwargs)
445 def add_comments(self, comments, default_parent=None,
446 ignore_missing_references=False):
448 Convert a raw list of comments to single root comment. If a
449 comment does not specify a parent with .in_reply_to, the
450 parent defaults to .comment_root, but you can specify another
451 default parent via default_parent.
454 if default_parent == None:
455 default_parent = self.comment_root
456 for c in list(self.comments()) + comments:
457 assert c.uuid != None
458 assert c.uuid not in uuid_map
461 uuid_map[c.alt_id] = c
462 uuid_map[None] = self.comment_root
463 if default_parent != self.comment_root:
464 assert default_parent.uuid in uuid_map, default_parent
466 if c.in_reply_to == None \
467 and default_parent.uuid != comment.INVALID_UUID:
468 c.in_reply_to = default_parent.uuid
469 elif c.in_reply_to == comment.INVALID_UUID:
472 parent = uuid_map[c.in_reply_to]
474 if ignore_missing_references == True:
475 print >> sys.stderr, \
476 "Ignoring missing reference to %s" % c.in_reply_to
477 parent = default_parent
478 if parent.uuid != comment.INVALID_UUID:
479 c.in_reply_to = parent.uuid
481 raise comment.MissingReference(c)
485 def merge(self, other, accept_changes=True,
486 accept_extra_strings=True, accept_comments=True,
487 change_exception=False):
489 Merge info from other into this bug. Overrides any attributes
490 in self that are listed in other.explicit_attrs.
491 >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
492 >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
493 >>> bugA.creator = 'Frank'
494 >>> bugA.extra_strings += ['TAG: very helpful']
495 >>> bugA.extra_strings += ['TAG: favorite']
496 >>> commA = bugA.comment_root.new_reply(body='comment A')
497 >>> commA.uuid = 'uuid-commA'
498 >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()')
499 >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
500 >>> bugB.creator = 'John'
501 >>> bugB.explicit_attrs = ['creator', 'summary']
502 >>> bugB.extra_strings += ['TAG: very helpful']
503 >>> bugB.extra_strings += ['TAG: useful']
504 >>> commB = bugB.comment_root.new_reply(body='comment B')
505 >>> commB.uuid = 'uuid-commB'
506 >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
507 ... accept_comments=False, change_exception=False)
508 >>> print bugA.creator
510 >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
511 ... accept_comments=False, change_exception=True)
512 Traceback (most recent call last):
514 ValueError: Merge would change creator "Frank"->"John" for bug 0123
515 >>> print bugA.creator
517 >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=False,
518 ... accept_comments=False, change_exception=True)
519 Traceback (most recent call last):
521 ValueError: Merge would add extra string "TAG: useful" for bug 0123
522 >>> print bugA.creator
524 >>> print bugA.extra_strings
525 ['TAG: favorite', 'TAG: very helpful']
526 >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
527 ... accept_comments=False, change_exception=True)
528 Traceback (most recent call last):
530 ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
531 >>> print bugA.extra_strings
532 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
533 >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
534 ... accept_comments=True, change_exception=True)
535 >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS
538 <short-name>0123</short-name>
539 <severity>minor</severity>
540 <status>open</status>
541 <creator>John</creator>
542 <created>...</created>
543 <summary>More tests for Bug.merge()</summary>
544 <extra-string>TAG: favorite</extra-string>
545 <extra-string>TAG: useful</extra-string>
546 <extra-string>TAG: very helpful</extra-string>
548 <uuid>uuid-commA</uuid>
549 <short-name>0123:1</short-name>
552 <content-type>text/plain</content-type>
553 <body>comment A</body>
556 <uuid>uuid-commB</uuid>
557 <short-name>0123:2</short-name>
560 <content-type>text/plain</content-type>
561 <body>comment B</body>
565 for attr in other.explicit_attrs:
566 old = getattr(self, attr)
567 new = getattr(other, attr)
569 if accept_changes == True:
570 setattr(self, attr, new)
571 elif change_exception == True:
573 'Merge would change %s "%s"->"%s" for bug %s' \
574 % (attr, old, new, self.uuid)
575 for estr in other.extra_strings:
576 if not estr in self.extra_strings:
577 if accept_extra_strings == True:
578 self.extra_strings.append(estr)
579 elif change_exception == True:
581 'Merge would add extra string "%s" for bug %s' \
583 for o_comm in other.comments():
585 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
588 s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
592 if accept_comments == True:
593 o_comm_copy = copy.copy(o_comm)
594 o_comm_copy.bug = self
595 self.comment_root.add_reply(o_comm_copy)
596 elif change_exception == True:
598 'Merge would add comment %s (alt: %s) to bug %s' \
599 % (o_comm.uuid, o_comm.alt_id, self.uuid)
601 s_comm.merge(o_comm, accept_changes=accept_changes,
602 accept_extra_strings=accept_extra_strings,
603 change_exception=change_exception)
605 def string(self, shortlist=False, show_comments=False):
606 if self.bugdir == None:
607 shortname = self.uuid
609 shortname = self.bugdir.bug_shortname(self)
610 if shortlist == False:
611 if self.time == None:
614 htime = utility.handy_time(self.time)
615 timestring = "%s (%s)" % (htime, self.time_string)
616 info = [("ID", self.uuid),
617 ("Short name", shortname),
618 ("Severity", self.severity),
619 ("Status", self.status),
620 ("Assigned", self._setting_attr_string("assigned")),
621 ("Target", self._setting_attr_string("target")),
622 ("Reporter", self._setting_attr_string("reporter")),
623 ("Creator", self._setting_attr_string("creator")),
624 ("Created", timestring)]
625 longest_key_len = max([len(k) for k,v in info])
626 infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
627 bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
629 statuschar = self.status[0]
630 severitychar = self.severity[0]
631 chars = "%c%c" % (statuschar, severitychar)
632 bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
634 if show_comments == True:
635 # take advantage of the string_thread(auto_name_map=True)
636 # SIDE-EFFECT of sorting by comment time.
637 comout = self.comment_root.string_thread(flatten=False,
639 bug_shortname=shortname)
640 output = bugout + '\n' + comout.rstrip('\n')
645 # methods for saving/loading/acessing settings and properties.
647 def get_path(self, *args):
648 dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
651 assert args[0] in ["values", "comments"], str(args)
652 return os.path.join(dir, *args)
654 def set_sync_with_disk(self, value):
655 self.sync_with_disk = value
656 for comment in self.comments():
657 comment.set_sync_with_disk(value)
659 def load_settings(self):
660 if self.sync_with_disk == False:
661 raise DiskAccessRequired("load settings")
662 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
663 self._setup_saved_settings()
665 def save_settings(self):
666 if self.sync_with_disk == False:
667 raise DiskAccessRequired("save settings")
668 assert self.summary != None, "Can't save blank bug"
669 self.vcs.mkdir(self.get_path())
670 path = self.get_path("values")
671 mapfile.map_save(self.vcs, path, self._get_saved_settings())
675 Save any loaded contents to disk. Because of lazy loading of
676 comments, this is actually not too inefficient.
678 However, if self.sync_with_disk = True, then any changes are
679 automatically written to disk as soon as they happen, so
680 calling this method will just waste time (unless something
681 else has been messing with your on-disk files).
683 sync_with_disk = self.sync_with_disk
684 if sync_with_disk == False:
685 self.set_sync_with_disk(True)
687 if len(self.comment_root) > 0:
688 comment.saveComments(self)
689 if sync_with_disk == False:
690 self.set_sync_with_disk(False)
692 def load_comments(self, load_full=True):
693 if self.sync_with_disk == False:
694 raise DiskAccessRequired("load comments")
695 if load_full == True:
696 # Force a complete load of the whole comment tree
697 self.comment_root = self._get_comment_root(load_full=True)
699 # Setup for fresh lazy-loading. Clear _comment_root, so
700 # _get_comment_root returns a fresh version. Turn of
701 # syncing temporarily so we don't write our blank comment
703 self.sync_with_disk = False
704 self.comment_root = None
705 self.sync_with_disk = True
708 if self.sync_with_disk == False:
709 raise DiskAccessRequired("remove")
710 self.comment_root.remove()
711 path = self.get_path()
712 self.vcs.recursive_remove(path)
714 # methods for managing comments
717 for comment in self.comment_root.traverse():
720 def new_comment(self, body=None):
721 comm = self.comment_root.new_reply(body=body)
724 def comment_from_shortname(self, shortname, *args, **kwargs):
725 return self.comment_root.comment_from_shortname(shortname,
728 def comment_from_uuid(self, uuid, *args, **kwargs):
729 return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
731 def comment_shortnames(self, shortname=None):
733 SIDE-EFFECT : Comment.comment_shortnames will sort the comment
736 for id, comment in self.comment_root.comment_shortnames(shortname):
740 # The general rule for bug sorting is that "more important" bugs are
741 # less than "less important" bugs. This way sorting a list of bugs
742 # will put the most important bugs first in the list. When relative
743 # importance is unclear, the sorting follows some arbitrary convention
744 # (i.e. dictionary order).
746 def cmp_severity(bug_1, bug_2):
748 Compare the severity levels of two bugs, with more severe bugs
752 >>> bugA.severity = bugB.severity = "wishlist"
753 >>> cmp_severity(bugA, bugB) == 0
755 >>> bugB.severity = "minor"
756 >>> cmp_severity(bugA, bugB) > 0
758 >>> bugA.severity = "critical"
759 >>> cmp_severity(bugA, bugB) < 0
762 if not hasattr(bug_2, "severity") :
764 return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
766 def cmp_status(bug_1, bug_2):
768 Compare the status levels of two bugs, with more 'open' bugs
772 >>> bugA.status = bugB.status = "open"
773 >>> cmp_status(bugA, bugB) == 0
775 >>> bugB.status = "closed"
776 >>> cmp_status(bugA, bugB) < 0
778 >>> bugA.status = "fixed"
779 >>> cmp_status(bugA, bugB) > 0
782 if not hasattr(bug_2, "status") :
784 val_2 = status_index[bug_2.status]
785 return cmp(status_index[bug_1.status], status_index[bug_2.status])
787 def cmp_attr(bug_1, bug_2, attr, invert=False):
789 Compare a general attribute between two bugs using the conventional
790 comparison rule for that attribute type. If invert == True, sort
791 *against* that convention.
795 >>> bugA.severity = "critical"
796 >>> bugB.severity = "wishlist"
797 >>> cmp_attr(bugA, bugB, attr) < 0
799 >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
801 >>> bugB.severity = "critical"
802 >>> cmp_attr(bugA, bugB, attr) == 0
805 if not hasattr(bug_2, attr) :
807 val_1 = getattr(bug_1, attr)
808 val_2 = getattr(bug_2, attr)
809 if val_1 == None: val_1 = None
810 if val_2 == None: val_2 = None
813 return -cmp(val_1, val_2)
815 return cmp(val_1, val_2)
817 # alphabetical rankings (a < z)
818 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
819 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
820 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
821 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
822 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
823 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
824 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
825 # chronological rankings (newer < older)
826 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
828 def cmp_comments(bug_1, bug_2):
830 Compare two bugs' comments lists. Doesn't load any new comments,
831 so you should call each bug's .load_comments() first if you want a
834 comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
835 comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
836 result = cmp(len(comms_1), len(comms_2))
839 for c_1,c_2 in zip(comms_1, comms_2):
840 result = cmp(c_1, c_2)
845 DEFAULT_CMP_FULL_CMP_LIST = \
846 (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
847 cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid,
850 class BugCompoundComparator (object):
851 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
852 self.cmp_list = cmp_list
853 def __call__(self, bug_1, bug_2):
854 for comparison in self.cmp_list :
855 val = comparison(bug_1, bug_2)
860 cmp_full = BugCompoundComparator()
863 # define some bonus cmp_* functions
864 def cmp_last_modified(bug_1, bug_2):
866 Like cmp_time(), but use most recent comment instead of bug
867 creation for the timestamp.
869 def last_modified(bug):
871 for comment in bug.comment_root.traverse():
872 if comment.time > time:
875 val_1 = last_modified(bug_1)
876 val_2 = last_modified(bug_2)
877 return -cmp(val_1, val_2)
880 suite = doctest.DocTestSuite()