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