Added cmp functions to libbe.comment, and fleshed them out in libbe.bug.
[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 class DiskAccessRequired (Exception):
37     def __init__(self, goal):
38         msg = "Cannot %s without accessing the disk" % goal
39         Exception.__init__(self, msg)
40
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/
44
45 # in order of increasing severity.  (name, description) pairs
46 severity_def = (
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."))
52
53 # in order of increasing resolution
54 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
55 active_status_def = (
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."))
64
65
66 ### Convert the description tuples to more useful formats
67
68 severity_values = ()
69 severity_description = {}
70 severity_index = {}
71 def load_severities(severity_def):
72     global severity_values
73     global severity_description
74     global severity_index
75     if severity_def == None:
76         return
77     severity_values = tuple([val for val,description in severity_def])
78     severity_description = dict(severity_def)
79     severity_index = {}
80     for i,severity in enumerate(severity_values):
81         severity_index[severity] = i
82 load_severities(severity_def)
83
84 active_status_values = []
85 inactive_status_values = []
86 status_values = []
87 status_description = {}
88 status_index = {}
89 def load_status(active_status_def, inactive_status_def):
90     global active_status_values
91     global inactive_status_values
92     global status_values
93     global status_description
94     global status_index
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))
103     status_index = {}
104     for i,status in enumerate(status_values):
105         status_index[status] = i
106 load_status(active_status_def, inactive_status_def)
107
108
109 class Bug(settings_object.SavedSettingsObject):
110     """
111     >>> b = Bug()
112     >>> print b.status
113     open
114     >>> print b.severity
115     minor
116
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)
121     <type 'int'>
122     >>> print type(b.time_string)
123     <type 'str'>
124     >>> b.time = 0
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"
128     >>> b.time
129     60
130     >>> print b.settings["time"]
131     Thu, 01 Jan 1970 00:01:00 +0000
132     """
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,
139                             **kwargs):
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)
145
146     @_versioned_property(name="severity",
147                          doc="A measure of the bug's importance",
148                          default="minor",
149                          check_fn=lambda s: s in severity_values,
150                          require_save=True)
151     def severity(): return {}
152
153     @_versioned_property(name="status",
154                          doc="The bug's current status",
155                          default="open",
156                          check_fn=lambda s: s in status_values,
157                          require_save=True)
158     def status(): return {}
159     
160     @property
161     def active(self):
162         return self.status in active_status_values
163
164     @_versioned_property(name="target",
165                          doc="The deadline for fixing this bug")
166     def target(): return {}
167
168     @_versioned_property(name="creator",
169                          doc="The user who entered the bug into the system")
170     def creator(): return {}
171
172     @_versioned_property(name="reporter",
173                          doc="The user who reported the bug")
174     def reporter(): return {}
175
176     @_versioned_property(name="assigned",
177                          doc="The developer in charge of the bug")
178     def assigned(): return {}
179
180     @_versioned_property(name="time",
181                          doc="An RFC 2822 timestamp for bug creation")
182     def time_string(): return {}
183
184     def _get_time(self):
185         if self.time_string == None:
186             return 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,
191                     fset=_set_time,
192                     doc="An integer version of .time_string")
193
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.",
202                          default=[],
203                          check_fn=_extra_strings_check_fn,
204                          change_hook=_extra_strings_change_hook,
205                          mutable=True)
206     def extra_strings(): return {}
207
208     @_versioned_property(name="summary",
209                          doc="A one-line bug description")
210     def summary(): return {}
211
212     def _get_comment_root(self, load_full=False):
213         if self.sync_with_disk:
214             return comment.loadComments(self, load_full=load_full)
215         else:
216             return comment.Comment(self, uuid=comment.INVALID_UUID)
217
218     @Property
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 {}
223
224     def _get_rcs(self):
225         if hasattr(self.bugdir, "rcs"):
226             return self.bugdir.rcs
227
228     @Property
229     @cached_property(generator=_get_rcs)
230     @local_property("rcs")
231     @doc_property(doc="A revision control system instance.")
232     def rcs(): return {}
233
234     def __init__(self, bugdir=None, uuid=None, from_disk=False,
235                  load_comments=False, summary=None):
236         settings_object.SavedSettingsObject.__init__(self)
237         self.bugdir = bugdir
238         self.uuid = uuid
239         if from_disk == True:
240             self.sync_with_disk = True
241         else:
242             self.sync_with_disk = False
243             if uuid == None:
244                 self.uuid = uuid_gen()
245             self.time = int(time.time()) # only save to second precision
246             if self.rcs != None:
247                 self.creator = self.rcs.get_user_id()
248             self.summary = summary
249
250     def __repr__(self):
251         return "Bug(uuid=%r)" % self.uuid
252
253     def __str__(self):
254         return self.string(shortlist=True)
255
256     def __cmp__(self, other):
257         return cmp_full(self, other)
258
259     # serializing methods
260
261     def _setting_attr_string(self, setting):
262         value = getattr(self, setting)
263         if value == None:
264             return ""
265         return str(value)
266
267     def xml(self, show_comments=False):
268         if self.bugdir == None:
269             shortname = self.uuid
270         else:
271             shortname = self.bugdir.bug_shortname(self)
272
273         if self.time == None:
274             timestring = ""
275         else:
276             timestring = utility.time_to_str(self.time)
277
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)]
288         ret = '<bug>\n'
289         for (k,v) in info:
290             if v is not None:
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)
297             if len(comout) > 0:
298                 ret += comout+'\n'
299         ret += '</bug>'
300         return ret
301
302     def string(self, shortlist=False, show_comments=False):
303         if self.bugdir == None:
304             shortname = self.uuid
305         else:
306             shortname = self.bugdir.bug_shortname(self)
307         if shortlist == False:
308             if self.time == None:
309                 timestring = ""
310             else:
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')
325         else:
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'))
330         
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,
335                                                      auto_name_map=True,
336                                                      bug_shortname=shortname)
337             output = bugout + '\n' + comout.rstrip('\n')
338         else :
339             output = bugout
340         return output
341
342     # methods for saving/loading/acessing settings and properties.
343
344     def get_path(self, name=None):
345         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
346         if name is None:
347             return my_dir
348         assert name in ["values", "comments"]
349         return os.path.join(my_dir, name)
350
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)
355
356     def load_settings(self):
357         if self.sync_with_disk == False:
358             raise DiskAccessRequired("load settings")
359         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
360         self._setup_saved_settings()
361
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.rcs.mkdir(self.get_path())
367         path = self.get_path("values")
368         mapfile.map_save(self.rcs, path, self._get_saved_settings())
369
370     def save(self):
371         """
372         Save any loaded contents to disk.  Because of lazy loading of
373         comments, this is actually not too inefficient.
374         
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).
379         """
380         sync_with_disk = self.sync_with_disk
381         if sync_with_disk == False:
382             self.set_sync_with_disk(True)
383         self.save_settings()
384         if len(self.comment_root) > 0:
385             comment.saveComments(self)
386         if sync_with_disk == False:
387             self.set_sync_with_disk(False)
388
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)
395         else:
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
399             # tree to disk.
400             self.sync_with_disk = False
401             self.comment_root = None
402             self.sync_with_disk = True
403
404     def remove(self):
405         if self.sync_with_disk == False:
406             raise DiskAccessRequired("remove")
407         self.comment_root.remove()
408         path = self.get_path()
409         self.rcs.recursive_remove(path)
410     
411     # methods for managing comments
412
413     def comments(self):
414         for comment in self.comment_root.traverse():
415             yield comment
416
417     def new_comment(self, body=None):
418         comm = self.comment_root.new_reply(body=body)
419         return comm
420
421     def comment_from_shortname(self, shortname, *args, **kwargs):
422         return self.comment_root.comment_from_shortname(shortname,
423                                                         *args, **kwargs)
424
425     def comment_from_uuid(self, uuid):
426         return self.comment_root.comment_from_uuid(uuid)
427
428     def comment_shortnames(self, shortname=None):
429         """
430         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
431         tree by comment.time
432         """
433         for id, comment in self.comment_root.comment_shortnames(shortname):
434             yield (id, comment)
435
436
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).
442
443 def cmp_severity(bug_1, bug_2):
444     """
445     Compare the severity levels of two bugs, with more severe bugs
446     comparing as less.
447     >>> bugA = Bug()
448     >>> bugB = Bug()
449     >>> bugA.severity = bugB.severity = "wishlist"
450     >>> cmp_severity(bugA, bugB) == 0
451     True
452     >>> bugB.severity = "minor"
453     >>> cmp_severity(bugA, bugB) > 0
454     True
455     >>> bugA.severity = "critical"
456     >>> cmp_severity(bugA, bugB) < 0
457     True
458     """
459     if not hasattr(bug_2, "severity") :
460         return 1
461     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
462
463 def cmp_status(bug_1, bug_2):
464     """
465     Compare the status levels of two bugs, with more 'open' bugs
466     comparing as less.
467     >>> bugA = Bug()
468     >>> bugB = Bug()
469     >>> bugA.status = bugB.status = "open"
470     >>> cmp_status(bugA, bugB) == 0
471     True
472     >>> bugB.status = "closed"
473     >>> cmp_status(bugA, bugB) < 0
474     True
475     >>> bugA.status = "fixed"
476     >>> cmp_status(bugA, bugB) > 0
477     True
478     """
479     if not hasattr(bug_2, "status") :
480         return 1
481     val_2 = status_index[bug_2.status]
482     return cmp(status_index[bug_1.status], status_index[bug_2.status])
483
484 def cmp_attr(bug_1, bug_2, attr, invert=False):
485     """
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.
489     >>> attr="severity"
490     >>> bugA = Bug()
491     >>> bugB = Bug()
492     >>> bugA.severity = "critical"
493     >>> bugB.severity = "wishlist"
494     >>> cmp_attr(bugA, bugB, attr) < 0
495     True
496     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
497     True
498     >>> bugB.severity = "critical"
499     >>> cmp_attr(bugA, bugB, attr) == 0
500     True
501     """
502     if not hasattr(bug_2, attr) :
503         return 1
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
508     
509     if invert == True :
510         return -cmp(val_1, val_2)
511     else :
512         return cmp(val_1, val_2)
513
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)
523
524 def cmp_comments(bug_1, bug_2):
525     """
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
528     full comparison.
529     """
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))
533     if result != 0:
534         return result
535     for c_1,c_2 in zip(comms_1, comms_2):
536         result = cmp(c_1, c_2)
537         if result != 0:
538             return result
539     return 0
540
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)
544
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)
551             if val != 0 :
552                 return val
553         return 0
554         
555 cmp_full = BugCompoundComparator()
556
557
558 # define some bonus cmp_* functions
559 def cmp_last_modified(bug_1, bug_2):
560     """
561     Like cmp_time(), but use most recent comment instead of bug
562     creation for the timestamp.
563     """
564     def last_modified(bug):
565         time = bug.time
566         for comment in bug.comment_root.traverse():
567             if comment.time > time:
568                 time = comment.time
569         return time
570     val_1 = last_modified(bug_1)
571     val_2 = last_modified(bug_2)
572     return -cmp(val_1, val_2)
573
574
575 suite = doctest.DocTestSuite()