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