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