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