1 # Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
2 # Thomas Habets <thomas@habets.pp.se>
3 # W. Trevor King <wking@drexel.edu>
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.
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.
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.
20 Define the Bug class for representing bugs.
28 import xml.sax.saxutils
31 from beuuid import uuid_gen
32 from properties import Property, doc_property, local_property, \
33 defaulting_property, checked_property, cached_property, \
34 primed_property, change_hook_property, settings_property
35 import settings_object
41 class DiskAccessRequired (Exception):
42 def __init__(self, goal):
43 msg = "Cannot %s without accessing the disk" % goal
44 Exception.__init__(self, msg)
46 ### Define and describe valid bug categories
47 # Use a tuple of (category, description) tuples since we don't have
48 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
50 # in order of increasing severity. (name, description) pairs
52 ("wishlist","A feature that could improve usefulness, but not a bug."),
53 ("minor","The standard bug level."),
54 ("serious","A bug that requires workarounds."),
55 ("critical","A bug that prevents some features from working at all."),
56 ("fatal","A bug that makes the package unusable."))
58 # in order of increasing resolution
59 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
61 ("unconfirmed","A possible bug which lacks independent existance confirmation."),
62 ("open","A working bug that has not been assigned to a developer."),
63 ("assigned","A working bug that has been assigned to a developer."),
64 ("test","The code has been adjusted, but the fix is still being tested."))
65 inactive_status_def = (
66 ("closed", "The bug is no longer relevant."),
67 ("fixed", "The bug should no longer occur."),
68 ("wontfix","It's not a bug, it's a feature."))
71 ### Convert the description tuples to more useful formats
74 severity_description = {}
76 def load_severities(severity_def):
77 global severity_values
78 global severity_description
80 if severity_def == None:
82 severity_values = tuple([val for val,description in severity_def])
83 severity_description = dict(severity_def)
85 for i,severity in enumerate(severity_values):
86 severity_index[severity] = i
87 load_severities(severity_def)
89 active_status_values = []
90 inactive_status_values = []
92 status_description = {}
94 def load_status(active_status_def, inactive_status_def):
95 global active_status_values
96 global inactive_status_values
98 global status_description
100 if active_status_def == None:
101 active_status_def = globals()["active_status_def"]
102 if inactive_status_def == None:
103 inactive_status_def = globals()["inactive_status_def"]
104 active_status_values = tuple([val for val,description in active_status_def])
105 inactive_status_values = tuple([val for val,description in inactive_status_def])
106 status_values = active_status_values + inactive_status_values
107 status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
109 for i,status in enumerate(status_values):
110 status_index[status] = i
111 load_status(active_status_def, inactive_status_def)
114 class Bug(settings_object.SavedSettingsObject):
122 There are two formats for time, int and string. Setting either
123 one will adjust the other appropriately. The string form is the
124 one stored in the bug's settings file on disk.
125 >>> print type(b.time)
127 >>> print type(b.time_string)
130 >>> print b.time_string
131 Thu, 01 Jan 1970 00:00:00 +0000
132 >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
135 >>> print b.settings["time"]
136 Thu, 01 Jan 1970 00:01:00 +0000
138 settings_properties = []
139 required_saved_properties = []
140 _prop_save_settings = settings_object.prop_save_settings
141 _prop_load_settings = settings_object.prop_load_settings
142 def _versioned_property(settings_properties=settings_properties,
143 required_saved_properties=required_saved_properties,
145 if "settings_properties" not in kwargs:
146 kwargs["settings_properties"] = settings_properties
147 if "required_saved_properties" not in kwargs:
148 kwargs["required_saved_properties"]=required_saved_properties
149 return settings_object.versioned_property(**kwargs)
151 @_versioned_property(name="severity",
152 doc="A measure of the bug's importance",
154 check_fn=lambda s: s in severity_values,
156 def severity(): return {}
158 @_versioned_property(name="status",
159 doc="The bug's current status",
161 check_fn=lambda s: s in status_values,
163 def status(): return {}
167 return self.status in active_status_values
169 @_versioned_property(name="target",
170 doc="The deadline for fixing this bug")
171 def target(): return {}
173 @_versioned_property(name="creator",
174 doc="The user who entered the bug into the system")
175 def creator(): return {}
177 @_versioned_property(name="reporter",
178 doc="The user who reported the bug")
179 def reporter(): return {}
181 @_versioned_property(name="assigned",
182 doc="The developer in charge of the bug")
183 def assigned(): return {}
185 @_versioned_property(name="time",
186 doc="An RFC 2822 timestamp for bug creation")
187 def time_string(): return {}
190 if self.time_string == None:
192 return utility.str_to_time(self.time_string)
193 def _set_time(self, value):
194 self.time_string = utility.time_to_str(value)
195 time = property(fget=_get_time,
197 doc="An integer version of .time_string")
199 def _extra_strings_check_fn(value):
200 return utility.iterable_full_of_strings(value, \
201 alternative=settings_object.EMPTY)
202 def _extra_strings_change_hook(self, old, new):
203 self.extra_strings.sort() # to make merging easier
204 self._prop_save_settings(old, new)
205 @_versioned_property(name="extra_strings",
206 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
208 check_fn=_extra_strings_check_fn,
209 change_hook=_extra_strings_change_hook,
211 def extra_strings(): return {}
213 @_versioned_property(name="summary",
214 doc="A one-line bug description")
215 def summary(): return {}
217 def _get_comment_root(self, load_full=False):
218 if self.sync_with_disk:
219 return comment.loadComments(self, load_full=load_full)
221 return comment.Comment(self, uuid=comment.INVALID_UUID)
224 @cached_property(generator=_get_comment_root)
225 @local_property("comment_root")
226 @doc_property(doc="The trunk of the comment tree")
227 def comment_root(): return {}
230 if hasattr(self.bugdir, "vcs"):
231 return self.bugdir.vcs
234 @cached_property(generator=_get_vcs)
235 @local_property("vcs")
236 @doc_property(doc="A revision control system instance.")
239 def __init__(self, bugdir=None, uuid=None, from_disk=False,
240 load_comments=False, summary=None):
241 settings_object.SavedSettingsObject.__init__(self)
244 if from_disk == True:
245 self.sync_with_disk = True
247 self.sync_with_disk = False
249 self.uuid = uuid_gen()
250 self.time = int(time.time()) # only save to second precision
252 self.creator = self.vcs.get_user_id()
253 self.summary = summary
256 return "Bug(uuid=%r)" % self.uuid
259 return self.string(shortlist=True)
261 def __cmp__(self, other):
262 return cmp_full(self, other)
264 # serializing methods
266 def _setting_attr_string(self, setting):
267 value = getattr(self, setting)
272 def xml(self, show_comments=False):
273 if self.bugdir == None:
274 shortname = self.uuid
276 shortname = self.bugdir.bug_shortname(self)
278 if self.time == None:
281 timestring = utility.time_to_str(self.time)
283 info = [("uuid", self.uuid),
284 ("short-name", shortname),
285 ("severity", self.severity),
286 ("status", self.status),
287 ("assigned", self.assigned),
288 ("target", self.target),
289 ("reporter", self.reporter),
290 ("creator", self.creator),
291 ("created", timestring),
292 ("summary", self.summary)]
296 ret += ' <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k)
297 for estr in self.extra_strings:
298 ret += ' <extra-string>%s</extra-string>\n' % estr
299 if show_comments == True:
300 comout = self.comment_root.xml_thread(auto_name_map=True,
301 bug_shortname=shortname)
307 def string(self, shortlist=False, show_comments=False):
308 if self.bugdir == None:
309 shortname = self.uuid
311 shortname = self.bugdir.bug_shortname(self)
312 if shortlist == False:
313 if self.time == None:
316 htime = utility.handy_time(self.time)
317 timestring = "%s (%s)" % (htime, self.time_string)
318 info = [("ID", self.uuid),
319 ("Short name", shortname),
320 ("Severity", self.severity),
321 ("Status", self.status),
322 ("Assigned", self._setting_attr_string("assigned")),
323 ("Target", self._setting_attr_string("target")),
324 ("Reporter", self._setting_attr_string("reporter")),
325 ("Creator", self._setting_attr_string("creator")),
326 ("Created", timestring)]
327 longest_key_len = max([len(k) for k,v in info])
328 infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
329 bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
331 statuschar = self.status[0]
332 severitychar = self.severity[0]
333 chars = "%c%c" % (statuschar, severitychar)
334 bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
336 if show_comments == True:
337 # take advantage of the string_thread(auto_name_map=True)
338 # SIDE-EFFECT of sorting by comment time.
339 comout = self.comment_root.string_thread(flatten=False,
341 bug_shortname=shortname)
342 output = bugout + '\n' + comout.rstrip('\n')
347 # methods for saving/loading/acessing settings and properties.
349 def get_path(self, *args):
350 dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
353 assert args[0] in ["values", "comments"], str(args)
354 return os.path.join(dir, *args)
356 def set_sync_with_disk(self, value):
357 self.sync_with_disk = value
358 for comment in self.comments():
359 comment.set_sync_with_disk(value)
361 def load_settings(self):
362 if self.sync_with_disk == False:
363 raise DiskAccessRequired("load settings")
364 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
365 self._setup_saved_settings()
367 def save_settings(self):
368 if self.sync_with_disk == False:
369 raise DiskAccessRequired("save settings")
370 assert self.summary != None, "Can't save blank bug"
371 self.vcs.mkdir(self.get_path())
372 path = self.get_path("values")
373 mapfile.map_save(self.vcs, path, self._get_saved_settings())
377 Save any loaded contents to disk. Because of lazy loading of
378 comments, this is actually not too inefficient.
380 However, if self.sync_with_disk = True, then any changes are
381 automatically written to disk as soon as they happen, so
382 calling this method will just waste time (unless something
383 else has been messing with your on-disk files).
385 sync_with_disk = self.sync_with_disk
386 if sync_with_disk == False:
387 self.set_sync_with_disk(True)
389 if len(self.comment_root) > 0:
390 comment.saveComments(self)
391 if sync_with_disk == False:
392 self.set_sync_with_disk(False)
394 def load_comments(self, load_full=True):
395 if self.sync_with_disk == False:
396 raise DiskAccessRequired("load comments")
397 if load_full == True:
398 # Force a complete load of the whole comment tree
399 self.comment_root = self._get_comment_root(load_full=True)
401 # Setup for fresh lazy-loading. Clear _comment_root, so
402 # _get_comment_root returns a fresh version. Turn of
403 # syncing temporarily so we don't write our blank comment
405 self.sync_with_disk = False
406 self.comment_root = None
407 self.sync_with_disk = True
410 if self.sync_with_disk == False:
411 raise DiskAccessRequired("remove")
412 self.comment_root.remove()
413 path = self.get_path()
414 self.vcs.recursive_remove(path)
416 # methods for managing comments
419 for comment in self.comment_root.traverse():
422 def new_comment(self, body=None):
423 comm = self.comment_root.new_reply(body=body)
426 def comment_from_shortname(self, shortname, *args, **kwargs):
427 return self.comment_root.comment_from_shortname(shortname,
430 def comment_from_uuid(self, uuid):
431 return self.comment_root.comment_from_uuid(uuid)
433 def comment_shortnames(self, shortname=None):
435 SIDE-EFFECT : Comment.comment_shortnames will sort the comment
438 for id, comment in self.comment_root.comment_shortnames(shortname):
442 # The general rule for bug sorting is that "more important" bugs are
443 # less than "less important" bugs. This way sorting a list of bugs
444 # will put the most important bugs first in the list. When relative
445 # importance is unclear, the sorting follows some arbitrary convention
446 # (i.e. dictionary order).
448 def cmp_severity(bug_1, bug_2):
450 Compare the severity levels of two bugs, with more severe bugs
454 >>> bugA.severity = bugB.severity = "wishlist"
455 >>> cmp_severity(bugA, bugB) == 0
457 >>> bugB.severity = "minor"
458 >>> cmp_severity(bugA, bugB) > 0
460 >>> bugA.severity = "critical"
461 >>> cmp_severity(bugA, bugB) < 0
464 if not hasattr(bug_2, "severity") :
466 return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
468 def cmp_status(bug_1, bug_2):
470 Compare the status levels of two bugs, with more 'open' bugs
474 >>> bugA.status = bugB.status = "open"
475 >>> cmp_status(bugA, bugB) == 0
477 >>> bugB.status = "closed"
478 >>> cmp_status(bugA, bugB) < 0
480 >>> bugA.status = "fixed"
481 >>> cmp_status(bugA, bugB) > 0
484 if not hasattr(bug_2, "status") :
486 val_2 = status_index[bug_2.status]
487 return cmp(status_index[bug_1.status], status_index[bug_2.status])
489 def cmp_attr(bug_1, bug_2, attr, invert=False):
491 Compare a general attribute between two bugs using the conventional
492 comparison rule for that attribute type. If invert == True, sort
493 *against* that convention.
497 >>> bugA.severity = "critical"
498 >>> bugB.severity = "wishlist"
499 >>> cmp_attr(bugA, bugB, attr) < 0
501 >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
503 >>> bugB.severity = "critical"
504 >>> cmp_attr(bugA, bugB, attr) == 0
507 if not hasattr(bug_2, attr) :
509 val_1 = getattr(bug_1, attr)
510 val_2 = getattr(bug_2, attr)
511 if val_1 == None: val_1 = None
512 if val_2 == None: val_2 = None
515 return -cmp(val_1, val_2)
517 return cmp(val_1, val_2)
519 # alphabetical rankings (a < z)
520 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
521 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
522 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
523 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
524 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
525 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
526 # chronological rankings (newer < older)
527 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
529 def cmp_comments(bug_1, bug_2):
531 Compare two bugs' comments lists. Doesn't load any new comments,
532 so you should call each bug's .load_comments() first if you want a
535 comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
536 comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
537 result = cmp(len(comms_1), len(comms_2))
540 for c_1,c_2 in zip(comms_1, comms_2):
541 result = cmp(c_1, c_2)
546 DEFAULT_CMP_FULL_CMP_LIST = \
547 (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
548 cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
550 class BugCompoundComparator (object):
551 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
552 self.cmp_list = cmp_list
553 def __call__(self, bug_1, bug_2):
554 for comparison in self.cmp_list :
555 val = comparison(bug_1, bug_2)
560 cmp_full = BugCompoundComparator()
563 # define some bonus cmp_* functions
564 def cmp_last_modified(bug_1, bug_2):
566 Like cmp_time(), but use most recent comment instead of bug
567 creation for the timestamp.
569 def last_modified(bug):
571 for comment in bug.comment_root.traverse():
572 if comment.time > time:
575 val_1 = last_modified(bug_1)
576 val_2 = last_modified(bug_2)
577 return -cmp(val_1, val_2)
580 suite = doctest.DocTestSuite()