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