5f2cf541207230f05fb2af3efd5faf5568e04c0a
[be.git] / libbe / bug.py
1 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
2 #                         Thomas Habets <thomas@habets.pp.se>
3 #                         W. Trevor King <wking@drexel.edu>
4 #
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.
9 #
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.
14 #
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.
18
19 """
20 Define the Bug class for representing bugs.
21 """
22
23 import copy
24 import os
25 import os.path
26 import errno
27 import time
28 import types
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
34 import doctest
35
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
41 import mapfile
42 import comment
43 import utility
44
45
46 class DiskAccessRequired (Exception):
47     def __init__(self, goal):
48         msg = "Cannot %s without accessing the disk" % goal
49         Exception.__init__(self, msg)
50
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/
54
55 # in order of increasing severity.  (name, description) pairs
56 severity_def = (
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."))
62
63 # in order of increasing resolution
64 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
65 active_status_def = (
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."))
74
75
76 ### Convert the description tuples to more useful formats
77
78 severity_values = ()
79 severity_description = {}
80 severity_index = {}
81 def load_severities(severity_def):
82     global severity_values
83     global severity_description
84     global severity_index
85     if severity_def == None:
86         return
87     severity_values = tuple([val for val,description in severity_def])
88     severity_description = dict(severity_def)
89     severity_index = {}
90     for i,severity in enumerate(severity_values):
91         severity_index[severity] = i
92 load_severities(severity_def)
93
94 active_status_values = []
95 inactive_status_values = []
96 status_values = []
97 status_description = {}
98 status_index = {}
99 def load_status(active_status_def, inactive_status_def):
100     global active_status_values
101     global inactive_status_values
102     global status_values
103     global status_description
104     global status_index
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))
113     status_index = {}
114     for i,status in enumerate(status_values):
115         status_index[status] = i
116 load_status(active_status_def, inactive_status_def)
117
118
119 class Bug(settings_object.SavedSettingsObject):
120     """
121     >>> b = Bug()
122     >>> print b.status
123     open
124     >>> print b.severity
125     minor
126
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)
131     <type 'int'>
132     >>> print type(b.time_string)
133     <type 'str'>
134     >>> b.time = 0
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"
138     >>> b.time
139     60
140     >>> print b.settings["time"]
141     Thu, 01 Jan 1970 00:01:00 +0000
142     """
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,
149                             **kwargs):
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)
155
156     @_versioned_property(name="severity",
157                          doc="A measure of the bug's importance",
158                          default="minor",
159                          check_fn=lambda s: s in severity_values,
160                          require_save=True)
161     def severity(): return {}
162
163     @_versioned_property(name="status",
164                          doc="The bug's current status",
165                          default="open",
166                          check_fn=lambda s: s in status_values,
167                          require_save=True)
168     def status(): return {}
169     
170     @property
171     def active(self):
172         return self.status in active_status_values
173
174     @_versioned_property(name="target",
175                          doc="The deadline for fixing this bug")
176     def target(): return {}
177
178     @_versioned_property(name="creator",
179                          doc="The user who entered the bug into the system")
180     def creator(): return {}
181
182     @_versioned_property(name="reporter",
183                          doc="The user who reported the bug")
184     def reporter(): return {}
185
186     @_versioned_property(name="assigned",
187                          doc="The developer in charge of the bug")
188     def assigned(): return {}
189
190     @_versioned_property(name="time",
191                          doc="An RFC 2822 timestamp for bug creation")
192     def time_string(): return {}
193
194     def _get_time(self):
195         if self.time_string == None:
196             return 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,
201                     fset=_set_time,
202                     doc="An integer version of .time_string")
203
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.",
212                          default=[],
213                          check_fn=_extra_strings_check_fn,
214                          change_hook=_extra_strings_change_hook,
215                          mutable=True)
216     def extra_strings(): return {}
217
218     @_versioned_property(name="summary",
219                          doc="A one-line bug description")
220     def summary(): return {}
221
222     def _get_comment_root(self, load_full=False):
223         if self.sync_with_disk:
224             return comment.loadComments(self, load_full=load_full)
225         else:
226             return comment.Comment(self, uuid=comment.INVALID_UUID)
227
228     @Property
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 {}
233
234     def _get_vcs(self):
235         if hasattr(self.bugdir, "vcs"):
236             return self.bugdir.vcs
237
238     @Property
239     @cached_property(generator=_get_vcs)
240     @local_property("vcs")
241     @doc_property(doc="A revision control system instance.")
242     def vcs(): return {}
243
244     def __init__(self, bugdir=None, uuid=None, from_disk=False,
245                  load_comments=False, summary=None):
246         settings_object.SavedSettingsObject.__init__(self)
247         self.bugdir = bugdir
248         self.uuid = uuid
249         if from_disk == True:
250             self.sync_with_disk = True
251         else:
252             self.sync_with_disk = False
253             if uuid == None:
254                 self.uuid = uuid_gen()
255             self.time = int(time.time()) # only save to second precision
256             if self.vcs != None:
257                 self.creator = self.vcs.get_user_id()
258             self.summary = summary
259
260     def __repr__(self):
261         return "Bug(uuid=%r)" % self.uuid
262
263     def __str__(self):
264         return self.string(shortlist=True)
265
266     def __cmp__(self, other):
267         return cmp_full(self, other)
268
269     # serializing methods
270
271     def _setting_attr_string(self, setting):
272         value = getattr(self, setting)
273         if value == None:
274             return ""
275         if type(value) not in types.StringTypes:
276             return str(value)
277         return value
278
279     def xml(self, indent=0, shortname=None, show_comments=False):
280         if shortname == None:
281             if self.bugdir == None:
282                 shortname = self.uuid
283             else:
284                 shortname = self.bugdir.bug_shortname(self)
285
286         if self.time == None:
287             timestring = ""
288         else:
289             timestring = utility.time_to_str(self.time)
290
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)]
301         lines = ['<bug>']
302         for (k,v) in info:
303             if v is not None:
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,
309                                                   auto_name_map=True,
310                                                   bug_shortname=shortname)
311             if len(comout) > 0:
312                 lines.append(comout)
313         lines.append('</bug>')
314         istring = ' '*indent
315         sep = '\n' + istring
316         return istring + sep.join(lines).rstrip('\n')
317
318     def from_xml(self, xml_string, verbose=True):
319         """
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)
329         >>> bugB = Bug()
330         >>> bugB.from_xml(xml, verbose=True)
331         >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
332         False
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
338         True
339         >>> bugB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
340         ['severity', 'status', 'creator', 'created', 'summary']
341         >>> len(list(bugB.comments()))
342         3
343         """
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
347             bug = xml_string
348         else:
349             bug = ElementTree.XML(xml_string)
350         if bug.tag != 'bug':
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 = []
356         uuid = None
357         estrs = []
358         for child in bug.getchildren():
359             if child.tag == 'short-name':
360                 pass
361             elif child.tag == 'comment':
362                 comm = comment.Comment(bug=self)
363                 comm.from_xml(child)
364                 self.add_comment(comm)
365                 continue
366             elif child.tag in tags:
367                 if child.text == None or len(child.text) == 0:
368                     text = settings_object.EMPTY
369                 else:
370                     text = xml.sax.saxutils.unescape(child.text)
371                     text = text.decode('unicode_escape').strip()
372                 if child.tag == 'uuid':
373                     uuid = text
374                     continue # don't set the bug's uuid tag.
375                 elif child.tag == 'extra-string':
376                     estrs.append(text)
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:
386                 self.alt_id = uuid
387         self.extra_strings = estrs
388
389     def add_comment(self, new_comment):
390         """
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
406         <bug>
407           <uuid>0123</uuid>
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>
414           <comment>
415             <uuid>commA</uuid>
416             <short-name>bug-1:1</short-name>
417             <author></author>
418             <date>...</date>
419             <content-type>text/plain</content-type>
420             <body>comment A</body>
421           </comment>
422           <comment>
423             <uuid>commC</uuid>
424             <short-name>bug-1:2</short-name>
425             <in-reply-to>commA</in-reply-to>
426             <author></author>
427             <date>...</date>
428             <content-type>text/plain</content-type>
429             <body>comment C</body>
430           </comment>
431           <comment>
432             <uuid>commB</uuid>
433             <short-name>bug-1:3</short-name>
434             <author></author>
435             <date>...</date>
436             <content-type>text/plain</content-type>
437             <body>comment B</body>
438           </comment>
439         </bug>
440         """
441         uuid_map = {}
442         for c in self.comments():
443             uuid_map[c.uuid] = c
444             if c.alt_id != None:
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
453         else:
454             parent = uuid_map[new_comment.in_reply_to]
455         new_comment.bug = self
456         parent.append(new_comment)
457
458     def merge(self, other, allow_changes=True, allow_new_comments=True):
459         """
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):
479           ...
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):
483           ...
484         ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
485         >>> bugA.merge(bugB)
486         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
487         <bug>
488           <uuid>0123</uuid>
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>
498           <comment>
499             <uuid>uuid-commA</uuid>
500             <short-name>0123:1</short-name>
501             <author></author>
502             <date>...</date>
503             <content-type>text/plain</content-type>
504             <body>comment A</body>
505           </comment>
506           <comment>
507             <uuid>uuid-commB</uuid>
508             <short-name>0123:2</short-name>
509             <author></author>
510             <date>...</date>
511             <content-type>text/plain</content-type>
512             <body>comment B</body>
513           </comment>
514         </bug>
515         """
516         for attr in other.explicit_attrs:
517             old = getattr(self, attr)
518             new = getattr(other, attr)
519             if old != new:
520                 if allow_changes == True:
521                     setattr(self, attr, new)
522                 else:
523                     raise ValueError, \
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:
527             raise ValueError, \
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)
532         import sys
533         for o_comm in other.comments():
534             try:
535                 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
536             except KeyError, e:
537                 try:
538                     s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
539                 except KeyError, e:
540                     s_comm = None
541             if s_comm == None:
542                 if allow_new_comments == False:
543                     raise ValueError, \
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)
550             else:
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)
553
554     def string(self, shortlist=False, show_comments=False):
555         if self.bugdir == None:
556             shortname = self.uuid
557         else:
558             shortname = self.bugdir.bug_shortname(self)
559         if shortlist == False:
560             if self.time == None:
561                 timestring = ""
562             else:
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')
577         else:
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'))
582         
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,
587                                                      auto_name_map=True,
588                                                      bug_shortname=shortname)
589             output = bugout + '\n' + comout.rstrip('\n')
590         else :
591             output = bugout
592         return output
593
594     # methods for saving/loading/acessing settings and properties.
595
596     def get_path(self, *args):
597         dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
598         if len(args) == 0:
599             return dir
600         assert args[0] in ["values", "comments"], str(args)
601         return os.path.join(dir, *args)
602
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)
607
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()
613
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())
621
622     def save(self):
623         """
624         Save any loaded contents to disk.  Because of lazy loading of
625         comments, this is actually not too inefficient.
626         
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).
631         """
632         sync_with_disk = self.sync_with_disk
633         if sync_with_disk == False:
634             self.set_sync_with_disk(True)
635         self.save_settings()
636         if len(self.comment_root) > 0:
637             comment.saveComments(self)
638         if sync_with_disk == False:
639             self.set_sync_with_disk(False)
640
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)
647         else:
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
651             # tree to disk.
652             self.sync_with_disk = False
653             self.comment_root = None
654             self.sync_with_disk = True
655
656     def remove(self):
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)
662     
663     # methods for managing comments
664
665     def comments(self):
666         for comment in self.comment_root.traverse():
667             yield comment
668
669     def new_comment(self, body=None):
670         comm = self.comment_root.new_reply(body=body)
671         return comm
672
673     def comment_from_shortname(self, shortname, *args, **kwargs):
674         return self.comment_root.comment_from_shortname(shortname,
675                                                         *args, **kwargs)
676
677     def comment_from_uuid(self, uuid, *args, **kwargs):
678         return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
679
680     def comment_shortnames(self, shortname=None):
681         """
682         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
683         tree by comment.time
684         """
685         for id, comment in self.comment_root.comment_shortnames(shortname):
686             yield (id, comment)
687
688
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).
694
695 def cmp_severity(bug_1, bug_2):
696     """
697     Compare the severity levels of two bugs, with more severe bugs
698     comparing as less.
699     >>> bugA = Bug()
700     >>> bugB = Bug()
701     >>> bugA.severity = bugB.severity = "wishlist"
702     >>> cmp_severity(bugA, bugB) == 0
703     True
704     >>> bugB.severity = "minor"
705     >>> cmp_severity(bugA, bugB) > 0
706     True
707     >>> bugA.severity = "critical"
708     >>> cmp_severity(bugA, bugB) < 0
709     True
710     """
711     if not hasattr(bug_2, "severity") :
712         return 1
713     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
714
715 def cmp_status(bug_1, bug_2):
716     """
717     Compare the status levels of two bugs, with more 'open' bugs
718     comparing as less.
719     >>> bugA = Bug()
720     >>> bugB = Bug()
721     >>> bugA.status = bugB.status = "open"
722     >>> cmp_status(bugA, bugB) == 0
723     True
724     >>> bugB.status = "closed"
725     >>> cmp_status(bugA, bugB) < 0
726     True
727     >>> bugA.status = "fixed"
728     >>> cmp_status(bugA, bugB) > 0
729     True
730     """
731     if not hasattr(bug_2, "status") :
732         return 1
733     val_2 = status_index[bug_2.status]
734     return cmp(status_index[bug_1.status], status_index[bug_2.status])
735
736 def cmp_attr(bug_1, bug_2, attr, invert=False):
737     """
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.
741     >>> attr="severity"
742     >>> bugA = Bug()
743     >>> bugB = Bug()
744     >>> bugA.severity = "critical"
745     >>> bugB.severity = "wishlist"
746     >>> cmp_attr(bugA, bugB, attr) < 0
747     True
748     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
749     True
750     >>> bugB.severity = "critical"
751     >>> cmp_attr(bugA, bugB, attr) == 0
752     True
753     """
754     if not hasattr(bug_2, attr) :
755         return 1
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
760     
761     if invert == True :
762         return -cmp(val_1, val_2)
763     else :
764         return cmp(val_1, val_2)
765
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)
776
777 def cmp_comments(bug_1, bug_2):
778     """
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
781     full comparison.
782     """
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))
786     if result != 0:
787         return result
788     for c_1,c_2 in zip(comms_1, comms_2):
789         result = cmp(c_1, c_2)
790         if result != 0:
791             return result
792     return 0
793
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,
797      cmp_extra_strings)
798
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)
805             if val != 0 :
806                 return val
807         return 0
808         
809 cmp_full = BugCompoundComparator()
810
811
812 # define some bonus cmp_* functions
813 def cmp_last_modified(bug_1, bug_2):
814     """
815     Like cmp_time(), but use most recent comment instead of bug
816     creation for the timestamp.
817     """
818     def last_modified(bug):
819         time = bug.time
820         for comment in bug.comment_root.traverse():
821             if comment.time > time:
822                 time = comment.time
823         return time
824     val_1 = last_modified(bug_1)
825     val_2 = last_modified(bug_2)
826     return -cmp(val_1, val_2)
827
828
829 suite = doctest.DocTestSuite()