Merged completed be.email-bugs branch.
[be.git] / libbe / bug.py
1 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
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 copy
24 import os
25 import os.path
26 import errno
27 import time
28 import types
29 try: # import core module, Python >= 2.5
30     from xml.etree import ElementTree
31 except ImportError: # look for non-core module
32     from elementtree import ElementTree
33 import xml.sax.saxutils
34 import doctest
35
36 from beuuid import uuid_gen
37 from properties import Property, doc_property, local_property, \
38     defaulting_property, checked_property, cached_property, \
39     primed_property, change_hook_property, settings_property
40 import settings_object
41 import mapfile
42 import comment
43 import utility
44
45
46 class DiskAccessRequired (Exception):
47     def __init__(self, goal):
48         msg = "Cannot %s without accessing the disk" % goal
49         Exception.__init__(self, msg)
50
51 ### Define and describe valid bug categories
52 # Use a tuple of (category, description) tuples since we don't have
53 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
54
55 # in order of increasing severity.  (name, description) pairs
56 severity_def = (
57   ("wishlist","A feature that could improve usefulness, but not a bug."),
58   ("minor","The standard bug level."),
59   ("serious","A bug that requires workarounds."),
60   ("critical","A bug that prevents some features from working at all."),
61   ("fatal","A bug that makes the package unusable."))
62
63 # in order of increasing resolution
64 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
65 active_status_def = (
66   ("unconfirmed","A possible bug which lacks independent existance confirmation."),
67   ("open","A working bug that has not been assigned to a developer."),
68   ("assigned","A working bug that has been assigned to a developer."),
69   ("test","The code has been adjusted, but the fix is still being tested."))
70 inactive_status_def = (
71   ("closed", "The bug is no longer relevant."),
72   ("fixed", "The bug should no longer occur."),
73   ("wontfix","It's not a bug, it's a feature."))
74
75
76 ### Convert the description tuples to more useful formats
77
78 severity_values = ()
79 severity_description = {}
80 severity_index = {}
81 def load_severities(severity_def):
82     global severity_values
83     global severity_description
84     global severity_index
85     if severity_def == None:
86         return
87     severity_values = tuple([val for val,description in severity_def])
88     severity_description = dict(severity_def)
89     severity_index = {}
90     for i,severity in enumerate(severity_values):
91         severity_index[severity] = i
92 load_severities(severity_def)
93
94 active_status_values = []
95 inactive_status_values = []
96 status_values = []
97 status_description = {}
98 status_index = {}
99 def load_status(active_status_def, inactive_status_def):
100     global active_status_values
101     global inactive_status_values
102     global status_values
103     global status_description
104     global status_index
105     if active_status_def == None:
106         active_status_def = globals()["active_status_def"]
107     if inactive_status_def == None:
108         inactive_status_def = globals()["inactive_status_def"]
109     active_status_values = tuple([val for val,description in active_status_def])
110     inactive_status_values = tuple([val for val,description in inactive_status_def])
111     status_values = active_status_values + inactive_status_values
112     status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
113     status_index = {}
114     for i,status in enumerate(status_values):
115         status_index[status] = i
116 load_status(active_status_def, inactive_status_def)
117
118
119 class Bug(settings_object.SavedSettingsObject):
120     """
121     >>> b = Bug()
122     >>> print b.status
123     open
124     >>> print b.severity
125     minor
126
127     There are two formats for time, int and string.  Setting either
128     one will adjust the other appropriately.  The string form is the
129     one stored in the bug's settings file on disk.
130     >>> print type(b.time)
131     <type 'int'>
132     >>> print type(b.time_string)
133     <type 'str'>
134     >>> b.time = 0
135     >>> print b.time_string
136     Thu, 01 Jan 1970 00:00:00 +0000
137     >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
138     >>> b.time
139     60
140     >>> print b.settings["time"]
141     Thu, 01 Jan 1970 00:01:00 +0000
142     """
143     settings_properties = []
144     required_saved_properties = []
145     _prop_save_settings = settings_object.prop_save_settings
146     _prop_load_settings = settings_object.prop_load_settings
147     def _versioned_property(settings_properties=settings_properties,
148                             required_saved_properties=required_saved_properties,
149                             **kwargs):
150         if "settings_properties" not in kwargs:
151             kwargs["settings_properties"] = settings_properties
152         if "required_saved_properties" not in kwargs:
153             kwargs["required_saved_properties"]=required_saved_properties
154         return settings_object.versioned_property(**kwargs)
155
156     @_versioned_property(name="severity",
157                          doc="A measure of the bug's importance",
158                          default="minor",
159                          check_fn=lambda s: s in severity_values,
160                          require_save=True)
161     def severity(): return {}
162
163     @_versioned_property(name="status",
164                          doc="The bug's current status",
165                          default="open",
166                          check_fn=lambda s: s in status_values,
167                          require_save=True)
168     def status(): return {}
169     
170     @property
171     def active(self):
172         return self.status in active_status_values
173
174     @_versioned_property(name="target",
175                          doc="The deadline for fixing this bug")
176     def target(): return {}
177
178     @_versioned_property(name="creator",
179                          doc="The user who entered the bug into the system")
180     def creator(): return {}
181
182     @_versioned_property(name="reporter",
183                          doc="The user who reported the bug")
184     def reporter(): return {}
185
186     @_versioned_property(name="assigned",
187                          doc="The developer in charge of the bug")
188     def assigned(): return {}
189
190     @_versioned_property(name="time",
191                          doc="An RFC 2822 timestamp for bug creation")
192     def time_string(): return {}
193
194     def _get_time(self):
195         if self.time_string == None:
196             return None
197         return utility.str_to_time(self.time_string)
198     def _set_time(self, value):
199         self.time_string = utility.time_to_str(value)
200     time = property(fget=_get_time,
201                     fset=_set_time,
202                     doc="An integer version of .time_string")
203
204     def _extra_strings_check_fn(value):
205         return utility.iterable_full_of_strings(value, \
206                          alternative=settings_object.EMPTY)
207     def _extra_strings_change_hook(self, old, new):
208         self.extra_strings.sort() # to make merging easier
209         self._prop_save_settings(old, new)
210     @_versioned_property(name="extra_strings",
211                          doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
212                          default=[],
213                          check_fn=_extra_strings_check_fn,
214                          change_hook=_extra_strings_change_hook,
215                          mutable=True)
216     def extra_strings(): return {}
217
218     @_versioned_property(name="summary",
219                          doc="A one-line bug description")
220     def summary(): return {}
221
222     def _get_comment_root(self, load_full=False):
223         if self.sync_with_disk:
224             return comment.loadComments(self, load_full=load_full)
225         else:
226             return comment.Comment(self, uuid=comment.INVALID_UUID)
227
228     @Property
229     @cached_property(generator=_get_comment_root)
230     @local_property("comment_root")
231     @doc_property(doc="The trunk of the comment tree.  We use a dummy root comment by default, because there can be several comment threads rooted on the same parent bug.  To simplify comment interaction, we condense these threads into a single thread with a Comment dummy root.")
232     def comment_root(): return {}
233
234     def _get_vcs(self):
235         if hasattr(self.bugdir, "vcs"):
236             return self.bugdir.vcs
237
238     @Property
239     @cached_property(generator=_get_vcs)
240     @local_property("vcs")
241     @doc_property(doc="A revision control system instance.")
242     def vcs(): return {}
243
244     def __init__(self, bugdir=None, uuid=None, from_disk=False,
245                  load_comments=False, summary=None):
246         settings_object.SavedSettingsObject.__init__(self)
247         self.bugdir = bugdir
248         self.uuid = uuid
249         if from_disk == True:
250             self.sync_with_disk = True
251         else:
252             self.sync_with_disk = False
253             if uuid == None:
254                 self.uuid = uuid_gen()
255             self.time = int(time.time()) # only save to second precision
256             if self.vcs != None:
257                 self.creator = self.vcs.get_user_id()
258             self.summary = summary
259
260     def __repr__(self):
261         return "Bug(uuid=%r)" % self.uuid
262
263     def __str__(self):
264         return self.string(shortlist=True)
265
266     def __cmp__(self, other):
267         return cmp_full(self, other)
268
269     # serializing methods
270
271     def _setting_attr_string(self, setting):
272         value = getattr(self, setting)
273         if value == None:
274             return ""
275         if type(value) not in types.StringTypes:
276             return str(value)
277         return value
278
279     def xml(self, indent=0, shortname=None, show_comments=False):
280         if shortname == None:
281             if self.bugdir == None:
282                 shortname = self.uuid
283             else:
284                 shortname = self.bugdir.bug_shortname(self)
285
286         if self.time == None:
287             timestring = ""
288         else:
289             timestring = utility.time_to_str(self.time)
290
291         info = [('uuid', self.uuid),
292                 ('short-name', shortname),
293                 ('severity', self.severity),
294                 ('status', self.status),
295                 ('assigned', self.assigned),
296                 ('target', self.target),
297                 ('reporter', self.reporter),
298                 ('creator', self.creator),
299                 ('created', timestring),
300                 ('summary', self.summary)]
301         lines = ['<bug>']
302         for (k,v) in info:
303             if v is not None:
304                 lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
305         for estr in self.extra_strings:
306             lines.append('  <extra-string>%s</extra-string>' % estr)
307         if show_comments == True:
308             comout = self.comment_root.xml_thread(indent=indent+2,
309                                                   auto_name_map=True,
310                                                   bug_shortname=shortname)
311             if len(comout) > 0:
312                 lines.append(comout)
313         lines.append('</bug>')
314         istring = ' '*indent
315         sep = '\n' + istring
316         return istring + sep.join(lines).rstrip('\n')
317
318     def from_xml(self, xml_string, verbose=True):
319         """
320         Note: If a bug uuid is given, set .alt_id to it's value.
321         >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()")
322         >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
323         >>> bugA.creator = u'Fran\xe7ois'
324         >>> bugA.extra_strings += ['TAG: very helpful']
325         >>> commA = bugA.comment_root.new_reply(body='comment A')
326         >>> commB = bugA.comment_root.new_reply(body='comment B')
327         >>> commC = commA.new_reply(body='comment C')
328         >>> xml = bugA.xml(shortname="bug-1", show_comments=True)
329         >>> bugB = Bug()
330         >>> bugB.from_xml(xml, verbose=True)
331         >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
332         False
333         >>> bugB.uuid = bugB.alt_id
334         >>> for comm in bugB.comments():
335         ...     comm.uuid = comm.alt_id
336         ...     comm.alt_id = None
337         >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
338         True
339         >>> bugB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
340         ['severity', 'status', 'creator', 'created', 'summary']
341         >>> len(list(bugB.comments()))
342         3
343         """
344         if type(xml_string) == types.UnicodeType:
345             xml_string = xml_string.strip().encode('unicode_escape')
346         if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
347             bug = xml_string
348         else:
349             bug = ElementTree.XML(xml_string)
350         if bug.tag != 'bug':
351             raise utility.InvalidXML( \
352                 'bug', bug, 'root element must be <comment>')
353         tags=['uuid','short-name','severity','status','assigned','target',
354               'reporter', 'creator','created','summary','extra-string']
355         self.explicit_attrs = []
356         uuid = None
357         estrs = []
358         comments = []
359         for child in bug.getchildren():
360             if child.tag == 'short-name':
361                 pass
362             elif child.tag == 'comment':
363                 comm = comment.Comment(bug=self)
364                 comm.from_xml(child)
365                 comments.append(comm)
366                 continue
367             elif child.tag in tags:
368                 if child.text == None or len(child.text) == 0:
369                     text = settings_object.EMPTY
370                 else:
371                     text = xml.sax.saxutils.unescape(child.text)
372                     text = text.decode('unicode_escape').strip()
373                 if child.tag == 'uuid':
374                     uuid = text
375                     continue # don't set the bug's uuid tag.
376                 elif child.tag == 'extra-string':
377                     estrs.append(text)
378                     continue # don't set the bug's extra_string yet.
379                 attr_name = child.tag.replace('-','_')
380                 self.explicit_attrs.append(attr_name)
381                 setattr(self, attr_name, text)
382             elif verbose == True:
383                 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
384                     % (child.tag, comment.tag)
385         if uuid != self.uuid:
386             if not hasattr(self, 'alt_id') or self.alt_id == None:
387                 self.alt_id = uuid
388         self.extra_strings = estrs
389         self.add_comments(comments)
390
391     def add_comment(self, comment, *args, **kwargs):
392         """
393         Add a comment too the current bug, under the parent specified
394         by comment.in_reply_to.
395         Note: If a bug uuid is given, set .alt_id to it's value.
396         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()')
397         >>> bugA.creator = 'Jack'
398         >>> commA = bugA.comment_root.new_reply(body='comment A')
399         >>> commA.uuid = 'commA'
400         >>> commB = comment.Comment(body='comment B')
401         >>> commB.uuid = 'commB'
402         >>> bugA.add_comment(commB)
403         >>> commC = comment.Comment(body='comment C')
404         >>> commC.uuid = 'commC'
405         >>> commC.in_reply_to = commA.uuid
406         >>> bugA.add_comment(commC)
407         >>> print bugA.xml(shortname="bug-1", show_comments=True)  # doctest: +ELLIPSIS
408         <bug>
409           <uuid>0123</uuid>
410           <short-name>bug-1</short-name>
411           <severity>minor</severity>
412           <status>open</status>
413           <creator>Jack</creator>
414           <created>...</created>
415           <summary>Need to test Bug.add_comment()</summary>
416           <comment>
417             <uuid>commA</uuid>
418             <short-name>bug-1:1</short-name>
419             <author></author>
420             <date>...</date>
421             <content-type>text/plain</content-type>
422             <body>comment A</body>
423           </comment>
424           <comment>
425             <uuid>commC</uuid>
426             <short-name>bug-1:2</short-name>
427             <in-reply-to>commA</in-reply-to>
428             <author></author>
429             <date>...</date>
430             <content-type>text/plain</content-type>
431             <body>comment C</body>
432           </comment>
433           <comment>
434             <uuid>commB</uuid>
435             <short-name>bug-1:3</short-name>
436             <author></author>
437             <date>...</date>
438             <content-type>text/plain</content-type>
439             <body>comment B</body>
440           </comment>
441         </bug>
442         """
443         self.add_comments([comment], **kwargs)
444
445     def add_comments(self, comments, default_parent=None,
446                      ignore_missing_references=False):
447         """
448         Convert a raw list of comments to single root comment.  If a
449         comment does not specify a parent with .in_reply_to, the
450         parent defaults to .comment_root, but you can specify another
451         default parent via default_parent.
452         """
453         uuid_map = {}
454         if default_parent == None:
455             default_parent = self.comment_root
456         for c in list(self.comments()) + comments:
457             assert c.uuid != None
458             assert c.uuid not in uuid_map
459             uuid_map[c.uuid] = c
460             if c.alt_id != None:
461                 uuid_map[c.alt_id] = c
462         uuid_map[None] = self.comment_root
463         if default_parent != self.comment_root:
464             assert default_parent.uuid in uuid_map, default_parent
465         for c in comments:
466             if c.in_reply_to == None \
467                     and default_parent.uuid != comment.INVALID_UUID:
468                 c.in_reply_to = default_parent.uuid
469             elif c.in_reply_to == comment.INVALID_UUID:
470                 c.in_reply_to = None
471             try:
472                 parent = uuid_map[c.in_reply_to]
473             except KeyError:
474                 if ignore_missing_references == True:
475                     print >> sys.stderr, \
476                         "Ignoring missing reference to %s" % c.in_reply_to
477                     parent = default_parent
478                     if parent.uuid != comment.INVALID_UUID:
479                         c.in_reply_to = parent.uuid
480                 else:
481                     raise comment.MissingReference(c)
482             c.bug = self
483             parent.append(c)
484
485     def merge(self, other, accept_changes=True,
486               accept_extra_strings=True, accept_comments=True,
487               change_exception=False):
488         """
489         Merge info from other into this bug.  Overrides any attributes
490         in self that are listed in other.explicit_attrs.
491         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
492         >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
493         >>> bugA.creator = 'Frank'
494         >>> bugA.extra_strings += ['TAG: very helpful']
495         >>> bugA.extra_strings += ['TAG: favorite']
496         >>> commA = bugA.comment_root.new_reply(body='comment A')
497         >>> commA.uuid = 'uuid-commA'
498         >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()')
499         >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
500         >>> bugB.creator = 'John'
501         >>> bugB.explicit_attrs = ['creator', 'summary']
502         >>> bugB.extra_strings += ['TAG: very helpful']
503         >>> bugB.extra_strings += ['TAG: useful']
504         >>> commB = bugB.comment_root.new_reply(body='comment B')
505         >>> commB.uuid = 'uuid-commB'
506         >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
507         ...            accept_comments=False, change_exception=False)
508         >>> print bugA.creator
509         Frank
510         >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
511         ...            accept_comments=False, change_exception=True)
512         Traceback (most recent call last):
513           ...
514         ValueError: Merge would change creator "Frank"->"John" for bug 0123
515         >>> print bugA.creator
516         Frank
517         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=False,
518         ...            accept_comments=False, change_exception=True)
519         Traceback (most recent call last):
520           ...
521         ValueError: Merge would add extra string "TAG: useful" for bug 0123
522         >>> print bugA.creator
523         John
524         >>> print bugA.extra_strings
525         ['TAG: favorite', 'TAG: very helpful']
526         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
527         ...            accept_comments=False, change_exception=True)
528         Traceback (most recent call last):
529           ...
530         ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
531         >>> print bugA.extra_strings
532         ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
533         >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
534         ...            accept_comments=True, change_exception=True)
535         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
536         <bug>
537           <uuid>0123</uuid>
538           <short-name>0123</short-name>
539           <severity>minor</severity>
540           <status>open</status>
541           <creator>John</creator>
542           <created>...</created>
543           <summary>More tests for Bug.merge()</summary>
544           <extra-string>TAG: favorite</extra-string>
545           <extra-string>TAG: useful</extra-string>
546           <extra-string>TAG: very helpful</extra-string>
547           <comment>
548             <uuid>uuid-commA</uuid>
549             <short-name>0123:1</short-name>
550             <author></author>
551             <date>...</date>
552             <content-type>text/plain</content-type>
553             <body>comment A</body>
554           </comment>
555           <comment>
556             <uuid>uuid-commB</uuid>
557             <short-name>0123:2</short-name>
558             <author></author>
559             <date>...</date>
560             <content-type>text/plain</content-type>
561             <body>comment B</body>
562           </comment>
563         </bug>
564         """
565         for attr in other.explicit_attrs:
566             old = getattr(self, attr)
567             new = getattr(other, attr)
568             if old != new:
569                 if accept_changes == True:
570                     setattr(self, attr, new)
571                 elif change_exception == True:
572                     raise ValueError, \
573                         'Merge would change %s "%s"->"%s" for bug %s' \
574                         % (attr, old, new, self.uuid)
575         for estr in other.extra_strings:
576             if not estr in self.extra_strings:
577                 if accept_extra_strings == True:
578                     self.extra_strings.append(estr)
579                 elif change_exception == True:
580                     raise ValueError, \
581                         'Merge would add extra string "%s" for bug %s' \
582                         % (estr, self.uuid)
583         for o_comm in other.comments():
584             try:
585                 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
586             except KeyError, e:
587                 try:
588                     s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id)
589                 except KeyError, e:
590                     s_comm = None
591             if s_comm == None:
592                 if accept_comments == True:
593                     o_comm_copy = copy.copy(o_comm)
594                     o_comm_copy.bug = self
595                     self.comment_root.add_reply(o_comm_copy)
596                 elif change_exception == True:
597                     raise ValueError, \
598                         'Merge would add comment %s (alt: %s) to bug %s' \
599                         % (o_comm.uuid, o_comm.alt_id, self.uuid)
600             else:
601                 s_comm.merge(o_comm, accept_changes=accept_changes,
602                              accept_extra_strings=accept_extra_strings,
603                              change_exception=change_exception)
604
605     def string(self, shortlist=False, show_comments=False):
606         if self.bugdir == None:
607             shortname = self.uuid
608         else:
609             shortname = self.bugdir.bug_shortname(self)
610         if shortlist == False:
611             if self.time == None:
612                 timestring = ""
613             else:
614                 htime = utility.handy_time(self.time)
615                 timestring = "%s (%s)" % (htime, self.time_string)
616             info = [("ID", self.uuid),
617                     ("Short name", shortname),
618                     ("Severity", self.severity),
619                     ("Status", self.status),
620                     ("Assigned", self._setting_attr_string("assigned")),
621                     ("Target", self._setting_attr_string("target")),
622                     ("Reporter", self._setting_attr_string("reporter")),
623                     ("Creator", self._setting_attr_string("creator")),
624                     ("Created", timestring)]
625             longest_key_len = max([len(k) for k,v in info])
626             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
627             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
628         else:
629             statuschar = self.status[0]
630             severitychar = self.severity[0]
631             chars = "%c%c" % (statuschar, severitychar)
632             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
633         
634         if show_comments == True:
635             # take advantage of the string_thread(auto_name_map=True)
636             # SIDE-EFFECT of sorting by comment time.
637             comout = self.comment_root.string_thread(flatten=False,
638                                                      auto_name_map=True,
639                                                      bug_shortname=shortname)
640             output = bugout + '\n' + comout.rstrip('\n')
641         else :
642             output = bugout
643         return output
644
645     # methods for saving/loading/acessing settings and properties.
646
647     def get_path(self, *args):
648         dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
649         if len(args) == 0:
650             return dir
651         assert args[0] in ["values", "comments"], str(args)
652         return os.path.join(dir, *args)
653
654     def set_sync_with_disk(self, value):
655         self.sync_with_disk = value
656         for comment in self.comments():
657             comment.set_sync_with_disk(value)
658
659     def load_settings(self):
660         if self.sync_with_disk == False:
661             raise DiskAccessRequired("load settings")
662         self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
663         self._setup_saved_settings()
664
665     def save_settings(self):
666         if self.sync_with_disk == False:
667             raise DiskAccessRequired("save settings")
668         assert self.summary != None, "Can't save blank bug"
669         self.vcs.mkdir(self.get_path())
670         path = self.get_path("values")
671         mapfile.map_save(self.vcs, path, self._get_saved_settings())
672
673     def save(self):
674         """
675         Save any loaded contents to disk.  Because of lazy loading of
676         comments, this is actually not too inefficient.
677         
678         However, if self.sync_with_disk = True, then any changes are
679         automatically written to disk as soon as they happen, so
680         calling this method will just waste time (unless something
681         else has been messing with your on-disk files).
682         """
683         sync_with_disk = self.sync_with_disk
684         if sync_with_disk == False:
685             self.set_sync_with_disk(True)
686         self.save_settings()
687         if len(self.comment_root) > 0:
688             comment.saveComments(self)
689         if sync_with_disk == False:
690             self.set_sync_with_disk(False)
691
692     def load_comments(self, load_full=True):
693         if self.sync_with_disk == False:
694             raise DiskAccessRequired("load comments")
695         if load_full == True:
696             # Force a complete load of the whole comment tree
697             self.comment_root = self._get_comment_root(load_full=True)
698         else:
699             # Setup for fresh lazy-loading.  Clear _comment_root, so
700             # _get_comment_root returns a fresh version.  Turn of
701             # syncing temporarily so we don't write our blank comment
702             # tree to disk.
703             self.sync_with_disk = False
704             self.comment_root = None
705             self.sync_with_disk = True
706
707     def remove(self):
708         if self.sync_with_disk == False:
709             raise DiskAccessRequired("remove")
710         self.comment_root.remove()
711         path = self.get_path()
712         self.vcs.recursive_remove(path)
713     
714     # methods for managing comments
715
716     def comments(self):
717         for comment in self.comment_root.traverse():
718             yield comment
719
720     def new_comment(self, body=None):
721         comm = self.comment_root.new_reply(body=body)
722         return comm
723
724     def comment_from_shortname(self, shortname, *args, **kwargs):
725         return self.comment_root.comment_from_shortname(shortname,
726                                                         *args, **kwargs)
727
728     def comment_from_uuid(self, uuid, *args, **kwargs):
729         return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
730
731     def comment_shortnames(self, shortname=None):
732         """
733         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
734         tree by comment.time
735         """
736         for id, comment in self.comment_root.comment_shortnames(shortname):
737             yield (id, comment)
738
739
740 # The general rule for bug sorting is that "more important" bugs are
741 # less than "less important" bugs.  This way sorting a list of bugs
742 # will put the most important bugs first in the list.  When relative
743 # importance is unclear, the sorting follows some arbitrary convention
744 # (i.e. dictionary order).
745
746 def cmp_severity(bug_1, bug_2):
747     """
748     Compare the severity levels of two bugs, with more severe bugs
749     comparing as less.
750     >>> bugA = Bug()
751     >>> bugB = Bug()
752     >>> bugA.severity = bugB.severity = "wishlist"
753     >>> cmp_severity(bugA, bugB) == 0
754     True
755     >>> bugB.severity = "minor"
756     >>> cmp_severity(bugA, bugB) > 0
757     True
758     >>> bugA.severity = "critical"
759     >>> cmp_severity(bugA, bugB) < 0
760     True
761     """
762     if not hasattr(bug_2, "severity") :
763         return 1
764     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
765
766 def cmp_status(bug_1, bug_2):
767     """
768     Compare the status levels of two bugs, with more 'open' bugs
769     comparing as less.
770     >>> bugA = Bug()
771     >>> bugB = Bug()
772     >>> bugA.status = bugB.status = "open"
773     >>> cmp_status(bugA, bugB) == 0
774     True
775     >>> bugB.status = "closed"
776     >>> cmp_status(bugA, bugB) < 0
777     True
778     >>> bugA.status = "fixed"
779     >>> cmp_status(bugA, bugB) > 0
780     True
781     """
782     if not hasattr(bug_2, "status") :
783         return 1
784     val_2 = status_index[bug_2.status]
785     return cmp(status_index[bug_1.status], status_index[bug_2.status])
786
787 def cmp_attr(bug_1, bug_2, attr, invert=False):
788     """
789     Compare a general attribute between two bugs using the conventional
790     comparison rule for that attribute type.  If invert == True, sort
791     *against* that convention.
792     >>> attr="severity"
793     >>> bugA = Bug()
794     >>> bugB = Bug()
795     >>> bugA.severity = "critical"
796     >>> bugB.severity = "wishlist"
797     >>> cmp_attr(bugA, bugB, attr) < 0
798     True
799     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
800     True
801     >>> bugB.severity = "critical"
802     >>> cmp_attr(bugA, bugB, attr) == 0
803     True
804     """
805     if not hasattr(bug_2, attr) :
806         return 1
807     val_1 = getattr(bug_1, attr)
808     val_2 = getattr(bug_2, attr)
809     if val_1 == None: val_1 = None
810     if val_2 == None: val_2 = None
811     
812     if invert == True :
813         return -cmp(val_1, val_2)
814     else :
815         return cmp(val_1, val_2)
816
817 # alphabetical rankings (a < z)
818 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
819 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
820 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
821 cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
822 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
823 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
824 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
825 # chronological rankings (newer < older)
826 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
827
828 def cmp_comments(bug_1, bug_2):
829     """
830     Compare two bugs' comments lists.  Doesn't load any new comments,
831     so you should call each bug's .load_comments() first if you want a
832     full comparison.
833     """
834     comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
835     comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
836     result = cmp(len(comms_1), len(comms_2))
837     if result != 0:
838         return result
839     for c_1,c_2 in zip(comms_1, comms_2):
840         result = cmp(c_1, c_2)
841         if result != 0:
842             return result
843     return 0
844
845 DEFAULT_CMP_FULL_CMP_LIST = \
846     (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
847      cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid,
848      cmp_extra_strings)
849
850 class BugCompoundComparator (object):
851     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
852         self.cmp_list = cmp_list
853     def __call__(self, bug_1, bug_2):
854         for comparison in self.cmp_list :
855             val = comparison(bug_1, bug_2)
856             if val != 0 :
857                 return val
858         return 0
859         
860 cmp_full = BugCompoundComparator()
861
862
863 # define some bonus cmp_* functions
864 def cmp_last_modified(bug_1, bug_2):
865     """
866     Like cmp_time(), but use most recent comment instead of bug
867     creation for the timestamp.
868     """
869     def last_modified(bug):
870         time = bug.time
871         for comment in bug.comment_root.traverse():
872             if comment.time > time:
873                 time = comment.time
874         return time
875     val_1 = last_modified(bug_1)
876     val_2 = last_modified(bug_2)
877     return -cmp(val_1, val_2)
878
879
880 suite = doctest.DocTestSuite()