Add extra strings to Bug.string() output.
[be.git] / libbe / bug.py
1 # Copyright (C) 2008-2011 Gianluca Montecchi <gian@grys.it>
2 #                         Thomas Habets <thomas@habets.pp.se>
3 #                         W. Trevor King <wking@drexel.edu>
4 #
5 # This file is part of Bugs Everywhere.
6 #
7 # Bugs Everywhere is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation, either version 2 of the License, or (at your
10 # option) any later version.
11 #
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 # General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
19
20 """Define the :class:`Bug` class for representing bugs.
21 """
22
23 import copy
24 import os
25 import os.path
26 import errno
27 import sys
28 import time
29 import types
30 try: # import core module, Python >= 2.5
31     from xml.etree import ElementTree
32 except ImportError: # look for non-core module
33     from elementtree import ElementTree
34 import xml.sax.saxutils
35
36 import libbe
37 import libbe.util.id
38 from libbe.storage.util.properties import Property, doc_property, \
39     local_property, defaulting_property, checked_property, cached_property, \
40     primed_property, change_hook_property, settings_property
41 import libbe.storage.util.settings_object as settings_object
42 import libbe.storage.util.mapfile as mapfile
43 import libbe.comment as comment
44 import libbe.util.utility as utility
45
46 if libbe.TESTING == True:
47     import doctest
48
49
50 ### Define and describe valid bug categories
51 # Use a tuple of (category, description) tuples since we don't have
52 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
53
54 # in order of increasing severity.  (name, description) pairs
55 severity_def = (
56   ("target", "The issue is a target or milestone, not a bug."),
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     """A bug (or issue) is a place to store attributes and attach
121     :class:`~libbe.comment.Comment`\s.  In mailing-list terms, a bug is
122     analogous to a thread.  Bugs are normally stored in
123     :class:`~libbe.bugdir.BugDir`\s.
124
125     >>> b = Bug()
126     >>> print b.status
127     open
128     >>> print b.severity
129     minor
130
131     There are two formats for time, int and string.  Setting either
132     one will adjust the other appropriately.  The string form is the
133     one stored in the bug's settings file on disk.
134
135     >>> print type(b.time)
136     <type 'int'>
137     >>> print type(b.time_string)
138     <type 'str'>
139     >>> b.time = 0
140     >>> print b.time_string
141     Thu, 01 Jan 1970 00:00:00 +0000
142     >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
143     >>> b.time
144     60
145     >>> print b.settings["time"]
146     Thu, 01 Jan 1970 00:01:00 +0000
147     """
148     settings_properties = []
149     required_saved_properties = []
150     _prop_save_settings = settings_object.prop_save_settings
151     _prop_load_settings = settings_object.prop_load_settings
152     def _versioned_property(settings_properties=settings_properties,
153                             required_saved_properties=required_saved_properties,
154                             **kwargs):
155         if "settings_properties" not in kwargs:
156             kwargs["settings_properties"] = settings_properties
157         if "required_saved_properties" not in kwargs:
158             kwargs["required_saved_properties"]=required_saved_properties
159         return settings_object.versioned_property(**kwargs)
160
161     @_versioned_property(name="severity",
162                          doc="A measure of the bug's importance",
163                          default="minor",
164                          check_fn=lambda s: s in severity_values,
165                          require_save=True)
166     def severity(): return {}
167
168     @_versioned_property(name="status",
169                          doc="The bug's current status",
170                          default="open",
171                          check_fn=lambda s: s in status_values,
172                          require_save=True)
173     def status(): return {}
174
175     @property
176     def active(self):
177         return self.status in active_status_values
178
179     @_versioned_property(name="creator",
180                          doc="The user who entered the bug into the system")
181     def creator(): return {}
182
183     @_versioned_property(name="reporter",
184                          doc="The user who reported the bug")
185     def reporter(): return {}
186
187     @_versioned_property(name="assigned",
188                          doc="The developer in charge of the bug")
189     def assigned(): return {}
190
191     @_versioned_property(name="time",
192                          doc="An RFC 2822 timestamp for bug creation")
193     def time_string(): return {}
194
195     def _get_time(self):
196         if self.time_string == None:
197             return None
198         return utility.str_to_time(self.time_string)
199     def _set_time(self, value):
200         self.time_string = utility.time_to_str(value)
201     time = property(fget=_get_time,
202                     fset=_set_time,
203                     doc="An integer version of .time_string")
204
205     def _extra_strings_check_fn(value):
206         return utility.iterable_full_of_strings(value, \
207                          alternative=settings_object.EMPTY)
208     def _extra_strings_change_hook(self, old, new):
209         self.extra_strings.sort() # to make merging easier
210         self._prop_save_settings(old, new)
211     @_versioned_property(name="extra_strings",
212                          doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
213                          default=[],
214                          check_fn=_extra_strings_check_fn,
215                          change_hook=_extra_strings_change_hook,
216                          mutable=True)
217     def extra_strings(): return {}
218
219     @_versioned_property(name="summary",
220                          doc="A one-line bug description")
221     def summary(): return {}
222
223     def _get_comment_root(self, load_full=False):
224         if self.storage != None and self.storage.is_readable():
225             return comment.load_comments(self, load_full=load_full)
226         else:
227             return comment.Comment(self, uuid=comment.INVALID_UUID)
228
229     @Property
230     @cached_property(generator=_get_comment_root)
231     @local_property("comment_root")
232     @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.")
233     def comment_root(): return {}
234
235     def __init__(self, bugdir=None, uuid=None, from_storage=False,
236                  load_comments=False, summary=None):
237         settings_object.SavedSettingsObject.__init__(self)
238         self.bugdir = bugdir
239         self.storage = None
240         self.uuid = uuid
241         self.id = libbe.util.id.ID(self, 'bug')
242         if from_storage == False:
243             if uuid == None:
244                 self.uuid = libbe.util.id.uuid_gen()
245             self.time = int(time.time()) # only save to second precision
246             self.summary = summary
247             dummy = self.comment_root
248         if self.bugdir != None:
249             self.storage = self.bugdir.storage
250         if from_storage == False:
251             if self.storage != None and self.storage.is_writeable():
252                 self.save()
253
254     def __repr__(self):
255         return "Bug(uuid=%r)" % self.uuid
256
257     def __str__(self):
258         return self.string(shortlist=True)
259
260     def __cmp__(self, other):
261         return cmp_full(self, other)
262
263     # serializing methods
264
265     def _setting_attr_string(self, setting):
266         value = getattr(self, setting)
267         if value == None:
268             return ""
269         if type(value) not in types.StringTypes:
270             return str(value)
271         return value
272
273     def string(self, shortlist=False, show_comments=False):
274         if shortlist == False:
275             if self.time == None:
276                 timestring = ""
277             else:
278                 htime = utility.handy_time(self.time)
279                 timestring = "%s (%s)" % (htime, self.time_string)
280             info = [("ID", self.uuid),
281                     ("Short name", self.id.user()),
282                     ("Severity", self.severity),
283                     ("Status", self.status),
284                     ("Assigned", self._setting_attr_string("assigned")),
285                     ("Reporter", self._setting_attr_string("reporter")),
286                     ("Creator", self._setting_attr_string("creator")),
287                     ("Created", timestring)]
288             for estr in self.extra_strings:
289                 info.append(('Extra string', estr))
290             longest_key_len = max([len(k) for k,v in info])
291             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
292             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
293         else:
294             statuschar = self.status[0]
295             severitychar = self.severity[0]
296             chars = "%c%c" % (statuschar, severitychar)
297             bugout = "%s:%s: %s" % (self.id.user(),chars,self.summary.rstrip('\n'))
298
299         if show_comments == True:
300             self.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
301             comout = self.comment_root.string_thread(flatten=False)
302             output = bugout + '\n' + comout.rstrip('\n')
303         else :
304             output = bugout
305         return output
306
307     def xml(self, indent=0, show_comments=False):
308         if self.time == None:
309             timestring = ""
310         else:
311             timestring = utility.time_to_str(self.time)
312
313         info = [('uuid', self.uuid),
314                 ('short-name', self.id.user()),
315                 ('severity', self.severity),
316                 ('status', self.status),
317                 ('assigned', self.assigned),
318                 ('reporter', self.reporter),
319                 ('creator', self.creator),
320                 ('created', timestring),
321                 ('summary', self.summary)]
322         lines = ['<bug>']
323         for (k,v) in info:
324             if v is not None:
325                 lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
326         for estr in self.extra_strings:
327             lines.append('  <extra-string>%s</extra-string>' % estr)
328         if show_comments == True:
329             comout = self.comment_root.xml_thread(indent=indent+2)
330             if len(comout) > 0:
331                 lines.append(comout)
332         lines.append('</bug>')
333         istring = ' '*indent
334         sep = '\n' + istring
335         return istring + sep.join(lines).rstrip('\n')
336
337     def from_xml(self, xml_string, preserve_uuids=False, verbose=True):
338         u"""
339         Note: If a bug uuid is given, set .alt_id to it's value.
340         >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()")
341         >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
342         >>> bugA.creator = u'Fran\xe7ois'
343         >>> bugA.extra_strings += ['TAG: very helpful']
344         >>> commA = bugA.comment_root.new_reply(body='comment A')
345         >>> commB = bugA.comment_root.new_reply(body='comment B')
346         >>> commC = commA.new_reply(body='comment C')
347         >>> xml = bugA.xml(show_comments=True)
348         >>> bugB = Bug()
349         >>> bugB.from_xml(xml, verbose=True)
350         >>> bugB.xml(show_comments=True) == xml
351         False
352         >>> bugB.uuid = bugB.alt_id
353         >>> for comm in bugB.comments():
354         ...     comm.uuid = comm.alt_id
355         ...     comm.alt_id = None
356         >>> bugB.xml(show_comments=True) == xml
357         True
358         >>> bugB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
359         ['severity', 'status', 'creator', 'time', 'summary']
360         >>> len(list(bugB.comments()))
361         3
362         >>> bugC = Bug()
363         >>> bugC.from_xml(xml, preserve_uuids=True)
364         >>> bugC.uuid == bugA.uuid
365         True
366         """
367         if type(xml_string) == types.UnicodeType:
368             xml_string = xml_string.strip().encode('unicode_escape')
369         if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
370             bug = xml_string
371         else:
372             bug = ElementTree.XML(xml_string)
373         if bug.tag != 'bug':
374             raise utility.InvalidXML( \
375                 'bug', bug, 'root element must be <comment>')
376         tags=['uuid','short-name','severity','status','assigned',
377               'reporter', 'creator','created','summary','extra-string']
378         self.explicit_attrs = []
379         uuid = None
380         estrs = []
381         comments = []
382         for child in bug.getchildren():
383             if child.tag == 'short-name':
384                 pass
385             elif child.tag == 'comment':
386                 comm = comment.Comment(bug=self)
387                 comm.from_xml(
388                     child, preserve_uuids=preserve_uuids, verbose=verbose)
389                 comments.append(comm)
390                 continue
391             elif child.tag in tags:
392                 if child.text == None or len(child.text) == 0:
393                     text = settings_object.EMPTY
394                 else:
395                     text = xml.sax.saxutils.unescape(child.text)
396                     text = text.decode('unicode_escape').strip()
397                 if child.tag == 'uuid' and not preserve_uuids:
398                     uuid = text
399                     continue # don't set the bug's uuid tag.
400                 elif child.tag == 'created':
401                     self.time = utility.str_to_time(text)
402                     self.explicit_attrs.append('time')
403                     continue
404                 elif child.tag == 'extra-string':
405                     estrs.append(text)
406                     continue # don't set the bug's extra_string yet.
407                 attr_name = child.tag.replace('-','_')
408                 self.explicit_attrs.append(attr_name)
409                 setattr(self, attr_name, text)
410             elif verbose == True:
411                 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
412                     % (child.tag, comment.tag)
413         if uuid != self.uuid:
414             if not hasattr(self, 'alt_id') or self.alt_id == None:
415                 self.alt_id = uuid
416         self.extra_strings = estrs
417         self.add_comments(comments, ignore_missing_references=True)
418
419     def add_comment(self, comment, *args, **kwargs):
420         """
421         Add a comment too the current bug, under the parent specified
422         by comment.in_reply_to.
423         Note: If a bug uuid is given, set .alt_id to it's value.
424
425         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()')
426         >>> bugA.creator = 'Jack'
427         >>> commA = bugA.comment_root.new_reply(body='comment A')
428         >>> commA.uuid = 'commA'
429         >>> commB = comment.Comment(body='comment B')
430         >>> commB.uuid = 'commB'
431         >>> bugA.add_comment(commB)
432         >>> commC = comment.Comment(body='comment C')
433         >>> commC.uuid = 'commC'
434         >>> commC.in_reply_to = commA.uuid
435         >>> bugA.add_comment(commC)
436         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
437         <bug>
438           <uuid>0123</uuid>
439           <short-name>/012</short-name>
440           <severity>minor</severity>
441           <status>open</status>
442           <creator>Jack</creator>
443           <created>...</created>
444           <summary>Need to test Bug.add_comment()</summary>
445           <comment>
446             <uuid>commA</uuid>
447             <short-name>/012/commA</short-name>
448             <author></author>
449             <date>...</date>
450             <content-type>text/plain</content-type>
451             <body>comment A</body>
452           </comment>
453           <comment>
454             <uuid>commC</uuid>
455             <short-name>/012/commC</short-name>
456             <in-reply-to>commA</in-reply-to>
457             <author></author>
458             <date>...</date>
459             <content-type>text/plain</content-type>
460             <body>comment C</body>
461           </comment>
462           <comment>
463             <uuid>commB</uuid>
464             <short-name>/012/commB</short-name>
465             <author></author>
466             <date>...</date>
467             <content-type>text/plain</content-type>
468             <body>comment B</body>
469           </comment>
470         </bug>
471         """
472         self.add_comments([comment], **kwargs)
473
474     def add_comments(self, comments, default_parent=None,
475                      ignore_missing_references=False):
476         """
477         Convert a raw list of comments to single root comment.  If a
478         comment does not specify a parent with .in_reply_to, the
479         parent defaults to .comment_root, but you can specify another
480         default parent via default_parent.
481         """
482         uuid_map = {}
483         if default_parent == None:
484             default_parent = self.comment_root
485         for c in list(self.comments()) + comments:
486             assert c.uuid != None
487             assert c.uuid not in uuid_map
488             uuid_map[c.uuid] = c
489             if c.alt_id != None:
490                 uuid_map[c.alt_id] = c
491         uuid_map[None] = self.comment_root
492         uuid_map[comment.INVALID_UUID] = self.comment_root
493         if default_parent != self.comment_root:
494             assert default_parent.uuid in uuid_map, default_parent.uuid
495         for c in comments:
496             if c.in_reply_to == None \
497                     and default_parent.uuid != comment.INVALID_UUID:
498                 c.in_reply_to = default_parent.uuid
499             elif c.in_reply_to == comment.INVALID_UUID:
500                 c.in_reply_to = None
501             try:
502                 parent = uuid_map[c.in_reply_to]
503             except KeyError:
504                 if ignore_missing_references == True:
505                     print >> sys.stderr, \
506                         'Ignoring missing reference to %s' % c.in_reply_to
507                     parent = default_parent
508                     if parent.uuid != comment.INVALID_UUID:
509                         c.in_reply_to = parent.uuid
510                 else:
511                     raise comment.MissingReference(c)
512             c.bug = self
513             parent.append(c)
514
515     def merge(self, other, accept_changes=True,
516               accept_extra_strings=True, accept_comments=True,
517               change_exception=False):
518         """
519         Merge info from other into this bug.  Overrides any attributes
520         in self that are listed in other.explicit_attrs.
521
522         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
523         >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
524         >>> bugA.creator = 'Frank'
525         >>> bugA.extra_strings += ['TAG: very helpful']
526         >>> bugA.extra_strings += ['TAG: favorite']
527         >>> commA = bugA.comment_root.new_reply(body='comment A')
528         >>> commA.uuid = 'uuid-commA'
529         >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()')
530         >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
531         >>> bugB.creator = 'John'
532         >>> bugB.explicit_attrs = ['creator', 'summary']
533         >>> bugB.extra_strings += ['TAG: very helpful']
534         >>> bugB.extra_strings += ['TAG: useful']
535         >>> commB = bugB.comment_root.new_reply(body='comment B')
536         >>> commB.uuid = 'uuid-commB'
537         >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
538         ...            accept_comments=False, change_exception=False)
539         >>> print bugA.creator
540         Frank
541         >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
542         ...            accept_comments=False, change_exception=True)
543         Traceback (most recent call last):
544           ...
545         ValueError: Merge would change creator "Frank"->"John" for bug 0123
546         >>> print bugA.creator
547         Frank
548         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=False,
549         ...            accept_comments=False, change_exception=True)
550         Traceback (most recent call last):
551           ...
552         ValueError: Merge would add extra string "TAG: useful" for bug 0123
553         >>> print bugA.creator
554         John
555         >>> print bugA.extra_strings
556         ['TAG: favorite', 'TAG: very helpful']
557         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
558         ...            accept_comments=False, change_exception=True)
559         Traceback (most recent call last):
560           ...
561         ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
562         >>> print bugA.extra_strings
563         ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
564         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
565         ...            accept_comments=True, change_exception=True)
566         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
567         <bug>
568           <uuid>0123</uuid>
569           <short-name>/012</short-name>
570           <severity>minor</severity>
571           <status>open</status>
572           <creator>John</creator>
573           <created>...</created>
574           <summary>More tests for Bug.merge()</summary>
575           <extra-string>TAG: favorite</extra-string>
576           <extra-string>TAG: useful</extra-string>
577           <extra-string>TAG: very helpful</extra-string>
578           <comment>
579             <uuid>uuid-commA</uuid>
580             <short-name>/012/uuid-commA</short-name>
581             <author></author>
582             <date>...</date>
583             <content-type>text/plain</content-type>
584             <body>comment A</body>
585           </comment>
586           <comment>
587             <uuid>uuid-commB</uuid>
588             <short-name>/012/uuid-commB</short-name>
589             <author></author>
590             <date>...</date>
591             <content-type>text/plain</content-type>
592             <body>comment B</body>
593           </comment>
594         </bug>
595         """
596         for attr in other.explicit_attrs:
597             old = getattr(self, attr)
598             new = getattr(other, attr)
599             if old != new:
600                 if accept_changes == True:
601                     setattr(self, attr, new)
602                 elif change_exception == True:
603                     raise ValueError, \
604                         'Merge would change %s "%s"->"%s" for bug %s' \
605                         % (attr, old, new, self.uuid)
606         for estr in other.extra_strings:
607             if not estr in self.extra_strings:
608                 if accept_extra_strings == True:
609                     self.extra_strings.append(estr)
610                 elif change_exception == True:
611                     raise ValueError, \
612                         'Merge would add extra string "%s" for bug %s' \
613                         % (estr, self.uuid)
614         for o_comm in other.comments():
615             try:
616                 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
617             except KeyError, e:
618                 try:
619                     s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
620                 except KeyError, e:
621                     s_comm = None
622             if s_comm == None:
623                 if accept_comments == True:
624                     o_comm_copy = copy.copy(o_comm)
625                     o_comm_copy.bug = self
626                     o_comm_copy.id = libbe.util.id.ID(o_comm_copy, 'comment')
627                     self.comment_root.add_reply(o_comm_copy)
628                 elif change_exception == True:
629                     raise ValueError, \
630                         'Merge would add comment %s (alt: %s) to bug %s' \
631                         % (o_comm.uuid, o_comm.alt_id, self.uuid)
632             else:
633                 s_comm.merge(o_comm, accept_changes=accept_changes,
634                              accept_extra_strings=accept_extra_strings,
635                              change_exception=change_exception)
636
637     # methods for saving/loading/acessing settings and properties.
638
639     def load_settings(self, settings_mapfile=None):
640         if settings_mapfile == None:
641             settings_mapfile = self.storage.get(
642                 self.id.storage('values'), '\n')
643         try:
644             settings = mapfile.parse(settings_mapfile)
645         except mapfile.InvalidMapfileContents, e:
646             raise Exception('Invalid settings file for bug %s\n'
647                             '(BE version missmatch?)' % self.id.user())
648         self._setup_saved_settings(settings)
649
650     def save_settings(self):
651         mf = mapfile.generate(self._get_saved_settings())
652         self.storage.set(self.id.storage('values'), mf)
653
654     def save(self):
655         """
656         Save any loaded contents to storage.  Because of lazy loading
657         of comments, this is actually not too inefficient.
658
659         However, if self.storage.is_writeable() == True, then any
660         changes are automatically written to storage as soon as they
661         happen, so calling this method will just waste time (unless
662         something else has been messing with your stored files).
663         """
664         assert self.storage != None, "Can't save without storage"
665         if self.bugdir != None:
666             parent = self.bugdir.id.storage()
667         else:
668             parent = None
669         self.storage.add(self.id.storage(), parent=parent, directory=True)
670         self.storage.add(self.id.storage('values'), parent=self.id.storage(),
671                          directory=False)
672         self.save_settings()
673         if len(self.comment_root) > 0:
674             comment.save_comments(self)
675
676     def load_comments(self, load_full=True):
677         if load_full == True:
678             # Force a complete load of the whole comment tree
679             self.comment_root = self._get_comment_root(load_full=True)
680         else:
681             # Setup for fresh lazy-loading.  Clear _comment_root, so
682             # next _get_comment_root returns a fresh version.  Turn of
683             # writing temporarily so we don't write our blank comment
684             # tree to disk.
685             w = self.storage.writeable
686             self.storage.writeable = False
687             self.comment_root = None
688             self.storage.writeable = w
689
690     def remove(self):
691         self.storage.recursive_remove(self.id.storage())
692
693     # methods for managing comments
694
695     def uuids(self):
696         for comment in self.comments():
697             yield comment.uuid
698
699     def comments(self):
700         for comment in self.comment_root.traverse():
701             yield comment
702
703     def new_comment(self, body=None):
704         comm = self.comment_root.new_reply(body=body)
705         return comm
706
707     def comment_from_uuid(self, uuid, *args, **kwargs):
708         return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
709
710     # methods for id generation
711
712     def sibling_uuids(self):
713         if self.bugdir != None:
714             return self.bugdir.uuids()
715         return []
716
717
718 # The general rule for bug sorting is that "more important" bugs are
719 # less than "less important" bugs.  This way sorting a list of bugs
720 # will put the most important bugs first in the list.  When relative
721 # importance is unclear, the sorting follows some arbitrary convention
722 # (i.e. dictionary order).
723
724 def cmp_severity(bug_1, bug_2):
725     """
726     Compare the severity levels of two bugs, with more severe bugs
727     comparing as less.
728
729     >>> bugA = Bug()
730     >>> bugB = Bug()
731     >>> bugA.severity = bugB.severity = "wishlist"
732     >>> cmp_severity(bugA, bugB) == 0
733     True
734     >>> bugB.severity = "minor"
735     >>> cmp_severity(bugA, bugB) > 0
736     True
737     >>> bugA.severity = "critical"
738     >>> cmp_severity(bugA, bugB) < 0
739     True
740     """
741     if not hasattr(bug_2, "severity") :
742         return 1
743     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
744
745 def cmp_status(bug_1, bug_2):
746     """
747     Compare the status levels of two bugs, with more "open" bugs
748     comparing as less.
749
750     >>> bugA = Bug()
751     >>> bugB = Bug()
752     >>> bugA.status = bugB.status = "open"
753     >>> cmp_status(bugA, bugB) == 0
754     True
755     >>> bugB.status = "closed"
756     >>> cmp_status(bugA, bugB) < 0
757     True
758     >>> bugA.status = "fixed"
759     >>> cmp_status(bugA, bugB) > 0
760     True
761     """
762     if not hasattr(bug_2, "status") :
763         return 1
764     val_2 = status_index[bug_2.status]
765     return cmp(status_index[bug_1.status], status_index[bug_2.status])
766
767 def cmp_attr(bug_1, bug_2, attr, invert=False):
768     """
769     Compare a general attribute between two bugs using the
770     conventional comparison rule for that attribute type.  If
771     ``invert==True``, sort *against* that convention.
772
773     >>> attr="severity"
774     >>> bugA = Bug()
775     >>> bugB = Bug()
776     >>> bugA.severity = "critical"
777     >>> bugB.severity = "wishlist"
778     >>> cmp_attr(bugA, bugB, attr) < 0
779     True
780     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
781     True
782     >>> bugB.severity = "critical"
783     >>> cmp_attr(bugA, bugB, attr) == 0
784     True
785     """
786     if not hasattr(bug_2, attr) :
787         return 1
788     val_1 = getattr(bug_1, attr)
789     val_2 = getattr(bug_2, attr)
790     if val_1 == None: val_1 = None
791     if val_2 == None: val_2 = None
792
793     if invert == True :
794         return -cmp(val_1, val_2)
795     else :
796         return cmp(val_1, val_2)
797
798 # alphabetical rankings (a < z)
799 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
800 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
801 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
802 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
803 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
804 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
805 # chronological rankings (newer < older)
806 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
807
808 def cmp_mine(bug_1, bug_2):
809     user_id = libbe.ui.util.user.get_user_id(bug_1.storage)
810     mine_1 = bug_1.assigned != user_id
811     mine_2 = bug_2.assigned != user_id
812     return cmp(mine_1, mine_2)
813
814 def cmp_comments(bug_1, bug_2):
815     """
816     Compare two bugs' comments lists.  Doesn't load any new comments,
817     so you should call each bug's .load_comments() first if you want a
818     full comparison.
819     """
820     comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
821     comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
822     result = cmp(len(comms_1), len(comms_2))
823     if result != 0:
824         return result
825     for c_1,c_2 in zip(comms_1, comms_2):
826         result = cmp(c_1, c_2)
827         if result != 0:
828             return result
829     return 0
830
831 DEFAULT_CMP_FULL_CMP_LIST = \
832     (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
833      cmp_reporter, cmp_comments, cmp_summary, cmp_uuid, cmp_extra_strings)
834
835 class BugCompoundComparator (object):
836     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
837         self.cmp_list = cmp_list
838     def __call__(self, bug_1, bug_2):
839         for comparison in self.cmp_list :
840             val = comparison(bug_1, bug_2)
841             if val != 0 :
842                 return val
843         return 0
844
845 cmp_full = BugCompoundComparator()
846
847
848 # define some bonus cmp_* functions
849 def cmp_last_modified(bug_1, bug_2):
850     """
851     Like cmp_time(), but use most recent comment instead of bug
852     creation for the timestamp.
853     """
854     def last_modified(bug):
855         time = bug.time
856         for comment in bug.comment_root.traverse():
857             if comment.time > time:
858                 time = comment.time
859         return time
860     val_1 = last_modified(bug_1)
861     val_2 = last_modified(bug_2)
862     return -cmp(val_1, val_2)
863
864
865 if libbe.TESTING == True:
866     suite = doctest.DocTestSuite()