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