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