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.
23 import xml.sax.saxutils
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
36 class DiskAccessRequired (Exception):
37 def __init__(self, goal):
38 msg = "Cannot %s without accessing the disk" % goal
39 Exception.__init__(self, msg)
41 ### Define and describe valid bug categories
42 # Use a tuple of (category, description) tuples since we don't have
43 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
45 # in order of increasing severity. (name, description) pairs
47 ("wishlist","A feature that could improve usefulness, but not a bug."),
48 ("minor","The standard bug level."),
49 ("serious","A bug that requires workarounds."),
50 ("critical","A bug that prevents some features from working at all."),
51 ("fatal","A bug that makes the package unusable."))
53 # in order of increasing resolution
54 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
56 ("unconfirmed","A possible bug which lacks independent existance confirmation."),
57 ("open","A working bug that has not been assigned to a developer."),
58 ("assigned","A working bug that has been assigned to a developer."),
59 ("test","The code has been adjusted, but the fix is still being tested."))
60 inactive_status_def = (
61 ("closed", "The bug is no longer relevant."),
62 ("fixed", "The bug should no longer occur."),
63 ("wontfix","It's not a bug, it's a feature."))
66 ### Convert the description tuples to more useful formats
69 severity_description = {}
71 def load_severities(severity_def):
72 global severity_values
73 global severity_description
75 if severity_def == None:
77 severity_values = tuple([val for val,description in severity_def])
78 severity_description = dict(severity_def)
80 for i,severity in enumerate(severity_values):
81 severity_index[severity] = i
82 load_severities(severity_def)
84 active_status_values = []
85 inactive_status_values = []
87 status_description = {}
89 def load_status(active_status_def, inactive_status_def):
90 global active_status_values
91 global inactive_status_values
93 global status_description
95 if active_status_def == None:
96 active_status_def = globals()["active_status_def"]
97 if inactive_status_def == None:
98 inactive_status_def = globals()["inactive_status_def"]
99 active_status_values = tuple([val for val,description in active_status_def])
100 inactive_status_values = tuple([val for val,description in inactive_status_def])
101 status_values = active_status_values + inactive_status_values
102 status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
104 for i,status in enumerate(status_values):
105 status_index[status] = i
106 load_status(active_status_def, inactive_status_def)
109 class Bug(settings_object.SavedSettingsObject):
117 There are two formats for time, int and string. Setting either
118 one will adjust the other appropriately. The string form is the
119 one stored in the bug's settings file on disk.
120 >>> print type(b.time)
122 >>> print type(b.time_string)
125 >>> print b.time_string
126 Thu, 01 Jan 1970 00:00:00 +0000
127 >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
130 >>> print b.settings["time"]
131 Thu, 01 Jan 1970 00:01:00 +0000
133 settings_properties = []
134 required_saved_properties = []
135 _prop_save_settings = settings_object.prop_save_settings
136 _prop_load_settings = settings_object.prop_load_settings
137 def _versioned_property(settings_properties=settings_properties,
138 required_saved_properties=required_saved_properties,
140 if "settings_properties" not in kwargs:
141 kwargs["settings_properties"] = settings_properties
142 if "required_saved_properties" not in kwargs:
143 kwargs["required_saved_properties"]=required_saved_properties
144 return settings_object.versioned_property(**kwargs)
146 @_versioned_property(name="severity",
147 doc="A measure of the bug's importance",
149 check_fn=lambda s: s in severity_values,
151 def severity(): return {}
153 @_versioned_property(name="status",
154 doc="The bug's current status",
156 check_fn=lambda s: s in status_values,
158 def status(): return {}
162 return self.status in active_status_values
164 @_versioned_property(name="target",
165 doc="The deadline for fixing this bug")
166 def target(): return {}
168 @_versioned_property(name="creator",
169 doc="The user who entered the bug into the system")
170 def creator(): return {}
172 @_versioned_property(name="reporter",
173 doc="The user who reported the bug")
174 def reporter(): return {}
176 @_versioned_property(name="assigned",
177 doc="The developer in charge of the bug")
178 def assigned(): return {}
180 @_versioned_property(name="time",
181 doc="An RFC 2822 timestamp for bug creation")
182 def time_string(): return {}
185 if self.time_string == None:
187 return utility.str_to_time(self.time_string)
188 def _set_time(self, value):
189 self.time_string = utility.time_to_str(value)
190 time = property(fget=_get_time,
192 doc="An integer version of .time_string")
194 def _extra_strings_check_fn(value):
195 return utility.iterable_full_of_strings(value, \
196 alternative=settings_object.EMPTY)
197 def _extra_strings_change_hook(self, old, new):
198 self.extra_strings.sort() # to make merging easier
199 self._prop_save_settings(old, new)
200 @_versioned_property(name="extra_strings",
201 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
203 check_fn=_extra_strings_check_fn,
204 change_hook=_extra_strings_change_hook,
206 def extra_strings(): return {}
208 @_versioned_property(name="summary",
209 doc="A one-line bug description")
210 def summary(): return {}
212 def _get_comment_root(self, load_full=False):
213 if self.sync_with_disk:
214 return comment.loadComments(self, load_full=load_full)
216 return comment.Comment(self, uuid=comment.INVALID_UUID)
219 @cached_property(generator=_get_comment_root)
220 @local_property("comment_root")
221 @doc_property(doc="The trunk of the comment tree")
222 def comment_root(): return {}
225 if hasattr(self.bugdir, "vcs"):
226 return self.bugdir.vcs
229 @cached_property(generator=_get_vcs)
230 @local_property("vcs")
231 @doc_property(doc="A revision control system instance.")
234 def __init__(self, bugdir=None, uuid=None, from_disk=False,
235 load_comments=False, summary=None):
236 settings_object.SavedSettingsObject.__init__(self)
239 if from_disk == True:
240 self.sync_with_disk = True
242 self.sync_with_disk = False
244 self.uuid = uuid_gen()
245 self.time = int(time.time()) # only save to second precision
247 self.creator = self.vcs.get_user_id()
248 self.summary = summary
251 return "Bug(uuid=%r)" % self.uuid
254 return self.string(shortlist=True)
256 def __cmp__(self, other):
257 return cmp_full(self, other)
259 # serializing methods
261 def _setting_attr_string(self, setting):
262 value = getattr(self, setting)
267 def xml(self, show_comments=False):
268 if self.bugdir == None:
269 shortname = self.uuid
271 shortname = self.bugdir.bug_shortname(self)
273 if self.time == None:
276 timestring = utility.time_to_str(self.time)
278 info = [("uuid", self.uuid),
279 ("short-name", shortname),
280 ("severity", self.severity),
281 ("status", self.status),
282 ("assigned", self.assigned),
283 ("target", self.target),
284 ("reporter", self.reporter),
285 ("creator", self.creator),
286 ("created", timestring),
287 ("summary", self.summary)]
291 ret += ' <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k)
292 for estr in self.extra_strings:
293 ret += ' <extra-string>%s</extra-string>\n' % estr
294 if show_comments == True:
295 comout = self.comment_root.xml_thread(auto_name_map=True,
296 bug_shortname=shortname)
302 def string(self, shortlist=False, show_comments=False):
303 if self.bugdir == None:
304 shortname = self.uuid
306 shortname = self.bugdir.bug_shortname(self)
307 if shortlist == False:
308 if self.time == None:
311 htime = utility.handy_time(self.time)
312 timestring = "%s (%s)" % (htime, self.time_string)
313 info = [("ID", self.uuid),
314 ("Short name", shortname),
315 ("Severity", self.severity),
316 ("Status", self.status),
317 ("Assigned", self._setting_attr_string("assigned")),
318 ("Target", self._setting_attr_string("target")),
319 ("Reporter", self._setting_attr_string("reporter")),
320 ("Creator", self._setting_attr_string("creator")),
321 ("Created", timestring)]
322 longest_key_len = max([len(k) for k,v in info])
323 infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
324 bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
326 statuschar = self.status[0]
327 severitychar = self.severity[0]
328 chars = "%c%c" % (statuschar, severitychar)
329 bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
331 if show_comments == True:
332 # take advantage of the string_thread(auto_name_map=True)
333 # SIDE-EFFECT of sorting by comment time.
334 comout = self.comment_root.string_thread(flatten=False,
336 bug_shortname=shortname)
337 output = bugout + '\n' + comout.rstrip('\n')
342 # methods for saving/loading/acessing settings and properties.
344 def get_path(self, *args):
345 dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
348 assert args[0] in ["values", "comments"], str(args)
349 return os.path.join(dir, *args)
351 def set_sync_with_disk(self, value):
352 self.sync_with_disk = value
353 for comment in self.comments():
354 comment.set_sync_with_disk(value)
356 def load_settings(self):
357 if self.sync_with_disk == False:
358 raise DiskAccessRequired("load settings")
359 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
360 self._setup_saved_settings()
362 def save_settings(self):
363 if self.sync_with_disk == False:
364 raise DiskAccessRequired("save settings")
365 assert self.summary != None, "Can't save blank bug"
366 self.vcs.mkdir(self.get_path())
367 path = self.get_path("values")
368 mapfile.map_save(self.vcs, path, self._get_saved_settings())
372 Save any loaded contents to disk. Because of lazy loading of
373 comments, this is actually not too inefficient.
375 However, if self.sync_with_disk = True, then any changes are
376 automatically written to disk as soon as they happen, so
377 calling this method will just waste time (unless something
378 else has been messing with your on-disk files).
380 sync_with_disk = self.sync_with_disk
381 if sync_with_disk == False:
382 self.set_sync_with_disk(True)
384 if len(self.comment_root) > 0:
385 comment.saveComments(self)
386 if sync_with_disk == False:
387 self.set_sync_with_disk(False)
389 def load_comments(self, load_full=True):
390 if self.sync_with_disk == False:
391 raise DiskAccessRequired("load comments")
392 if load_full == True:
393 # Force a complete load of the whole comment tree
394 self.comment_root = self._get_comment_root(load_full=True)
396 # Setup for fresh lazy-loading. Clear _comment_root, so
397 # _get_comment_root returns a fresh version. Turn of
398 # syncing temporarily so we don't write our blank comment
400 self.sync_with_disk = False
401 self.comment_root = None
402 self.sync_with_disk = True
405 if self.sync_with_disk == False:
406 raise DiskAccessRequired("remove")
407 self.comment_root.remove()
408 path = self.get_path()
409 self.vcs.recursive_remove(path)
411 # methods for managing comments
414 for comment in self.comment_root.traverse():
417 def new_comment(self, body=None):
418 comm = self.comment_root.new_reply(body=body)
421 def comment_from_shortname(self, shortname, *args, **kwargs):
422 return self.comment_root.comment_from_shortname(shortname,
425 def comment_from_uuid(self, uuid):
426 return self.comment_root.comment_from_uuid(uuid)
428 def comment_shortnames(self, shortname=None):
430 SIDE-EFFECT : Comment.comment_shortnames will sort the comment
433 for id, comment in self.comment_root.comment_shortnames(shortname):
437 # The general rule for bug sorting is that "more important" bugs are
438 # less than "less important" bugs. This way sorting a list of bugs
439 # will put the most important bugs first in the list. When relative
440 # importance is unclear, the sorting follows some arbitrary convention
441 # (i.e. dictionary order).
443 def cmp_severity(bug_1, bug_2):
445 Compare the severity levels of two bugs, with more severe bugs
449 >>> bugA.severity = bugB.severity = "wishlist"
450 >>> cmp_severity(bugA, bugB) == 0
452 >>> bugB.severity = "minor"
453 >>> cmp_severity(bugA, bugB) > 0
455 >>> bugA.severity = "critical"
456 >>> cmp_severity(bugA, bugB) < 0
459 if not hasattr(bug_2, "severity") :
461 return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
463 def cmp_status(bug_1, bug_2):
465 Compare the status levels of two bugs, with more 'open' bugs
469 >>> bugA.status = bugB.status = "open"
470 >>> cmp_status(bugA, bugB) == 0
472 >>> bugB.status = "closed"
473 >>> cmp_status(bugA, bugB) < 0
475 >>> bugA.status = "fixed"
476 >>> cmp_status(bugA, bugB) > 0
479 if not hasattr(bug_2, "status") :
481 val_2 = status_index[bug_2.status]
482 return cmp(status_index[bug_1.status], status_index[bug_2.status])
484 def cmp_attr(bug_1, bug_2, attr, invert=False):
486 Compare a general attribute between two bugs using the conventional
487 comparison rule for that attribute type. If invert == True, sort
488 *against* that convention.
492 >>> bugA.severity = "critical"
493 >>> bugB.severity = "wishlist"
494 >>> cmp_attr(bugA, bugB, attr) < 0
496 >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
498 >>> bugB.severity = "critical"
499 >>> cmp_attr(bugA, bugB, attr) == 0
502 if not hasattr(bug_2, attr) :
504 val_1 = getattr(bug_1, attr)
505 val_2 = getattr(bug_2, attr)
506 if val_1 == None: val_1 = None
507 if val_2 == None: val_2 = None
510 return -cmp(val_1, val_2)
512 return cmp(val_1, val_2)
514 # alphabetical rankings (a < z)
515 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
516 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
517 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
518 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
519 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
520 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
521 # chronological rankings (newer < older)
522 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
524 def cmp_comments(bug_1, bug_2):
526 Compare two bugs' comments lists. Doesn't load any new comments,
527 so you should call each bug's .load_comments() first if you want a
530 comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
531 comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
532 result = cmp(len(comms_1), len(comms_2))
535 for c_1,c_2 in zip(comms_1, comms_2):
536 result = cmp(c_1, c_2)
541 DEFAULT_CMP_FULL_CMP_LIST = \
542 (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
543 cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
545 class BugCompoundComparator (object):
546 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
547 self.cmp_list = cmp_list
548 def __call__(self, bug_1, bug_2):
549 for comparison in self.cmp_list :
550 val = comparison(bug_1, bug_2)
555 cmp_full = BugCompoundComparator()
558 # define some bonus cmp_* functions
559 def cmp_last_modified(bug_1, bug_2):
561 Like cmp_time(), but use most recent comment instead of bug
562 creation for the timestamp.
564 def last_modified(bug):
566 for comment in bug.comment_root.traverse():
567 if comment.time > time:
570 val_1 = last_modified(bug_1)
571 val_2 = last_modified(bug_2)
572 return -cmp(val_1, val_2)
575 suite = doctest.DocTestSuite()