Added libbe/properties to make property management easier.
[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 from properties import Property, doc_property, local_property, \
25     defaulting_property, checked_property, cached_property, \
26     primed_property, change_hook_property, settings_property
27 import mapfile
28 import comment
29 import utility
30
31
32 ### Define and describe valid bug categories
33 # Use a tuple of (category, description) tuples since we don't have
34 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
35
36 # in order of increasing severity
37 severity_level_def = (
38   ("wishlist","A feature that could improve usefullness, but not a bug."),
39   ("minor","The standard bug level."),
40   ("serious","A bug that requires workarounds."),
41   ("critical","A bug that prevents some features from working at all."),
42   ("fatal","A bug that makes the package unusable."))
43
44 # in order of increasing resolution
45 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
46 active_status_def = (
47   ("unconfirmed","A possible bug which lacks independent existance confirmation."),
48   ("open","A working bug that has not been assigned to a developer."),
49   ("assigned","A working bug that has been assigned to a developer."),
50   ("test","The code has been adjusted, but the fix is still being tested."))
51 inactive_status_def = (
52   ("closed", "The bug is no longer relevant."),
53   ("fixed", "The bug should no longer occur."),
54   ("wontfix","It's not a bug, it's a feature."),
55   ("disabled", "?"))
56
57
58 ### Convert the description tuples to more useful formats
59
60 severity_values = tuple([val for val,description in severity_level_def])
61 severity_description = dict(severity_level_def)
62 severity_index = {}
63 for i in range(len(severity_values)):
64     severity_index[severity_values[i]] = i
65
66 active_status_values = tuple(val for val,description in active_status_def)
67 inactive_status_values = tuple(val for val,description in inactive_status_def)
68 status_values = active_status_values + inactive_status_values
69 status_description = dict(active_status_def+inactive_status_def)
70 status_index = {}
71 for i in range(len(status_values)):
72     status_index[status_values[i]] = i
73
74
75 # Define an invalid value for our properties, distinct from None,
76 # which shows that a property has been initialized but has no value.
77 EMPTY = -1
78
79
80 class Bug(object):
81     """
82     >>> b = Bug()
83     >>> print b.status
84     open
85     >>> print b.severity
86     minor
87
88     There are two formats for time, int and string.  Setting either
89     one will adjust the other appropriately.  The string form is the
90     one stored in the bug's settings file on disk.
91     >>> print type(b.time)
92     <type 'int'>
93     >>> print type(b.time_string)
94     <type 'str'>
95     >>> b.time = 0
96     >>> print b.time_string
97     Thu, 01 Jan 1970 00:00:00 +0000
98     >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
99     >>> b.time
100     60
101     >>> print b.settings["time"]
102     Thu, 01 Jan 1970 00:01:00 +0000
103     """
104     def _save_settings(self, old, new):
105         if self.sync_with_disk==True:
106             self.save_settings()
107     def _load_settings(self):
108         if self.sync_with_disk==True and self._settings_loaded==False:
109             self.load_settings()
110         else:
111             for property in self.settings_properties:
112                 if property not in self.settings:
113                     self.settings[property] = EMPTY
114
115     settings_properties = []
116     required_saved_properties = ['status','severity'] # to protect against future changes in default values
117
118     def _versioned_property(name, doc, default=None, save=_save_settings, load=_load_settings, setprops=settings_properties, allowed=None):
119         "Combine the common decorators in a single function"
120         setprops.append(name)
121         def decorator(funcs):
122             if allowed != None:
123                 checked = checked_property(allowed=allowed)
124             defaulting  = defaulting_property(default=default, null=EMPTY)
125             change_hook = change_hook_property(hook=save)
126             primed      = primed_property(primer=load)
127             settings    = settings_property(name=name)
128             docp        = doc_property(doc=doc)
129             deco = defaulting(change_hook(primed(settings(docp(funcs)))))
130             if allowed != None:
131                 deco = checked(deco)
132             return Property(deco)
133         return decorator
134
135     @_versioned_property(name="severity",
136                          doc="A measure of the bug's importance",
137                          default="minor",
138                          allowed=severity_values)
139     def severity(): return {}
140
141     @_versioned_property(name="status",
142                          doc="The bug's current status",
143                          default="open",
144                          allowed=status_values)
145     def status(): return {}
146     
147     @property
148     def active(self):
149         return self.status in active_status_values
150
151     @_versioned_property(name="target",
152                          doc="The deadline for fixing this bug")
153     def target(): return {}
154
155     @_versioned_property(name="creator",
156                          doc="The user who entered the bug into the system")
157     def creator(): return {}
158
159     @_versioned_property(name="reporter",
160                          doc="The user who reported the bug")
161     def reporter(): return {}
162
163     @_versioned_property(name="assigned",
164                          doc="The developer in charge of the bug")
165     def assigned(): return {}
166
167     @_versioned_property(name="time",
168                          doc="An RFC 2822 timestamp for bug creation")
169     def time_string(): return {}
170
171     def _get_time(self):
172         if self.time_string == None:
173             return None
174         return utility.str_to_time(self.time_string)
175     def _set_time(self, value):
176         self.time_string = utility.time_to_str(value)
177     time = property(fget=_get_time,
178                     fset=_set_time,
179                     doc="An integere version of .time_string")
180
181     @_versioned_property(name="summary",
182                          doc="A one-line bug description")
183     def summary(): return {}
184
185     def _get_comment_root(self):
186         if self.sync_with_disk:
187             return comment.loadComments(self)
188         else:
189             return comment.Comment(self, uuid=comment.INVALID_UUID)
190
191     @Property
192     @cached_property(generator=_get_comment_root)
193     @local_property("comment_root")
194     @doc_property(doc="The trunk of the comment tree")
195     def comment_root(): return {}
196
197     def _get_rcs(self):
198         if hasattr(self.bugdir, "rcs"):
199             return self.bugdir.rcs
200
201     @Property
202     @cached_property(generator=_get_rcs)
203     @local_property("rcs")
204     @doc_property(doc="A revision control system instance.")
205     def rcs(): return {}
206
207     def __init__(self, bugdir=None, uuid=None, from_disk=False,
208                  load_comments=False, summary=None):
209         self.bugdir = bugdir
210         self.uuid = uuid
211         self._settings_loaded = False
212         self.settings = {}
213         if from_disk == True:
214             self.sync_with_disk = True
215             #self.load(load_comments=load_comments)
216         else:
217             self.sync_with_disk = False
218             if uuid == None:
219                 self.uuid = uuid_gen()
220             self.time = int(time.time()) # only save to second precision
221             if self.rcs != None:
222                 self.creator = self.rcs.get_user_id()
223             self.summary = summary
224
225     def __repr__(self):
226         return "Bug(uuid=%r)" % self.uuid
227
228     def string(self, shortlist=False, show_comments=False):
229         if self.bugdir == None:
230             shortname = self.uuid
231         else:
232             shortname = self.bugdir.bug_shortname(self)
233         if shortlist == False:
234             if self.time_string == "":
235                 timestring = self.time_string
236             else:
237                 htime = utility.handy_time(self.time)
238                 timestring = "%s (%s)" % (htime, self.time_string)
239             info = [("ID", self.uuid),
240                     ("Short name", shortname),
241                     ("Severity", self.severity),
242                     ("Status", self.status),
243                     ("Assigned", self.assigned or ""),
244                     ("Target", self.target or ""),
245                     ("Creator", self.creator or ""),
246                     ("Created", timestring)]
247             longest_key_len = max([len(k) for k,v in info])
248             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
249             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
250         else:
251             statuschar = self.status[0]
252             severitychar = self.severity[0]
253             chars = "%c%c" % (statuschar, severitychar)
254             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
255         
256         if show_comments == True:
257             # take advantage of the string_thread(auto_name_map=True)
258             # SIDE-EFFECT of sorting by comment time.
259             comout = self.comment_root.string_thread(flatten=False,
260                                                      auto_name_map=True,
261                                                      bug_shortname=shortname)
262             output = bugout + '\n' + comout.rstrip('\n')
263         else :
264             output = bugout
265         return output
266
267     def __str__(self):
268         return self.string(shortlist=True)
269
270     def __cmp__(self, other):
271         return cmp_full(self, other)
272
273     def get_path(self, name=None):
274         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
275         if name is None:
276             return my_dir
277         assert name in ["values", "comments"]
278         return os.path.join(my_dir, name)
279
280     def load_settings(self):
281         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
282         for property in self.settings_properties:
283             if property not in self.settings:
284                 self.settings[property] = EMPTY
285             elif self.settings[property] == None:
286                 self.settings[property] = EMPTY
287         self._settings_loaded = True
288
289     def load_comments(self):
290         # Clear _comment_root, so _get_comment_root returns a fresh
291         # version.  Turn of syncing temporarily so we don't write our
292         # blank comment tree to disk.
293         self.sync_with_disk = False
294         self._comment_root = None
295         self.sync_with_disk = True
296
297     def save_settings(self):
298         assert self.summary != None, "Can't save blank bug"
299         map = {}
300         for k,v in self.settings.items():
301             if (v != None and v != EMPTY):
302                 map[k] = v
303         for k in self.required_saved_properties:
304             map[k] = getattr(self, k)
305
306         self.rcs.mkdir(self.get_path())
307         path = self.get_path("values")
308         mapfile.map_save(self.rcs, path, map)
309         
310     def save(self):
311         self.save_settings()
312
313         if len(self.comment_root) > 0:
314             self.rcs.mkdir(self.get_path("comments"))
315             comment.saveComments(self)
316
317     def remove(self):
318         self.comment_root.remove()
319         path = self.get_path()
320         self.rcs.recursive_remove(path)
321     
322     def comments(self):
323         for comment in self.comment_root.traverse():
324             yield comment
325
326     def new_comment(self, body=None):
327         comm = self.comment_root.new_reply(body=body)
328         return comm
329
330     def comment_from_shortname(self, shortname, *args, **kwargs):
331         return self.comment_root.comment_from_shortname(shortname,
332                                                         *args, **kwargs)
333
334     def comment_from_uuid(self, uuid):
335         return self.comment_root.comment_from_uuid(uuid)
336
337     def comment_shortnames(self, shortname=None):
338         """
339         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
340         tree by comment.time
341         """
342         for id, comment in self.comment_root.comment_shortnames(shortname):
343             yield (id, comment)
344
345
346 # The general rule for bug sorting is that "more important" bugs are
347 # less than "less important" bugs.  This way sorting a list of bugs
348 # will put the most important bugs first in the list.  When relative
349 # importance is unclear, the sorting follows some arbitrary convention
350 # (i.e. dictionary order).
351
352 def cmp_severity(bug_1, bug_2):
353     """
354     Compare the severity levels of two bugs, with more severe bugs
355     comparing as less.
356     >>> bugA = Bug()
357     >>> bugB = Bug()
358     >>> bugA.severity = bugB.severity = "wishlist"
359     >>> cmp_severity(bugA, bugB) == 0
360     True
361     >>> bugB.severity = "minor"
362     >>> cmp_severity(bugA, bugB) > 0
363     True
364     >>> bugA.severity = "critical"
365     >>> cmp_severity(bugA, bugB) < 0
366     True
367     """
368     if not hasattr(bug_2, "severity") :
369         return 1
370     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
371
372 def cmp_status(bug_1, bug_2):
373     """
374     Compare the status levels of two bugs, with more 'open' bugs
375     comparing as less.
376     >>> bugA = Bug()
377     >>> bugB = Bug()
378     >>> bugA.status = bugB.status = "open"
379     >>> cmp_status(bugA, bugB) == 0
380     True
381     >>> bugB.status = "closed"
382     >>> cmp_status(bugA, bugB) < 0
383     True
384     >>> bugA.status = "fixed"
385     >>> cmp_status(bugA, bugB) > 0
386     True
387     """
388     if not hasattr(bug_2, "status") :
389         return 1
390     val_2 = status_index[bug_2.status]
391     return cmp(status_index[bug_1.status], status_index[bug_2.status])
392
393 def cmp_attr(bug_1, bug_2, attr, invert=False):
394     """
395     Compare a general attribute between two bugs using the conventional
396     comparison rule for that attribute type.  If invert == True, sort
397     *against* that convention.
398     >>> attr="severity"
399     >>> bugA = Bug()
400     >>> bugB = Bug()
401     >>> bugA.severity = "critical"
402     >>> bugB.severity = "wishlist"
403     >>> cmp_attr(bugA, bugB, attr) < 0
404     True
405     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
406     True
407     >>> bugB.severity = "critical"
408     >>> cmp_attr(bugA, bugB, attr) == 0
409     True
410     """
411     if not hasattr(bug_2, attr) :
412         return 1
413     if invert == True :
414         return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
415     else :
416         return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
417
418 # alphabetical rankings (a < z)
419 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
420 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
421 # chronological rankings (newer < older)
422 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
423
424 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
425                                      cmp_time,cmp_creator)):
426     for comparison in cmp_list :
427         val = comparison(bug_1, bug_2)
428         if val != 0 :
429             return val
430     return 0
431
432 class InvalidValue(ValueError):
433     def __init__(self, name, value):
434         msg = "Cannot assign value %s to %s" % (value, name)
435         Exception.__init__(self, msg)
436         self.name = name
437         self.value = value
438
439 suite = doctest.DocTestSuite()