Reported bug with utf-8 strings
[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
19 """
20 Define the Bug class for representing bugs.
21 """
22
23 import os
24 import os.path
25 import errno
26 import time
27 import types
28 import xml.sax.saxutils
29 import doctest
30
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
36 import mapfile
37 import comment
38 import utility
39
40
41 class DiskAccessRequired (Exception):
42     def __init__(self, goal):
43         msg = "Cannot %s without accessing the disk" % goal
44         Exception.__init__(self, msg)
45
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/
49
50 # in order of increasing severity.  (name, description) pairs
51 severity_def = (
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."))
57
58 # in order of increasing resolution
59 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
60 active_status_def = (
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."))
69
70
71 ### Convert the description tuples to more useful formats
72
73 severity_values = ()
74 severity_description = {}
75 severity_index = {}
76 def load_severities(severity_def):
77     global severity_values
78     global severity_description
79     global severity_index
80     if severity_def == None:
81         return
82     severity_values = tuple([val for val,description in severity_def])
83     severity_description = dict(severity_def)
84     severity_index = {}
85     for i,severity in enumerate(severity_values):
86         severity_index[severity] = i
87 load_severities(severity_def)
88
89 active_status_values = []
90 inactive_status_values = []
91 status_values = []
92 status_description = {}
93 status_index = {}
94 def load_status(active_status_def, inactive_status_def):
95     global active_status_values
96     global inactive_status_values
97     global status_values
98     global status_description
99     global status_index
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))
108     status_index = {}
109     for i,status in enumerate(status_values):
110         status_index[status] = i
111 load_status(active_status_def, inactive_status_def)
112
113
114 class Bug(settings_object.SavedSettingsObject):
115     """
116     >>> b = Bug()
117     >>> print b.status
118     open
119     >>> print b.severity
120     minor
121
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)
126     <type 'int'>
127     >>> print type(b.time_string)
128     <type 'str'>
129     >>> b.time = 0
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"
133     >>> b.time
134     60
135     >>> print b.settings["time"]
136     Thu, 01 Jan 1970 00:01:00 +0000
137     """
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,
144                             **kwargs):
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)
150
151     @_versioned_property(name="severity",
152                          doc="A measure of the bug's importance",
153                          default="minor",
154                          check_fn=lambda s: s in severity_values,
155                          require_save=True)
156     def severity(): return {}
157
158     @_versioned_property(name="status",
159                          doc="The bug's current status",
160                          default="open",
161                          check_fn=lambda s: s in status_values,
162                          require_save=True)
163     def status(): return {}
164     
165     @property
166     def active(self):
167         return self.status in active_status_values
168
169     @_versioned_property(name="target",
170                          doc="The deadline for fixing this bug")
171     def target(): return {}
172
173     @_versioned_property(name="creator",
174                          doc="The user who entered the bug into the system")
175     def creator(): return {}
176
177     @_versioned_property(name="reporter",
178                          doc="The user who reported the bug")
179     def reporter(): return {}
180
181     @_versioned_property(name="assigned",
182                          doc="The developer in charge of the bug")
183     def assigned(): return {}
184
185     @_versioned_property(name="time",
186                          doc="An RFC 2822 timestamp for bug creation")
187     def time_string(): return {}
188
189     def _get_time(self):
190         if self.time_string == None:
191             return 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,
196                     fset=_set_time,
197                     doc="An integer version of .time_string")
198
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.",
207                          default=[],
208                          check_fn=_extra_strings_check_fn,
209                          change_hook=_extra_strings_change_hook,
210                          mutable=True)
211     def extra_strings(): return {}
212
213     @_versioned_property(name="summary",
214                          doc="A one-line bug description")
215     def summary(): return {}
216
217     def _get_comment_root(self, load_full=False):
218         if self.sync_with_disk:
219             return comment.loadComments(self, load_full=load_full)
220         else:
221             return comment.Comment(self, uuid=comment.INVALID_UUID)
222
223     @Property
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 {}
228
229     def _get_vcs(self):
230         if hasattr(self.bugdir, "vcs"):
231             return self.bugdir.vcs
232
233     @Property
234     @cached_property(generator=_get_vcs)
235     @local_property("vcs")
236     @doc_property(doc="A revision control system instance.")
237     def vcs(): return {}
238
239     def __init__(self, bugdir=None, uuid=None, from_disk=False,
240                  load_comments=False, summary=None):
241         settings_object.SavedSettingsObject.__init__(self)
242         self.bugdir = bugdir
243         self.uuid = uuid
244         if from_disk == True:
245             self.sync_with_disk = True
246         else:
247             self.sync_with_disk = False
248             if uuid == None:
249                 self.uuid = uuid_gen()
250             self.time = int(time.time()) # only save to second precision
251             if self.vcs != None:
252                 self.creator = self.vcs.get_user_id()
253             self.summary = summary
254
255     def __repr__(self):
256         return "Bug(uuid=%r)" % self.uuid
257
258     def __str__(self):
259         return self.string(shortlist=True)
260
261     def __cmp__(self, other):
262         return cmp_full(self, other)
263
264     # serializing methods
265
266     def _setting_attr_string(self, setting):
267         value = getattr(self, setting)
268         if value == None:
269             return ""
270         return str(value)
271
272     def xml(self, show_comments=False):
273         if self.bugdir == None:
274             shortname = self.uuid
275         else:
276             shortname = self.bugdir.bug_shortname(self)
277
278         if self.time == None:
279             timestring = ""
280         else:
281             timestring = utility.time_to_str(self.time)
282
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)]
293         ret = '<bug>\n'
294         for (k,v) in info:
295             if v is not None:
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)
302             if len(comout) > 0:
303                 ret += comout+'\n'
304         ret += '</bug>'
305         return ret
306
307     def string(self, shortlist=False, show_comments=False):
308         if self.bugdir == None:
309             shortname = self.uuid
310         else:
311             shortname = self.bugdir.bug_shortname(self)
312         if shortlist == False:
313             if self.time == None:
314                 timestring = ""
315             else:
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')
330         else:
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'))
335         
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,
340                                                      auto_name_map=True,
341                                                      bug_shortname=shortname)
342             output = bugout + '\n' + comout.rstrip('\n')
343         else :
344             output = bugout
345         return output
346
347     # methods for saving/loading/acessing settings and properties.
348
349     def get_path(self, *args):
350         dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
351         if len(args) == 0:
352             return dir
353         assert args[0] in ["values", "comments"], str(args)
354         return os.path.join(dir, *args)
355
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)
360
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()
366
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())
374
375     def save(self):
376         """
377         Save any loaded contents to disk.  Because of lazy loading of
378         comments, this is actually not too inefficient.
379         
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).
384         """
385         sync_with_disk = self.sync_with_disk
386         if sync_with_disk == False:
387             self.set_sync_with_disk(True)
388         self.save_settings()
389         if len(self.comment_root) > 0:
390             comment.saveComments(self)
391         if sync_with_disk == False:
392             self.set_sync_with_disk(False)
393
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)
400         else:
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
404             # tree to disk.
405             self.sync_with_disk = False
406             self.comment_root = None
407             self.sync_with_disk = True
408
409     def remove(self):
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)
415     
416     # methods for managing comments
417
418     def comments(self):
419         for comment in self.comment_root.traverse():
420             yield comment
421
422     def new_comment(self, body=None):
423         comm = self.comment_root.new_reply(body=body)
424         return comm
425
426     def comment_from_shortname(self, shortname, *args, **kwargs):
427         return self.comment_root.comment_from_shortname(shortname,
428                                                         *args, **kwargs)
429
430     def comment_from_uuid(self, uuid):
431         return self.comment_root.comment_from_uuid(uuid)
432
433     def comment_shortnames(self, shortname=None):
434         """
435         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
436         tree by comment.time
437         """
438         for id, comment in self.comment_root.comment_shortnames(shortname):
439             yield (id, comment)
440
441
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).
447
448 def cmp_severity(bug_1, bug_2):
449     """
450     Compare the severity levels of two bugs, with more severe bugs
451     comparing as less.
452     >>> bugA = Bug()
453     >>> bugB = Bug()
454     >>> bugA.severity = bugB.severity = "wishlist"
455     >>> cmp_severity(bugA, bugB) == 0
456     True
457     >>> bugB.severity = "minor"
458     >>> cmp_severity(bugA, bugB) > 0
459     True
460     >>> bugA.severity = "critical"
461     >>> cmp_severity(bugA, bugB) < 0
462     True
463     """
464     if not hasattr(bug_2, "severity") :
465         return 1
466     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
467
468 def cmp_status(bug_1, bug_2):
469     """
470     Compare the status levels of two bugs, with more 'open' bugs
471     comparing as less.
472     >>> bugA = Bug()
473     >>> bugB = Bug()
474     >>> bugA.status = bugB.status = "open"
475     >>> cmp_status(bugA, bugB) == 0
476     True
477     >>> bugB.status = "closed"
478     >>> cmp_status(bugA, bugB) < 0
479     True
480     >>> bugA.status = "fixed"
481     >>> cmp_status(bugA, bugB) > 0
482     True
483     """
484     if not hasattr(bug_2, "status") :
485         return 1
486     val_2 = status_index[bug_2.status]
487     return cmp(status_index[bug_1.status], status_index[bug_2.status])
488
489 def cmp_attr(bug_1, bug_2, attr, invert=False):
490     """
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.
494     >>> attr="severity"
495     >>> bugA = Bug()
496     >>> bugB = Bug()
497     >>> bugA.severity = "critical"
498     >>> bugB.severity = "wishlist"
499     >>> cmp_attr(bugA, bugB, attr) < 0
500     True
501     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
502     True
503     >>> bugB.severity = "critical"
504     >>> cmp_attr(bugA, bugB, attr) == 0
505     True
506     """
507     if not hasattr(bug_2, attr) :
508         return 1
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
513     
514     if invert == True :
515         return -cmp(val_1, val_2)
516     else :
517         return cmp(val_1, val_2)
518
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)
528
529 def cmp_comments(bug_1, bug_2):
530     """
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
533     full comparison.
534     """
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))
538     if result != 0:
539         return result
540     for c_1,c_2 in zip(comms_1, comms_2):
541         result = cmp(c_1, c_2)
542         if result != 0:
543             return result
544     return 0
545
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)
549
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)
556             if val != 0 :
557                 return val
558         return 0
559         
560 cmp_full = BugCompoundComparator()
561
562
563 # define some bonus cmp_* functions
564 def cmp_last_modified(bug_1, bug_2):
565     """
566     Like cmp_time(), but use most recent comment instead of bug
567     creation for the timestamp.
568     """
569     def last_modified(bug):
570         time = bug.time
571         for comment in bug.comment_root.traverse():
572             if comment.time > time:
573                 time = comment.time
574         return time
575     val_1 = last_modified(bug_1)
576     val_2 = last_modified(bug_2)
577     return -cmp(val_1, val_2)
578
579
580 suite = doctest.DocTestSuite()