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