Moved comment.list_to_root() to Bug.add_comments() with some cleanups.
[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.  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 {}
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         comments = []
359         for child in bug.getchildren():
360             if child.tag == 'short-name':
361                 pass
362             elif child.tag == 'comment':
363                 comm = comment.Comment(bug=self)
364                 comm.from_xml(child)
365                 comments.append(comm)
366                 continue
367             elif child.tag in tags:
368                 if child.text == None or len(child.text) == 0:
369                     text = settings_object.EMPTY
370                 else:
371                     text = xml.sax.saxutils.unescape(child.text)
372                     text = text.decode('unicode_escape').strip()
373                 if child.tag == 'uuid':
374                     uuid = text
375                     continue # don't set the bug's uuid tag.
376                 elif child.tag == 'extra-string':
377                     estrs.append(text)
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:
387                 self.alt_id = uuid
388         self.extra_strings = estrs
389         self.add_comments(comments)
390
391     def add_comment(self, comment, *args, **kwargs):
392         """
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
408         <bug>
409           <uuid>0123</uuid>
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>
416           <comment>
417             <uuid>commA</uuid>
418             <short-name>bug-1:1</short-name>
419             <author></author>
420             <date>...</date>
421             <content-type>text/plain</content-type>
422             <body>comment A</body>
423           </comment>
424           <comment>
425             <uuid>commC</uuid>
426             <short-name>bug-1:2</short-name>
427             <in-reply-to>commA</in-reply-to>
428             <author></author>
429             <date>...</date>
430             <content-type>text/plain</content-type>
431             <body>comment C</body>
432           </comment>
433           <comment>
434             <uuid>commB</uuid>
435             <short-name>bug-1:3</short-name>
436             <author></author>
437             <date>...</date>
438             <content-type>text/plain</content-type>
439             <body>comment B</body>
440           </comment>
441         </bug>
442         """
443         self.add_comments([comment], **kwargs)
444
445     def add_comments(self, comments, default_parent=None,
446                      ignore_missing_references=False):
447         """
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.
452         """
453         uuid_map = {}
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
459             uuid_map[c.uuid] = c
460             if c.alt_id != None:
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
465         for c in comments:
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:
470                 c.in_reply_to = None
471             try:
472                 parent = uuid_map[c.in_reply_to]
473             except KeyError:
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
480                 else:
481                     raise comment.MissingReference(c)
482             c.bug = self
483             parent.append(c)
484
485     def merge(self, other, allow_changes=True, allow_new_comments=True):
486         """
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):
506           ...
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):
510           ...
511         ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
512         >>> bugA.merge(bugB)
513         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
514         <bug>
515           <uuid>0123</uuid>
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>
525           <comment>
526             <uuid>uuid-commA</uuid>
527             <short-name>0123:1</short-name>
528             <author></author>
529             <date>...</date>
530             <content-type>text/plain</content-type>
531             <body>comment A</body>
532           </comment>
533           <comment>
534             <uuid>uuid-commB</uuid>
535             <short-name>0123:2</short-name>
536             <author></author>
537             <date>...</date>
538             <content-type>text/plain</content-type>
539             <body>comment B</body>
540           </comment>
541         </bug>
542         """
543         for attr in other.explicit_attrs:
544             old = getattr(self, attr)
545             new = getattr(other, attr)
546             if old != new:
547                 if allow_changes == True:
548                     setattr(self, attr, new)
549                 else:
550                     raise ValueError, \
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:
554             raise ValueError, \
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)
559         import sys
560         for o_comm in other.comments():
561             try:
562                 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
563             except KeyError, e:
564                 try:
565                     s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
566                 except KeyError, e:
567                     s_comm = None
568             if s_comm == None:
569                 if allow_new_comments == False:
570                     raise ValueError, \
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)
576             else:
577                 s_comm.merge(o_comm, allow_changes=allow_changes)
578
579     def string(self, shortlist=False, show_comments=False):
580         if self.bugdir == None:
581             shortname = self.uuid
582         else:
583             shortname = self.bugdir.bug_shortname(self)
584         if shortlist == False:
585             if self.time == None:
586                 timestring = ""
587             else:
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')
602         else:
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'))
607         
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,
612                                                      auto_name_map=True,
613                                                      bug_shortname=shortname)
614             output = bugout + '\n' + comout.rstrip('\n')
615         else :
616             output = bugout
617         return output
618
619     # methods for saving/loading/acessing settings and properties.
620
621     def get_path(self, *args):
622         dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
623         if len(args) == 0:
624             return dir
625         assert args[0] in ["values", "comments"], str(args)
626         return os.path.join(dir, *args)
627
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)
632
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()
638
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())
646
647     def save(self):
648         """
649         Save any loaded contents to disk.  Because of lazy loading of
650         comments, this is actually not too inefficient.
651         
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).
656         """
657         sync_with_disk = self.sync_with_disk
658         if sync_with_disk == False:
659             self.set_sync_with_disk(True)
660         self.save_settings()
661         if len(self.comment_root) > 0:
662             comment.saveComments(self)
663         if sync_with_disk == False:
664             self.set_sync_with_disk(False)
665
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)
672         else:
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
676             # tree to disk.
677             self.sync_with_disk = False
678             self.comment_root = None
679             self.sync_with_disk = True
680
681     def remove(self):
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)
687     
688     # methods for managing comments
689
690     def comments(self):
691         for comment in self.comment_root.traverse():
692             yield comment
693
694     def new_comment(self, body=None):
695         comm = self.comment_root.new_reply(body=body)
696         return comm
697
698     def comment_from_shortname(self, shortname, *args, **kwargs):
699         return self.comment_root.comment_from_shortname(shortname,
700                                                         *args, **kwargs)
701
702     def comment_from_uuid(self, uuid, *args, **kwargs):
703         return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
704
705     def comment_shortnames(self, shortname=None):
706         """
707         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
708         tree by comment.time
709         """
710         for id, comment in self.comment_root.comment_shortnames(shortname):
711             yield (id, comment)
712
713
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).
719
720 def cmp_severity(bug_1, bug_2):
721     """
722     Compare the severity levels of two bugs, with more severe bugs
723     comparing as less.
724     >>> bugA = Bug()
725     >>> bugB = Bug()
726     >>> bugA.severity = bugB.severity = "wishlist"
727     >>> cmp_severity(bugA, bugB) == 0
728     True
729     >>> bugB.severity = "minor"
730     >>> cmp_severity(bugA, bugB) > 0
731     True
732     >>> bugA.severity = "critical"
733     >>> cmp_severity(bugA, bugB) < 0
734     True
735     """
736     if not hasattr(bug_2, "severity") :
737         return 1
738     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
739
740 def cmp_status(bug_1, bug_2):
741     """
742     Compare the status levels of two bugs, with more 'open' bugs
743     comparing as less.
744     >>> bugA = Bug()
745     >>> bugB = Bug()
746     >>> bugA.status = bugB.status = "open"
747     >>> cmp_status(bugA, bugB) == 0
748     True
749     >>> bugB.status = "closed"
750     >>> cmp_status(bugA, bugB) < 0
751     True
752     >>> bugA.status = "fixed"
753     >>> cmp_status(bugA, bugB) > 0
754     True
755     """
756     if not hasattr(bug_2, "status") :
757         return 1
758     val_2 = status_index[bug_2.status]
759     return cmp(status_index[bug_1.status], status_index[bug_2.status])
760
761 def cmp_attr(bug_1, bug_2, attr, invert=False):
762     """
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.
766     >>> attr="severity"
767     >>> bugA = Bug()
768     >>> bugB = Bug()
769     >>> bugA.severity = "critical"
770     >>> bugB.severity = "wishlist"
771     >>> cmp_attr(bugA, bugB, attr) < 0
772     True
773     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
774     True
775     >>> bugB.severity = "critical"
776     >>> cmp_attr(bugA, bugB, attr) == 0
777     True
778     """
779     if not hasattr(bug_2, attr) :
780         return 1
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
785     
786     if invert == True :
787         return -cmp(val_1, val_2)
788     else :
789         return cmp(val_1, val_2)
790
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)
801
802 def cmp_comments(bug_1, bug_2):
803     """
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
806     full comparison.
807     """
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))
811     if result != 0:
812         return result
813     for c_1,c_2 in zip(comms_1, comms_2):
814         result = cmp(c_1, c_2)
815         if result != 0:
816             return result
817     return 0
818
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,
822      cmp_extra_strings)
823
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)
830             if val != 0 :
831                 return val
832         return 0
833         
834 cmp_full = BugCompoundComparator()
835
836
837 # define some bonus cmp_* functions
838 def cmp_last_modified(bug_1, bug_2):
839     """
840     Like cmp_time(), but use most recent comment instead of bug
841     creation for the timestamp.
842     """
843     def last_modified(bug):
844         time = bug.time
845         for comment in bug.comment_root.traverse():
846             if comment.time > time:
847                 time = comment.time
848         return time
849     val_1 = last_modified(bug_1)
850     val_2 = last_modified(bug_2)
851     return -cmp(val_1, val_2)
852
853
854 suite = doctest.DocTestSuite()