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