Added decorator-style properties to libbe/comment.py.
[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 integer 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, load_full=False):
186         if self.sync_with_disk:
187             return comment.loadComments(self, load_full=load_full)
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         else:
216             self.sync_with_disk = False
217             if uuid == None:
218                 self.uuid = uuid_gen()
219             self.time = int(time.time()) # only save to second precision
220             if self.rcs != None:
221                 self.creator = self.rcs.get_user_id()
222             self.summary = summary
223
224     def __repr__(self):
225         return "Bug(uuid=%r)" % self.uuid
226
227     def string(self, shortlist=False, show_comments=False):
228         if self.bugdir == None:
229             shortname = self.uuid
230         else:
231             shortname = self.bugdir.bug_shortname(self)
232         if shortlist == False:
233             if self.time_string == "":
234                 timestring = self.time_string
235             else:
236                 htime = utility.handy_time(self.time)
237                 timestring = "%s (%s)" % (htime, self.time_string)
238             info = [("ID", self.uuid),
239                     ("Short name", shortname),
240                     ("Severity", self.severity),
241                     ("Status", self.status),
242                     ("Assigned", self.assigned or ""),
243                     ("Target", self.target or ""),
244                     ("Creator", self.creator or ""),
245                     ("Created", timestring)]
246             longest_key_len = max([len(k) for k,v in info])
247             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
248             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
249         else:
250             statuschar = self.status[0]
251             severitychar = self.severity[0]
252             chars = "%c%c" % (statuschar, severitychar)
253             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
254         
255         if show_comments == True:
256             # take advantage of the string_thread(auto_name_map=True)
257             # SIDE-EFFECT of sorting by comment time.
258             comout = self.comment_root.string_thread(flatten=False,
259                                                      auto_name_map=True,
260                                                      bug_shortname=shortname)
261             output = bugout + '\n' + comout.rstrip('\n')
262         else :
263             output = bugout
264         return output
265
266     def __str__(self):
267         return self.string(shortlist=True)
268
269     def __cmp__(self, other):
270         return cmp_full(self, other)
271
272     def get_path(self, name=None):
273         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
274         if name is None:
275             return my_dir
276         assert name in ["values", "comments"]
277         return os.path.join(my_dir, name)
278
279     def load_settings(self):
280         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
281         for property in self.settings_properties:
282             if property not in self.settings:
283                 self.settings[property] = EMPTY
284             elif self.settings[property] == None:
285                 self.settings[property] = EMPTY
286         self._settings_loaded = True
287
288     def load_comments(self, load_full=True):
289         if load_full == True:
290             # Force a complete load of the whole comment tree
291             self.comment_root = self._get_comment_root(load_full=True)
292         else:
293             # Setup for fresh lazy-loading.  Clear _comment_root, so
294             # _get_comment_root returns a fresh version.  Turn of
295             # syncing temporarily so we don't write our blank comment
296             # tree to disk.
297             self.sync_with_disk = False
298             self.comment_root = None
299             self.sync_with_disk = True
300
301     def save_settings(self):
302         assert self.summary != None, "Can't save blank bug"
303         map = {}
304         for k,v in self.settings.items():
305             if (v != None and v != EMPTY):
306                 map[k] = v
307         for k in self.required_saved_properties:
308             map[k] = getattr(self, k)
309
310         self.rcs.mkdir(self.get_path())
311         path = self.get_path("values")
312         mapfile.map_save(self.rcs, path, map)
313         
314     def save(self):
315         self.save_settings()
316
317         if len(self.comment_root) > 0:
318             self.rcs.mkdir(self.get_path("comments"))
319             comment.saveComments(self)
320
321     def remove(self):
322         self.comment_root.remove()
323         path = self.get_path()
324         self.rcs.recursive_remove(path)
325     
326     def comments(self):
327         for comment in self.comment_root.traverse():
328             yield comment
329
330     def new_comment(self, body=None):
331         comm = self.comment_root.new_reply(body=body)
332         return comm
333
334     def comment_from_shortname(self, shortname, *args, **kwargs):
335         return self.comment_root.comment_from_shortname(shortname,
336                                                         *args, **kwargs)
337
338     def comment_from_uuid(self, uuid):
339         return self.comment_root.comment_from_uuid(uuid)
340
341     def comment_shortnames(self, shortname=None):
342         """
343         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
344         tree by comment.time
345         """
346         for id, comment in self.comment_root.comment_shortnames(shortname):
347             yield (id, comment)
348
349
350 # The general rule for bug sorting is that "more important" bugs are
351 # less than "less important" bugs.  This way sorting a list of bugs
352 # will put the most important bugs first in the list.  When relative
353 # importance is unclear, the sorting follows some arbitrary convention
354 # (i.e. dictionary order).
355
356 def cmp_severity(bug_1, bug_2):
357     """
358     Compare the severity levels of two bugs, with more severe bugs
359     comparing as less.
360     >>> bugA = Bug()
361     >>> bugB = Bug()
362     >>> bugA.severity = bugB.severity = "wishlist"
363     >>> cmp_severity(bugA, bugB) == 0
364     True
365     >>> bugB.severity = "minor"
366     >>> cmp_severity(bugA, bugB) > 0
367     True
368     >>> bugA.severity = "critical"
369     >>> cmp_severity(bugA, bugB) < 0
370     True
371     """
372     if not hasattr(bug_2, "severity") :
373         return 1
374     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
375
376 def cmp_status(bug_1, bug_2):
377     """
378     Compare the status levels of two bugs, with more 'open' bugs
379     comparing as less.
380     >>> bugA = Bug()
381     >>> bugB = Bug()
382     >>> bugA.status = bugB.status = "open"
383     >>> cmp_status(bugA, bugB) == 0
384     True
385     >>> bugB.status = "closed"
386     >>> cmp_status(bugA, bugB) < 0
387     True
388     >>> bugA.status = "fixed"
389     >>> cmp_status(bugA, bugB) > 0
390     True
391     """
392     if not hasattr(bug_2, "status") :
393         return 1
394     val_2 = status_index[bug_2.status]
395     return cmp(status_index[bug_1.status], status_index[bug_2.status])
396
397 def cmp_attr(bug_1, bug_2, attr, invert=False):
398     """
399     Compare a general attribute between two bugs using the conventional
400     comparison rule for that attribute type.  If invert == True, sort
401     *against* that convention.
402     >>> attr="severity"
403     >>> bugA = Bug()
404     >>> bugB = Bug()
405     >>> bugA.severity = "critical"
406     >>> bugB.severity = "wishlist"
407     >>> cmp_attr(bugA, bugB, attr) < 0
408     True
409     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
410     True
411     >>> bugB.severity = "critical"
412     >>> cmp_attr(bugA, bugB, attr) == 0
413     True
414     """
415     if not hasattr(bug_2, attr) :
416         return 1
417     if invert == True :
418         return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
419     else :
420         return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
421
422 # alphabetical rankings (a < z)
423 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
424 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
425 # chronological rankings (newer < older)
426 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
427
428 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
429                                      cmp_time,cmp_creator)):
430     for comparison in cmp_list :
431         val = comparison(bug_1, bug_2)
432         if val != 0 :
433             return val
434     return 0
435
436 class InvalidValue(ValueError):
437     def __init__(self, name, value):
438         msg = "Cannot assign value %s to %s" % (value, name)
439         Exception.__init__(self, msg)
440         self.name = name
441         self.value = value
442
443 suite = doctest.DocTestSuite()