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")
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 = []
358 for child in bug.getchildren():
359 if child.tag == 'short-name':
361 elif child.tag == 'comment':
362 comm = comment.Comment(bug=self)
364 self.add_comment(comm)
366 elif child.tag in tags:
367 if child.text == None or len(child.text) == 0:
368 text = settings_object.EMPTY
370 text = xml.sax.saxutils.unescape(child.text)
371 text = text.decode('unicode_escape').strip()
372 if child.tag == 'uuid':
374 continue # don't set the bug's uuid tag.
375 elif child.tag == 'extra-string':
377 continue # don't set the bug's extra_string yet.
378 attr_name = child.tag.replace('-','_')
379 self.explicit_attrs.append(attr_name)
380 setattr(self, attr_name, text)
381 elif verbose == True:
382 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
383 % (child.tag, comment.tag)
384 if uuid != self.uuid:
385 if not hasattr(self, 'alt_id') or self.alt_id == None:
387 self.extra_strings = estrs
389 def add_comment(self, new_comment):
391 Add a comment too the current bug, under the parent specified
392 by comment.in_reply_to.
393 Note: If a bug uuid is given, set .alt_id to it's value.
394 >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()')
395 >>> bugA.creator = 'Jack'
396 >>> commA = bugA.comment_root.new_reply(body='comment A')
397 >>> commA.uuid = 'commA'
398 >>> commB = comment.Comment(body='comment B')
399 >>> commB.uuid = 'commB'
400 >>> bugA.add_comment(commB)
401 >>> commC = comment.Comment(body='comment C')
402 >>> commC.uuid = 'commC'
403 >>> commC.in_reply_to = commA.uuid
404 >>> bugA.add_comment(commC)
405 >>> print bugA.xml(shortname="bug-1", show_comments=True) # doctest: +ELLIPSIS
408 <short-name>bug-1</short-name>
409 <severity>minor</severity>
410 <status>open</status>
411 <creator>Jack</creator>
412 <created>...</created>
413 <summary>Need to test Bug.add_comment()</summary>
416 <short-name>bug-1:1</short-name>
419 <content-type>text/plain</content-type>
420 <body>comment A</body>
424 <short-name>bug-1:2</short-name>
425 <in-reply-to>commA</in-reply-to>
428 <content-type>text/plain</content-type>
429 <body>comment C</body>
433 <short-name>bug-1:3</short-name>
436 <content-type>text/plain</content-type>
437 <body>comment B</body>
442 for c in self.comments():
445 uuid_map[c.alt_id] = c
446 assert new_comment.uuid not in uuid_map
447 if new_comment.alt_id != None:
448 assert new_comment.alt_id not in uuid_map
449 if new_comment.in_reply_to == comment.INVALID_UUID:
450 new_comment.in_reply_to = None
451 if new_comment.in_reply_to == None:
452 parent = self.comment_root
454 parent = uuid_map[new_comment.in_reply_to]
455 new_comment.bug = self
456 parent.append(new_comment)
458 def merge(self, other, allow_changes=True, allow_new_comments=True):
460 Merge info from other into this bug. Overrides any attributes
461 in self that are listed in other.explicit_attrs.
462 >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
463 >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
464 >>> bugA.creator = 'Frank'
465 >>> bugA.extra_strings += ['TAG: very helpful']
466 >>> bugA.extra_strings += ['TAG: favorite']
467 >>> commA = bugA.comment_root.new_reply(body='comment A')
468 >>> commA.uuid = 'uuid-commA'
469 >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()')
470 >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
471 >>> bugB.creator = 'John'
472 >>> bugB.explicit_attrs = ['creator', 'summary']
473 >>> bugB.extra_strings += ['TAG: very helpful']
474 >>> bugB.extra_strings += ['TAG: useful']
475 >>> commB = bugB.comment_root.new_reply(body='comment B')
476 >>> commB.uuid = 'uuid-commB'
477 >>> bugA.merge(bugB, allow_changes=False)
478 Traceback (most recent call last):
480 ValueError: Merge would change creator "Frank"->"John" for bug 0123
481 >>> bugA.merge(bugB, allow_new_comments=False)
482 Traceback (most recent call last):
484 ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
486 >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS
489 <short-name>0123</short-name>
490 <severity>minor</severity>
491 <status>open</status>
492 <creator>John</creator>
493 <created>...</created>
494 <summary>More tests for Bug.merge()</summary>
495 <extra-string>TAG: favorite</extra-string>
496 <extra-string>TAG: useful</extra-string>
497 <extra-string>TAG: very helpful</extra-string>
499 <uuid>uuid-commA</uuid>
500 <short-name>0123:1</short-name>
503 <content-type>text/plain</content-type>
504 <body>comment A</body>
507 <uuid>uuid-commB</uuid>
508 <short-name>0123:2</short-name>
511 <content-type>text/plain</content-type>
512 <body>comment B</body>
516 for attr in other.explicit_attrs:
517 old = getattr(self, attr)
518 new = getattr(other, attr)
520 if allow_changes == True:
521 setattr(self, attr, new)
524 'Merge would change %s "%s"->"%s" for bug %s' \
525 % (attr, old, new, self.uuid)
526 if allow_changes == False and len(other.extra_strings) > 0:
528 'Merge would change extra_strings for bug %s' % self.uuid
529 for estr in other.extra_strings:
530 if not estr in self.extra_strings:
531 self.extra_strings.append(estr)
533 for o_comm in other.comments():
535 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
538 s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
542 if allow_new_comments == False:
544 'Merge would add comment %s (alt: %s) to bug %s' \
545 % (o_comm.uuid, o_comm.alt_id, self.uuid)
546 o_comm_copy = copy.copy(o_comm)
547 o_comm_copy.bug = self
548 print >> sys.stderr, "add comment %s" % o_comm.uuid
549 self.comment_root.add_reply(o_comm_copy)
551 print >> sys.stderr, "merge comment %s into %s" % (o_comm.uuid, s_comm.uuid)
552 s_comm.merge(o_comm, allow_changes=allow_changes)
554 def string(self, shortlist=False, show_comments=False):
555 if self.bugdir == None:
556 shortname = self.uuid
558 shortname = self.bugdir.bug_shortname(self)
559 if shortlist == False:
560 if self.time == None:
563 htime = utility.handy_time(self.time)
564 timestring = "%s (%s)" % (htime, self.time_string)
565 info = [("ID", self.uuid),
566 ("Short name", shortname),
567 ("Severity", self.severity),
568 ("Status", self.status),
569 ("Assigned", self._setting_attr_string("assigned")),
570 ("Target", self._setting_attr_string("target")),
571 ("Reporter", self._setting_attr_string("reporter")),
572 ("Creator", self._setting_attr_string("creator")),
573 ("Created", timestring)]
574 longest_key_len = max([len(k) for k,v in info])
575 infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
576 bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
578 statuschar = self.status[0]
579 severitychar = self.severity[0]
580 chars = "%c%c" % (statuschar, severitychar)
581 bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
583 if show_comments == True:
584 # take advantage of the string_thread(auto_name_map=True)
585 # SIDE-EFFECT of sorting by comment time.
586 comout = self.comment_root.string_thread(flatten=False,
588 bug_shortname=shortname)
589 output = bugout + '\n' + comout.rstrip('\n')
594 # methods for saving/loading/acessing settings and properties.
596 def get_path(self, *args):
597 dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
600 assert args[0] in ["values", "comments"], str(args)
601 return os.path.join(dir, *args)
603 def set_sync_with_disk(self, value):
604 self.sync_with_disk = value
605 for comment in self.comments():
606 comment.set_sync_with_disk(value)
608 def load_settings(self):
609 if self.sync_with_disk == False:
610 raise DiskAccessRequired("load settings")
611 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
612 self._setup_saved_settings()
614 def save_settings(self):
615 if self.sync_with_disk == False:
616 raise DiskAccessRequired("save settings")
617 assert self.summary != None, "Can't save blank bug"
618 self.vcs.mkdir(self.get_path())
619 path = self.get_path("values")
620 mapfile.map_save(self.vcs, path, self._get_saved_settings())
624 Save any loaded contents to disk. Because of lazy loading of
625 comments, this is actually not too inefficient.
627 However, if self.sync_with_disk = True, then any changes are
628 automatically written to disk as soon as they happen, so
629 calling this method will just waste time (unless something
630 else has been messing with your on-disk files).
632 sync_with_disk = self.sync_with_disk
633 if sync_with_disk == False:
634 self.set_sync_with_disk(True)
636 if len(self.comment_root) > 0:
637 comment.saveComments(self)
638 if sync_with_disk == False:
639 self.set_sync_with_disk(False)
641 def load_comments(self, load_full=True):
642 if self.sync_with_disk == False:
643 raise DiskAccessRequired("load comments")
644 if load_full == True:
645 # Force a complete load of the whole comment tree
646 self.comment_root = self._get_comment_root(load_full=True)
648 # Setup for fresh lazy-loading. Clear _comment_root, so
649 # _get_comment_root returns a fresh version. Turn of
650 # syncing temporarily so we don't write our blank comment
652 self.sync_with_disk = False
653 self.comment_root = None
654 self.sync_with_disk = True
657 if self.sync_with_disk == False:
658 raise DiskAccessRequired("remove")
659 self.comment_root.remove()
660 path = self.get_path()
661 self.vcs.recursive_remove(path)
663 # methods for managing comments
666 for comment in self.comment_root.traverse():
669 def new_comment(self, body=None):
670 comm = self.comment_root.new_reply(body=body)
673 def comment_from_shortname(self, shortname, *args, **kwargs):
674 return self.comment_root.comment_from_shortname(shortname,
677 def comment_from_uuid(self, uuid, *args, **kwargs):
678 return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
680 def comment_shortnames(self, shortname=None):
682 SIDE-EFFECT : Comment.comment_shortnames will sort the comment
685 for id, comment in self.comment_root.comment_shortnames(shortname):
689 # The general rule for bug sorting is that "more important" bugs are
690 # less than "less important" bugs. This way sorting a list of bugs
691 # will put the most important bugs first in the list. When relative
692 # importance is unclear, the sorting follows some arbitrary convention
693 # (i.e. dictionary order).
695 def cmp_severity(bug_1, bug_2):
697 Compare the severity levels of two bugs, with more severe bugs
701 >>> bugA.severity = bugB.severity = "wishlist"
702 >>> cmp_severity(bugA, bugB) == 0
704 >>> bugB.severity = "minor"
705 >>> cmp_severity(bugA, bugB) > 0
707 >>> bugA.severity = "critical"
708 >>> cmp_severity(bugA, bugB) < 0
711 if not hasattr(bug_2, "severity") :
713 return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
715 def cmp_status(bug_1, bug_2):
717 Compare the status levels of two bugs, with more 'open' bugs
721 >>> bugA.status = bugB.status = "open"
722 >>> cmp_status(bugA, bugB) == 0
724 >>> bugB.status = "closed"
725 >>> cmp_status(bugA, bugB) < 0
727 >>> bugA.status = "fixed"
728 >>> cmp_status(bugA, bugB) > 0
731 if not hasattr(bug_2, "status") :
733 val_2 = status_index[bug_2.status]
734 return cmp(status_index[bug_1.status], status_index[bug_2.status])
736 def cmp_attr(bug_1, bug_2, attr, invert=False):
738 Compare a general attribute between two bugs using the conventional
739 comparison rule for that attribute type. If invert == True, sort
740 *against* that convention.
744 >>> bugA.severity = "critical"
745 >>> bugB.severity = "wishlist"
746 >>> cmp_attr(bugA, bugB, attr) < 0
748 >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
750 >>> bugB.severity = "critical"
751 >>> cmp_attr(bugA, bugB, attr) == 0
754 if not hasattr(bug_2, attr) :
756 val_1 = getattr(bug_1, attr)
757 val_2 = getattr(bug_2, attr)
758 if val_1 == None: val_1 = None
759 if val_2 == None: val_2 = None
762 return -cmp(val_1, val_2)
764 return cmp(val_1, val_2)
766 # alphabetical rankings (a < z)
767 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
768 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
769 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
770 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
771 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
772 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
773 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
774 # chronological rankings (newer < older)
775 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
777 def cmp_comments(bug_1, bug_2):
779 Compare two bugs' comments lists. Doesn't load any new comments,
780 so you should call each bug's .load_comments() first if you want a
783 comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
784 comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
785 result = cmp(len(comms_1), len(comms_2))
788 for c_1,c_2 in zip(comms_1, comms_2):
789 result = cmp(c_1, c_2)
794 DEFAULT_CMP_FULL_CMP_LIST = \
795 (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
796 cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid,
799 class BugCompoundComparator (object):
800 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
801 self.cmp_list = cmp_list
802 def __call__(self, bug_1, bug_2):
803 for comparison in self.cmp_list :
804 val = comparison(bug_1, bug_2)
809 cmp_full = BugCompoundComparator()
812 # define some bonus cmp_* functions
813 def cmp_last_modified(bug_1, bug_2):
815 Like cmp_time(), but use most recent comment instead of bug
816 creation for the timestamp.
818 def last_modified(bug):
820 for comment in bug.comment_root.traverse():
821 if comment.time > time:
824 val_1 = last_modified(bug_1)
825 val_2 = last_modified(bug_2)
826 return -cmp(val_1, val_2)
829 suite = doctest.DocTestSuite()