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, allow_changes=True, allow_new_comments=True):
487 Merge info from other into this bug. Overrides any attributes
488 in self that are listed in other.explicit_attrs.
489 >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
490 >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
491 >>> bugA.creator = 'Frank'
492 >>> bugA.extra_strings += ['TAG: very helpful']
493 >>> bugA.extra_strings += ['TAG: favorite']
494 >>> commA = bugA.comment_root.new_reply(body='comment A')
495 >>> commA.uuid = 'uuid-commA'
496 >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()')
497 >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
498 >>> bugB.creator = 'John'
499 >>> bugB.explicit_attrs = ['creator', 'summary']
500 >>> bugB.extra_strings += ['TAG: very helpful']
501 >>> bugB.extra_strings += ['TAG: useful']
502 >>> commB = bugB.comment_root.new_reply(body='comment B')
503 >>> commB.uuid = 'uuid-commB'
504 >>> bugA.merge(bugB, allow_changes=False)
505 Traceback (most recent call last):
507 ValueError: Merge would change creator "Frank"->"John" for bug 0123
508 >>> bugA.merge(bugB, allow_new_comments=False)
509 Traceback (most recent call last):
511 ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
513 >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS
516 <short-name>0123</short-name>
517 <severity>minor</severity>
518 <status>open</status>
519 <creator>John</creator>
520 <created>...</created>
521 <summary>More tests for Bug.merge()</summary>
522 <extra-string>TAG: favorite</extra-string>
523 <extra-string>TAG: useful</extra-string>
524 <extra-string>TAG: very helpful</extra-string>
526 <uuid>uuid-commA</uuid>
527 <short-name>0123:1</short-name>
530 <content-type>text/plain</content-type>
531 <body>comment A</body>
534 <uuid>uuid-commB</uuid>
535 <short-name>0123:2</short-name>
538 <content-type>text/plain</content-type>
539 <body>comment B</body>
543 for attr in other.explicit_attrs:
544 old = getattr(self, attr)
545 new = getattr(other, attr)
547 if allow_changes == True:
548 setattr(self, attr, new)
551 'Merge would change %s "%s"->"%s" for bug %s' \
552 % (attr, old, new, self.uuid)
553 if allow_changes == False and len(other.extra_strings) > 0:
555 'Merge would change extra_strings for bug %s' % self.uuid
556 for estr in other.extra_strings:
557 if not estr in self.extra_strings:
558 self.extra_strings.append(estr)
560 for o_comm in other.comments():
562 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
565 s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
569 if allow_new_comments == False:
571 'Merge would add comment %s (alt: %s) to bug %s' \
572 % (o_comm.uuid, o_comm.alt_id, self.uuid)
573 o_comm_copy = copy.copy(o_comm)
574 o_comm_copy.bug = self
575 self.comment_root.add_reply(o_comm_copy)
577 s_comm.merge(o_comm, allow_changes=allow_changes)
579 def string(self, shortlist=False, show_comments=False):
580 if self.bugdir == None:
581 shortname = self.uuid
583 shortname = self.bugdir.bug_shortname(self)
584 if shortlist == False:
585 if self.time == None:
588 htime = utility.handy_time(self.time)
589 timestring = "%s (%s)" % (htime, self.time_string)
590 info = [("ID", self.uuid),
591 ("Short name", shortname),
592 ("Severity", self.severity),
593 ("Status", self.status),
594 ("Assigned", self._setting_attr_string("assigned")),
595 ("Target", self._setting_attr_string("target")),
596 ("Reporter", self._setting_attr_string("reporter")),
597 ("Creator", self._setting_attr_string("creator")),
598 ("Created", timestring)]
599 longest_key_len = max([len(k) for k,v in info])
600 infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
601 bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
603 statuschar = self.status[0]
604 severitychar = self.severity[0]
605 chars = "%c%c" % (statuschar, severitychar)
606 bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
608 if show_comments == True:
609 # take advantage of the string_thread(auto_name_map=True)
610 # SIDE-EFFECT of sorting by comment time.
611 comout = self.comment_root.string_thread(flatten=False,
613 bug_shortname=shortname)
614 output = bugout + '\n' + comout.rstrip('\n')
619 # methods for saving/loading/acessing settings and properties.
621 def get_path(self, *args):
622 dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
625 assert args[0] in ["values", "comments"], str(args)
626 return os.path.join(dir, *args)
628 def set_sync_with_disk(self, value):
629 self.sync_with_disk = value
630 for comment in self.comments():
631 comment.set_sync_with_disk(value)
633 def load_settings(self):
634 if self.sync_with_disk == False:
635 raise DiskAccessRequired("load settings")
636 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
637 self._setup_saved_settings()
639 def save_settings(self):
640 if self.sync_with_disk == False:
641 raise DiskAccessRequired("save settings")
642 assert self.summary != None, "Can't save blank bug"
643 self.vcs.mkdir(self.get_path())
644 path = self.get_path("values")
645 mapfile.map_save(self.vcs, path, self._get_saved_settings())
649 Save any loaded contents to disk. Because of lazy loading of
650 comments, this is actually not too inefficient.
652 However, if self.sync_with_disk = True, then any changes are
653 automatically written to disk as soon as they happen, so
654 calling this method will just waste time (unless something
655 else has been messing with your on-disk files).
657 sync_with_disk = self.sync_with_disk
658 if sync_with_disk == False:
659 self.set_sync_with_disk(True)
661 if len(self.comment_root) > 0:
662 comment.saveComments(self)
663 if sync_with_disk == False:
664 self.set_sync_with_disk(False)
666 def load_comments(self, load_full=True):
667 if self.sync_with_disk == False:
668 raise DiskAccessRequired("load comments")
669 if load_full == True:
670 # Force a complete load of the whole comment tree
671 self.comment_root = self._get_comment_root(load_full=True)
673 # Setup for fresh lazy-loading. Clear _comment_root, so
674 # _get_comment_root returns a fresh version. Turn of
675 # syncing temporarily so we don't write our blank comment
677 self.sync_with_disk = False
678 self.comment_root = None
679 self.sync_with_disk = True
682 if self.sync_with_disk == False:
683 raise DiskAccessRequired("remove")
684 self.comment_root.remove()
685 path = self.get_path()
686 self.vcs.recursive_remove(path)
688 # methods for managing comments
691 for comment in self.comment_root.traverse():
694 def new_comment(self, body=None):
695 comm = self.comment_root.new_reply(body=body)
698 def comment_from_shortname(self, shortname, *args, **kwargs):
699 return self.comment_root.comment_from_shortname(shortname,
702 def comment_from_uuid(self, uuid, *args, **kwargs):
703 return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
705 def comment_shortnames(self, shortname=None):
707 SIDE-EFFECT : Comment.comment_shortnames will sort the comment
710 for id, comment in self.comment_root.comment_shortnames(shortname):
714 # The general rule for bug sorting is that "more important" bugs are
715 # less than "less important" bugs. This way sorting a list of bugs
716 # will put the most important bugs first in the list. When relative
717 # importance is unclear, the sorting follows some arbitrary convention
718 # (i.e. dictionary order).
720 def cmp_severity(bug_1, bug_2):
722 Compare the severity levels of two bugs, with more severe bugs
726 >>> bugA.severity = bugB.severity = "wishlist"
727 >>> cmp_severity(bugA, bugB) == 0
729 >>> bugB.severity = "minor"
730 >>> cmp_severity(bugA, bugB) > 0
732 >>> bugA.severity = "critical"
733 >>> cmp_severity(bugA, bugB) < 0
736 if not hasattr(bug_2, "severity") :
738 return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
740 def cmp_status(bug_1, bug_2):
742 Compare the status levels of two bugs, with more 'open' bugs
746 >>> bugA.status = bugB.status = "open"
747 >>> cmp_status(bugA, bugB) == 0
749 >>> bugB.status = "closed"
750 >>> cmp_status(bugA, bugB) < 0
752 >>> bugA.status = "fixed"
753 >>> cmp_status(bugA, bugB) > 0
756 if not hasattr(bug_2, "status") :
758 val_2 = status_index[bug_2.status]
759 return cmp(status_index[bug_1.status], status_index[bug_2.status])
761 def cmp_attr(bug_1, bug_2, attr, invert=False):
763 Compare a general attribute between two bugs using the conventional
764 comparison rule for that attribute type. If invert == True, sort
765 *against* that convention.
769 >>> bugA.severity = "critical"
770 >>> bugB.severity = "wishlist"
771 >>> cmp_attr(bugA, bugB, attr) < 0
773 >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
775 >>> bugB.severity = "critical"
776 >>> cmp_attr(bugA, bugB, attr) == 0
779 if not hasattr(bug_2, attr) :
781 val_1 = getattr(bug_1, attr)
782 val_2 = getattr(bug_2, attr)
783 if val_1 == None: val_1 = None
784 if val_2 == None: val_2 = None
787 return -cmp(val_1, val_2)
789 return cmp(val_1, val_2)
791 # alphabetical rankings (a < z)
792 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
793 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
794 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
795 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
796 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
797 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
798 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
799 # chronological rankings (newer < older)
800 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
802 def cmp_comments(bug_1, bug_2):
804 Compare two bugs' comments lists. Doesn't load any new comments,
805 so you should call each bug's .load_comments() first if you want a
808 comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
809 comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
810 result = cmp(len(comms_1), len(comms_2))
813 for c_1,c_2 in zip(comms_1, comms_2):
814 result = cmp(c_1, c_2)
819 DEFAULT_CMP_FULL_CMP_LIST = \
820 (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
821 cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid,
824 class BugCompoundComparator (object):
825 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
826 self.cmp_list = cmp_list
827 def __call__(self, bug_1, bug_2):
828 for comparison in self.cmp_list :
829 val = comparison(bug_1, bug_2)
834 cmp_full = BugCompoundComparator()
837 # define some bonus cmp_* functions
838 def cmp_last_modified(bug_1, bug_2):
840 Like cmp_time(), but use most recent comment instead of bug
841 creation for the timestamp.
843 def last_modified(bug):
845 for comment in bug.comment_root.traverse():
846 if comment.time > time:
849 val_1 = last_modified(bug_1)
850 val_2 = last_modified(bug_2)
851 return -cmp(val_1, val_2)
854 suite = doctest.DocTestSuite()