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