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