Merge from W. Trevor King's tree.
[be.git] / libbe / bug.py
1 # Copyright (C) 2005 Aaron Bentley and Panometrics, Inc.
2 # <abentley@panoramicfeedback.com>
3 #
4 #    This program is free software; you can redistribute it and/or modify
5 #    it under the terms of the GNU General Public License as published by
6 #    the Free Software Foundation; either version 2 of the License, or
7 #    (at your option) any later version.
8 #
9 #    This program is distributed in the hope that it will be useful,
10 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
11 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 #    GNU General Public License for more details.
13 #
14 #    You should have received a copy of the GNU General Public License
15 #    along with this program; if not, write to the Free Software
16 #    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17 import os
18 import os.path
19 import errno
20 import time
21 import doctest
22
23 from beuuid import uuid_gen
24 import mapfile
25 import comment
26 import utility
27
28
29 ### Define and describe valid bug categories
30 # Use a tuple of (category, description) tuples since we don't have
31 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
32
33 # in order of increasing severity
34 severity_level_def = (
35   ("wishlist","A feature that could improve usefullness, but not a bug."),
36   ("minor","The standard bug level."),
37   ("serious","A bug that requires workarounds."),
38   ("critical","A bug that prevents some features from working at all."),
39   ("fatal","A bug that makes the package unusable."))
40
41 # in order of increasing resolution
42 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
43 active_status_def = (
44   ("unconfirmed","A possible bug which lacks independent existance confirmation."),
45   ("open","A working bug that has not been assigned to a developer."),
46   ("assigned","A working bug that has been assigned to a developer."),
47   ("test","The code has been adjusted, but the fix is still being tested."))
48 inactive_status_def = (
49   ("closed", "The bug is no longer relevant."),
50   ("fixed", "The bug should no longer occur."),
51   ("wontfix","It's not a bug, it's a feature."),
52   ("disabled", "?"))
53
54
55 ### Convert the description tuples to more useful formats
56
57 severity_values = tuple([val for val,description in severity_level_def])
58 severity_description = dict(severity_level_def)
59 severity_index = {}
60 for i in range(len(severity_values)):
61     severity_index[severity_values[i]] = i
62
63 active_status_values = tuple(val for val,description in active_status_def)
64 inactive_status_values = tuple(val for val,description in inactive_status_def)
65 status_values = active_status_values + inactive_status_values
66 status_description = dict(active_status_def+inactive_status_def)
67 status_index = {}
68 for i in range(len(status_values)):
69     status_index[status_values[i]] = i
70
71
72 def checked_property(name, valid):
73     """
74     Provide access to an attribute name, testing for valid values.
75     """
76     def getter(self):
77         value = getattr(self, "_"+name)
78         if value not in valid:
79             raise InvalidValue(name, value)
80         return value
81
82     def setter(self, value):
83         if value not in valid:
84             raise InvalidValue(name, value)
85         return setattr(self, "_"+name, value)
86     return property(getter, setter)
87
88
89 class Bug(object):
90     severity = checked_property("severity", severity_values)
91     status = checked_property("status", status_values)
92
93     def _get_active(self):
94         return self.status in active_status_values
95
96     active = property(_get_active)
97
98     def __init__(self, bugdir=None, uuid=None, from_disk=False,
99                  load_comments=False, summary=None):
100         self.bugdir = bugdir
101         if bugdir != None:
102             self.rcs = bugdir.rcs
103         else:
104             self.rcs = None
105         if from_disk == True:
106             self._comments_loaded = False
107             self.uuid = uuid
108             self.load(load_comments=load_comments)
109         else:
110             # Note: defaults should match those in Bug.load()
111             self._comments_loaded = True
112             if uuid != None:
113                 self.uuid = uuid
114             else:
115                 self.uuid = uuid_gen()
116             self.summary = summary
117             if self.rcs != None:
118                 self.creator = self.rcs.get_user_id()
119             else:
120                 self.creator = None
121             self.target = None
122             self.status = "open"
123             self.severity = "minor"
124             self.assigned = None
125             self.time = int(time.time()) # only save to second precision
126             self.comment_root = comment.Comment(self, uuid=comment.INVALID_UUID)
127
128     def __repr__(self):
129         return "Bug(uuid=%r)" % self.uuid
130
131     def string(self, shortlist=False, show_comments=False):
132         if self.bugdir == None:
133             shortname = self.uuid
134         else:
135             shortname = self.bugdir.bug_shortname(self)
136         if shortlist == False:
137             if self.time == None:
138                 timestring = ""
139             else:
140                 htime = utility.handy_time(self.time)
141                 ftime = utility.time_to_str(self.time)
142                 timestring = "%s (%s)" % (htime, ftime)
143             info = [("ID", self.uuid),
144                     ("Short name", shortname),
145                     ("Severity", self.severity),
146                     ("Status", self.status),
147                     ("Assigned", self.assigned),
148                     ("Target", self.target),
149                     ("Creator", self.creator),
150                     ("Created", timestring)]
151             newinfo = []
152             for k,v in info:
153                 if v == None:
154                     newinfo.append((k,""))
155                 else:
156                     newinfo.append((k,v))
157             info = newinfo
158             longest_key_len = max([len(k) for k,v in info])
159             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
160             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
161         else:
162             statuschar = self.status[0]
163             severitychar = self.severity[0]
164             chars = "%c%c" % (statuschar, severitychar)
165             bugout = "%s:%s: %s" % (shortname, chars, self.summary.rstrip('\n'))
166         
167         if show_comments == True:
168             if self._comments_loaded == False:
169                 self.load_comments()
170             comout = self.comment_root.string_thread(auto_name_map=True,
171                                                      bug_shortname=shortname)
172             output = bugout + '\n' + comout.rstrip('\n')
173         else :
174             output = bugout
175         return output
176
177     def __str__(self):
178         return self.string(shortlist=True)
179
180     def __cmp__(self, other):
181         return cmp_full(self, other)
182
183     def get_path(self, name=None):
184         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
185         if name is None:
186             return my_dir
187         assert name in ["values", "comments"]
188         return os.path.join(my_dir, name)
189
190     def load(self, load_comments=False):
191         map = mapfile.map_load(self.rcs, self.get_path("values"))
192         self.summary = map.get("summary")
193         self.creator = map.get("creator")
194         self.target = map.get("target")
195         self.status = map.get("status", "open")
196         self.severity = map.get("severity", "minor")
197         self.assigned = map.get("assigned")
198         self.time = map.get("time")
199         if self.time is not None:
200             self.time = utility.str_to_time(self.time)
201
202         if load_comments == True:
203             self.load_comments()
204
205     def load_comments(self):
206         self.comment_root = comment.loadComments(self)
207         self._comments_loaded = True
208
209     def comments(self):
210         if self._comments_loaded == False:
211             self.load_comments()
212         for comment in self.comment_root.traverse():
213             yield comment
214
215     def _add_attr(self, map, name):
216         value = getattr(self, name)
217         if value is not None:
218             map[name] = value
219
220     def save(self):
221         assert self.summary != None, "Can't save blank bug"
222         map = {}
223         self._add_attr(map, "assigned")
224         self._add_attr(map, "summary")
225         self._add_attr(map, "creator")
226         self._add_attr(map, "target")
227         self._add_attr(map, "status")
228         self._add_attr(map, "severity")
229         if self.time is not None:
230             map["time"] = utility.time_to_str(self.time)
231
232         self.rcs.mkdir(self.get_path())
233         path = self.get_path("values")
234         mapfile.map_save(self.rcs, path, map)
235
236         if self._comments_loaded:
237             if len(self.comment_root) > 0:
238                 self.rcs.mkdir(self.get_path("comments"))
239                 comment.saveComments(self)
240
241     def remove(self):
242         self.load_comments()
243         self.comment_root.remove()
244         path = self.get_path()
245         self.rcs.recursive_remove(path)
246     
247     def new_comment(self, body=None):
248         comm = comment.comment_root.new_reply(body=body)
249         return comm
250
251     def comment_from_shortname(self, shortname, *args, **kwargs):
252         return self.comment_root.comment_from_shortname(shortname, *args, **kwargs)
253
254     def comment_from_uuid(self, uuid):
255         return self.comment_root.comment_from_uuid(uuid)
256
257
258 # the general rule for bug sorting is that "more important" bugs are
259 # less than "less important" bugs.  This way sorting a list of bugs
260 # will put the most important bugs first in the list.  When relative
261 # importance is unclear, the sorting follows some arbitrary convention
262 # (i.e. dictionary order).
263
264 def cmp_severity(bug_1, bug_2):
265     """
266     Compare the severity levels of two bugs, with more severe bugs
267     comparing as less.
268     >>> bugA = Bug()
269     >>> bugB = Bug()
270     >>> bugA.severity = bugB.severity = "wishlist"
271     >>> cmp_severity(bugA, bugB) == 0
272     True
273     >>> bugB.severity = "minor"
274     >>> cmp_severity(bugA, bugB) > 0
275     True
276     >>> bugA.severity = "critical"
277     >>> cmp_severity(bugA, bugB) < 0
278     True
279     """
280     if not hasattr(bug_2, "severity") :
281         return 1
282     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
283
284 def cmp_status(bug_1, bug_2):
285     """
286     Compare the status levels of two bugs, with more 'open' bugs
287     comparing as less.
288     >>> bugA = Bug()
289     >>> bugB = Bug()
290     >>> bugA.status = bugB.status = "open"
291     >>> cmp_status(bugA, bugB) == 0
292     True
293     >>> bugB.status = "closed"
294     >>> cmp_status(bugA, bugB) < 0
295     True
296     >>> bugA.status = "fixed"
297     >>> cmp_status(bugA, bugB) > 0
298     True
299     """
300     if not hasattr(bug_2, "status") :
301         return 1
302     val_2 = status_index[bug_2.status]
303     return cmp(status_index[bug_1.status], status_index[bug_2.status])
304
305 def cmp_attr(bug_1, bug_2, attr, invert=False):
306     """
307     Compare a general attribute between two bugs using the conventional
308     comparison rule for that attribute type.  If invert == True, sort
309     *against* that convention.
310     >>> attr="severity"
311     >>> bugA = Bug()
312     >>> bugB = Bug()
313     >>> bugA.severity = "critical"
314     >>> bugB.severity = "wishlist"
315     >>> cmp_attr(bugA, bugB, attr) < 0
316     True
317     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
318     True
319     >>> bugB.severity = "critical"
320     >>> cmp_attr(bugA, bugB, attr) == 0
321     True
322     """
323     if not hasattr(bug_2, attr) :
324         return 1
325     if invert == True :
326         return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
327     else :
328         return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
329
330 # alphabetical rankings (a < z)
331 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
332 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
333 # chronological rankings (newer < older)
334 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
335
336 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
337                                      cmp_time,cmp_creator)):
338     for comparison in cmp_list :
339         val = comparison(bug_1, bug_2)
340         if val != 0 :
341             return val
342     return 0
343
344 class InvalidValue(ValueError):
345     def __init__(self, name, value):
346         msg = "Cannot assign value %s to %s" % (value, name)
347         Exception.__init__(self, msg)
348         self.name = name
349         self.value = value
350
351 suite = doctest.DocTestSuite()