Added libbe/encoding.py to wrap input/output/file access appropriately.
[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 _get_comment_root(self):
99         if self._comment_root == None:
100             if self._comments_loaded == True:
101                 self._comment_root = comment.loadComments(self)
102             else:
103                 self._comment_root = comment.Comment(self,
104                                                      uuid=comment.INVALID_UUID)
105         return self._comment_root
106
107     def _set_comment_root(self, comment_root):
108         self._comment_root = comment_root
109
110     _comment_root = None
111     comment_root = property(_get_comment_root, _set_comment_root,
112                             doc="The trunk of the comment tree")
113
114     def __init__(self, bugdir=None, uuid=None, from_disk=False,
115                  load_comments=False, summary=None):
116         self.bugdir = bugdir
117         if bugdir != None:
118             self.rcs = bugdir.rcs
119         else:
120             self.rcs = None
121         if from_disk == True:
122             self._comments_loaded = False
123             self.uuid = uuid
124             self.load(load_comments=load_comments)
125         else:
126             # Note: defaults should match those in Bug.load()
127             self._comments_loaded = True
128             if uuid != None:
129                 self.uuid = uuid
130             else:
131                 self.uuid = uuid_gen()
132             self.summary = summary
133             if self.rcs != None:
134                 self.creator = self.rcs.get_user_id()
135             else:
136                 self.creator = None
137             self.target = None
138             self.status = "open"
139             self.severity = "minor"
140             self.assigned = None
141             self.time = int(time.time()) # only save to second precision
142
143     def __repr__(self):
144         return "Bug(uuid=%r)" % self.uuid
145
146     def string(self, shortlist=False, show_comments=False):
147         if self.bugdir == None:
148             shortname = self.uuid
149         else:
150             shortname = self.bugdir.bug_shortname(self)
151         if shortlist == False:
152             if self.time == None:
153                 timestring = ""
154             else:
155                 htime = utility.handy_time(self.time)
156                 ftime = utility.time_to_str(self.time)
157                 timestring = "%s (%s)" % (htime, ftime)
158             info = [("ID", self.uuid),
159                     ("Short name", shortname),
160                     ("Severity", self.severity),
161                     ("Status", self.status),
162                     ("Assigned", self.assigned),
163                     ("Target", self.target),
164                     ("Creator", self.creator),
165                     ("Created", timestring)]
166             newinfo = []
167             for k,v in info:
168                 if v == None:
169                     newinfo.append((k,""))
170                 else:
171                     newinfo.append((k,v))
172             info = newinfo
173             longest_key_len = max([len(k) for k,v in info])
174             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
175             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
176         else:
177             statuschar = self.status[0]
178             severitychar = self.severity[0]
179             chars = "%c%c" % (statuschar, severitychar)
180             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
181         
182         if show_comments == True:
183             if self._comments_loaded == False:
184                 self.load_comments()
185             # take advantage of the string_thread(auto_name_map=True)
186             # SIDE-EFFECT of sorting by bug time.
187             comout = self.comment_root.string_thread(flatten=False,
188                                                      auto_name_map=True,
189                                                      bug_shortname=shortname)
190             output = bugout + '\n' + comout.rstrip('\n')
191         else :
192             output = bugout
193         return output
194
195     def __str__(self):
196         return self.string(shortlist=True)
197
198     def __cmp__(self, other):
199         return cmp_full(self, other)
200
201     def get_path(self, name=None):
202         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
203         if name is None:
204             return my_dir
205         assert name in ["values", "comments"]
206         return os.path.join(my_dir, name)
207
208     def load(self, load_comments=False):
209         map = mapfile.map_load(self.rcs, self.get_path("values"))
210         self.summary = map.get("summary")
211         self.creator = map.get("creator")
212         self.target = map.get("target")
213         self.status = map.get("status", "open")
214         self.severity = map.get("severity", "minor")
215         self.assigned = map.get("assigned")
216         self.time = map.get("time")
217         if self.time is not None:
218             self.time = utility.str_to_time(self.time)
219
220         if load_comments == True:
221             self.load_comments()
222
223     def load_comments(self):
224         # clear _comment_root, so _get_comment_root returns a fresh version
225         self._comment_root = None 
226         self._comments_loaded = True
227  
228     def comments(self):
229         if self._comments_loaded == False:
230             self.load_comments()
231         for comment in self.comment_root.traverse():
232             yield comment
233
234     def _add_attr(self, map, name):
235         value = getattr(self, name)
236         if value is not None:
237             map[name] = value
238
239     def save(self):
240         assert self.summary != None, "Can't save blank bug"
241         map = {}
242         self._add_attr(map, "assigned")
243         self._add_attr(map, "summary")
244         self._add_attr(map, "creator")
245         self._add_attr(map, "target")
246         self._add_attr(map, "status")
247         self._add_attr(map, "severity")
248         if self.time is not None:
249             map["time"] = utility.time_to_str(self.time)
250
251         self.rcs.mkdir(self.get_path())
252         path = self.get_path("values")
253         mapfile.map_save(self.rcs, path, map)
254
255         if len(self.comment_root) > 0:
256             self.rcs.mkdir(self.get_path("comments"))
257             comment.saveComments(self)
258
259     def remove(self):
260         self.comment_root.remove()
261         path = self.get_path()
262         self.rcs.recursive_remove(path)
263     
264     def new_comment(self, body=None):
265         comm = self.comment_root.new_reply(body=body)
266         return comm
267
268     def comment_from_shortname(self, shortname, *args, **kwargs):
269         return self.comment_root.comment_from_shortname(shortname,
270                                                         *args, **kwargs)
271
272     def comment_from_uuid(self, uuid):
273         return self.comment_root.comment_from_uuid(uuid)
274
275
276 # the general rule for bug sorting is that "more important" bugs are
277 # less than "less important" bugs.  This way sorting a list of bugs
278 # will put the most important bugs first in the list.  When relative
279 # importance is unclear, the sorting follows some arbitrary convention
280 # (i.e. dictionary order).
281
282 def cmp_severity(bug_1, bug_2):
283     """
284     Compare the severity levels of two bugs, with more severe bugs
285     comparing as less.
286     >>> bugA = Bug()
287     >>> bugB = Bug()
288     >>> bugA.severity = bugB.severity = "wishlist"
289     >>> cmp_severity(bugA, bugB) == 0
290     True
291     >>> bugB.severity = "minor"
292     >>> cmp_severity(bugA, bugB) > 0
293     True
294     >>> bugA.severity = "critical"
295     >>> cmp_severity(bugA, bugB) < 0
296     True
297     """
298     if not hasattr(bug_2, "severity") :
299         return 1
300     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
301
302 def cmp_status(bug_1, bug_2):
303     """
304     Compare the status levels of two bugs, with more 'open' bugs
305     comparing as less.
306     >>> bugA = Bug()
307     >>> bugB = Bug()
308     >>> bugA.status = bugB.status = "open"
309     >>> cmp_status(bugA, bugB) == 0
310     True
311     >>> bugB.status = "closed"
312     >>> cmp_status(bugA, bugB) < 0
313     True
314     >>> bugA.status = "fixed"
315     >>> cmp_status(bugA, bugB) > 0
316     True
317     """
318     if not hasattr(bug_2, "status") :
319         return 1
320     val_2 = status_index[bug_2.status]
321     return cmp(status_index[bug_1.status], status_index[bug_2.status])
322
323 def cmp_attr(bug_1, bug_2, attr, invert=False):
324     """
325     Compare a general attribute between two bugs using the conventional
326     comparison rule for that attribute type.  If invert == True, sort
327     *against* that convention.
328     >>> attr="severity"
329     >>> bugA = Bug()
330     >>> bugB = Bug()
331     >>> bugA.severity = "critical"
332     >>> bugB.severity = "wishlist"
333     >>> cmp_attr(bugA, bugB, attr) < 0
334     True
335     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
336     True
337     >>> bugB.severity = "critical"
338     >>> cmp_attr(bugA, bugB, attr) == 0
339     True
340     """
341     if not hasattr(bug_2, attr) :
342         return 1
343     if invert == True :
344         return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
345     else :
346         return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
347
348 # alphabetical rankings (a < z)
349 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
350 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
351 # chronological rankings (newer < older)
352 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
353
354 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
355                                      cmp_time,cmp_creator)):
356     for comparison in cmp_list :
357         val = comparison(bug_1, bug_2)
358         if val != 0 :
359             return val
360     return 0
361
362 class InvalidValue(ValueError):
363     def __init__(self, name, value):
364         msg = "Cannot assign value %s to %s" % (value, name)
365         Exception.__init__(self, msg)
366         self.name = name
367         self.value = value
368
369 suite = doctest.DocTestSuite()