1 # Copyright (C) 2008-2011 Gianluca Montecchi <gian@grys.it>
2 # Thomas Habets <thomas@habets.pp.se>
3 # W. Trevor King <wking@drexel.edu>
5 # This file is part of Bugs Everywhere.
7 # Bugs Everywhere is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation, either version 2 of the License, or (at your
10 # option) any later version.
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 # General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
20 """Define the :class:`Comment` class for representing bug comments.
30 from email.mime.base import MIMEBase
31 from email.encoders import encode_base64
33 # adjust to old python 2.4
34 from email.MIMEBase import MIMEBase
35 from email.Encoders import encode_base64
36 try: # import core module, Python >= 2.5
37 from xml.etree import ElementTree
38 except ImportError: # look for non-core module
39 from elementtree import ElementTree
40 import xml.sax.saxutils
44 from libbe.storage.util.properties import Property, doc_property, \
45 local_property, defaulting_property, checked_property, cached_property, \
46 primed_property, change_hook_property, settings_property
47 import libbe.storage.util.settings_object as settings_object
48 import libbe.storage.util.mapfile as mapfile
49 from libbe.util.tree import Tree
50 import libbe.util.utility as utility
52 if libbe.TESTING == True:
56 class InvalidShortname(KeyError):
57 def __init__(self, shortname, shortnames):
58 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
59 KeyError.__init__(self, msg)
60 self.shortname = shortname
61 self.shortnames = shortnames
63 class MissingReference(ValueError):
64 def __init__(self, comment):
65 msg = "Missing reference to %s" % (comment.in_reply_to)
66 ValueError.__init__(self, msg)
67 self.reference = comment.in_reply_to
68 self.comment = comment
70 class DiskAccessRequired (Exception):
71 def __init__(self, goal):
72 msg = "Cannot %s without accessing the disk" % goal
73 Exception.__init__(self, msg)
75 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
77 def load_comments(bug, load_full=False):
79 Set load_full=True when you want to load the comment completely
80 from disk *now*, rather than waiting and lazy loading as required.
83 for id in libbe.util.id.child_uuids(
89 comm = Comment(bug, uuid, from_storage=True)
92 dummy = comm.body # force the body to load
94 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
95 bug.add_comments(comments, ignore_missing_references=True)
96 return bug.comment_root
98 def save_comments(bug):
99 for comment in bug.comment_root.traverse():
103 class Comment (Tree, settings_object.SavedSettingsObject):
104 """Comments are a notes that attach to :class:`~libbe.bug.Bug`\s in
105 threaded trees. In mailing-list terms, a comment is analogous to
106 a single part of an email.
111 >>> c.uuid = "some-UUID"
112 >>> print c.content_type
116 settings_properties = []
117 required_saved_properties = []
118 _prop_save_settings = settings_object.prop_save_settings
119 _prop_load_settings = settings_object.prop_load_settings
120 def _versioned_property(settings_properties=settings_properties,
121 required_saved_properties=required_saved_properties,
123 if "settings_properties" not in kwargs:
124 kwargs["settings_properties"] = settings_properties
125 if "required_saved_properties" not in kwargs:
126 kwargs["required_saved_properties"]=required_saved_properties
127 return settings_object.versioned_property(**kwargs)
129 @_versioned_property(name="Alt-id",
130 doc="Alternate ID for linking imported comments. Internally comments are linked (via In-reply-to) to the parent's UUID. However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.")
131 def alt_id(): return {}
133 @_versioned_property(name="Author",
134 doc="The author of the comment")
135 def author(): return {}
137 @_versioned_property(name="In-reply-to",
138 doc="UUID for parent comment or bug")
139 def in_reply_to(): return {}
141 @_versioned_property(name="Content-type",
142 doc="Mime type for comment body",
143 default="text/plain",
145 def content_type(): return {}
147 @_versioned_property(name="Date",
148 doc="An RFC 2822 timestamp for comment creation")
149 def date(): return {}
152 if self.date == None:
154 return utility.str_to_time(self.date)
155 def _set_time(self, value):
156 self.date = utility.time_to_str(value)
157 time = property(fget=_get_time,
159 doc="An integer version of .date")
161 def _get_comment_body(self):
162 if self.storage != None and self.storage.is_readable() \
163 and self.uuid != INVALID_UUID:
164 return self.storage.get(self.id.storage("body"),
165 decode=self.content_type.startswith("text/"))
166 def _set_comment_body(self, old=None, new=None, force=False):
167 assert self.uuid != INVALID_UUID, self
168 if self.content_type.startswith('text/') \
169 and self.bug != None and self.bug.bugdir != None:
170 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
171 if (self.storage != None and self.storage.writeable == True) \
173 assert new != None, "Can't save empty comment"
174 self.storage.set(self.id.storage("body"), new)
177 @change_hook_property(hook=_set_comment_body)
178 @cached_property(generator=_get_comment_body)
179 @local_property("body")
180 @doc_property(doc="The meat of the comment")
181 def body(): return {}
183 def _extra_strings_check_fn(value):
184 return utility.iterable_full_of_strings(value, \
185 alternative=settings_object.EMPTY)
186 def _extra_strings_change_hook(self, old, new):
187 self.extra_strings.sort() # to make merging easier
188 self._prop_save_settings(old, new)
189 @_versioned_property(name="extra_strings",
190 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
192 check_fn=_extra_strings_check_fn,
193 change_hook=_extra_strings_change_hook,
195 def extra_strings(): return {}
197 def __init__(self, bug=None, uuid=None, from_storage=False,
198 in_reply_to=None, body=None, content_type=None):
200 Set ``from_storage=True`` to load an old comment.
201 Set ``from_storage=False`` to create a new comment.
203 The ``uuid`` option is required when ``from_storage==True``.
205 The in_reply_to, body, and content_type options are only used
206 if ``from_storage==False`` (the default). When
207 ``from_storage==True``, they are loaded from the bug database.
208 ``content_type`` decides if the body should be run through
209 :func:`util.id.short_to_long_text` before saving. See
210 :meth:`_set_comment_body` for details.
212 ``in_reply_to`` should be the uuid string of the parent comment.
215 settings_object.SavedSettingsObject.__init__(self)
219 self.id = libbe.util.id.ID(self, 'comment')
220 if from_storage == False:
222 self.uuid = libbe.util.id.uuid_gen()
223 self.time = int(time.time()) # only save to second precision
224 self.in_reply_to = in_reply_to
225 if content_type != None:
226 self.content_type = content_type
229 self.storage = self.bug.storage
230 if from_storage == False:
231 if self.storage != None and self.storage.is_writeable():
234 def __cmp__(self, other):
235 return cmp_full(self, other)
239 >>> comm = Comment(bug=None, body="Some insightful remarks")
240 >>> comm.uuid = "com-1"
241 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
242 >>> comm.author = "Jane Doe <jdoe@example.com>"
244 --------- Comment ---------
246 From: Jane Doe <jdoe@example.com>
247 Date: Thu, 20 Nov 2008 15:55:11 +0000
249 Some insightful remarks
253 def traverse(self, *args, **kwargs):
254 """Avoid working with the possible dummy root comment"""
255 for comment in Tree.traverse(self, *args, **kwargs):
256 if comment.uuid == INVALID_UUID:
260 # serializing methods
262 def _setting_attr_string(self, setting):
263 value = getattr(self, setting)
266 if type(value) not in types.StringTypes:
270 def safe_in_reply_to(self):
272 Return self.in_reply_to, except...
274 * if no comment matches that id, in which case return None.
275 * if that id matches another comments .alt_id, in which case
276 return the matching comments .uuid.
278 if self.in_reply_to == None:
282 irt_comment = self.bug.comment_from_uuid(
283 self.in_reply_to, match_alt_id=True)
284 return irt_comment.uuid
288 def xml(self, indent=0):
290 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
291 >>> comm.uuid = "0123"
292 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
293 >>> print comm.xml(indent=2)
296 <short-name>//012</short-name>
298 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
299 <content-type>text/plain</content-type>
304 >>> comm.content_type = 'image/png'
308 <short-name>//012</short-name>
310 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
311 <content-type>image/png</content-type>
312 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
316 if self.content_type.startswith('text/'):
317 body = (self.body or '').rstrip('\n')
319 maintype,subtype = self.content_type.split('/',1)
320 msg = MIMEBase(maintype, subtype)
321 msg.set_payload(self.body or '')
323 body = base64.encodestring(self.body or '')
324 info = [('uuid', self.uuid),
325 ('alt-id', self.alt_id),
326 ('short-name', self.id.user()),
327 ('in-reply-to', self.safe_in_reply_to()),
328 ('author', self._setting_attr_string('author')),
330 ('content-type', self.content_type),
332 lines = ['<comment>']
335 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
336 for estr in self.extra_strings:
337 lines.append(' <extra-string>%s</extra-string>' % estr)
338 lines.append('</comment>')
341 return istring + sep.join(lines).rstrip('\n')
343 def from_xml(self, xml_string, preserve_uuids=False, verbose=True):
345 Note: If alt-id is not given, translates any <uuid> fields to
347 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
348 >>> commA.uuid = "0123"
349 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
350 >>> commA.author = u'Fran\xe7ois'
351 >>> commA.extra_strings += ['TAG: very helpful']
352 >>> xml = commA.xml()
353 >>> commB = Comment()
354 >>> commB.from_xml(xml, verbose=True)
355 >>> commB.explicit_attrs
356 ['author', 'date', 'content_type', 'body', 'alt_id']
357 >>> commB.xml() == xml
359 >>> commB.uuid = commB.alt_id
360 >>> commB.alt_id = None
361 >>> commB.xml() == xml
363 >>> commC = Comment()
364 >>> commC.from_xml(xml, preserve_uuids=True)
365 >>> commC.uuid == commA.uuid
368 if type(xml_string) == types.UnicodeType:
369 xml_string = xml_string.strip().encode('unicode_escape')
370 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
373 comment = ElementTree.XML(xml_string)
374 if comment.tag != 'comment':
375 raise utility.InvalidXML( \
376 'comment', comment, 'root element must be <comment>')
377 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
378 'body','extra-string']
379 self.explicit_attrs = []
383 for child in comment.getchildren():
384 if child.tag == 'short-name':
386 elif child.tag in tags:
387 if child.text == None or len(child.text) == 0:
388 text = settings_object.EMPTY
390 text = xml.sax.saxutils.unescape(child.text)
391 text = text.decode('unicode_escape').strip()
392 if child.tag == 'uuid' and not preserve_uuids:
394 continue # don't set the comment's uuid tag.
395 elif child.tag == 'body':
397 self.explicit_attrs.append(child.tag)
398 continue # don't set the comment's body yet.
399 elif child.tag == 'extra-string':
401 continue # don't set the comment's extra_string yet.
402 attr_name = child.tag.replace('-','_')
403 self.explicit_attrs.append(attr_name)
404 setattr(self, attr_name, text)
405 elif verbose == True:
406 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
407 % (child.tag, comment.tag)
408 if uuid != self.uuid and self.alt_id == None:
409 self.explicit_attrs.append('alt_id')
412 if self.content_type.startswith('text/'):
413 self.body = body+'\n' # restore trailing newline
415 self.body = base64.decodestring(body)
416 self.extra_strings = estrs
418 def merge(self, other, accept_changes=True,
419 accept_extra_strings=True, change_exception=False):
421 Merge info from other into this comment. Overrides any
422 attributes in self that are listed in other.explicit_attrs.
424 >>> commA = Comment(bug=None, body='Some insightful remarks')
425 >>> commA.uuid = '0123'
426 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
427 >>> commA.author = 'Frank'
428 >>> commA.extra_strings += ['TAG: very helpful']
429 >>> commA.extra_strings += ['TAG: favorite']
430 >>> commB = Comment(bug=None, body='More insightful remarks')
431 >>> commB.uuid = '3210'
432 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
433 >>> commB.author = 'John'
434 >>> commB.explicit_attrs = ['author', 'body']
435 >>> commB.extra_strings += ['TAG: very helpful']
436 >>> commB.extra_strings += ['TAG: useful']
437 >>> commA.merge(commB, accept_changes=False,
438 ... accept_extra_strings=False, change_exception=False)
439 >>> commA.merge(commB, accept_changes=False,
440 ... accept_extra_strings=False, change_exception=True)
441 Traceback (most recent call last):
443 ValueError: Merge would change author "Frank"->"John" for comment 0123
444 >>> commA.merge(commB, accept_changes=True,
445 ... accept_extra_strings=False, change_exception=True)
446 Traceback (most recent call last):
448 ValueError: Merge would add extra string "TAG: useful" to comment 0123
449 >>> print commA.author
451 >>> print commA.extra_strings
452 ['TAG: favorite', 'TAG: very helpful']
453 >>> commA.merge(commB, accept_changes=True,
454 ... accept_extra_strings=True, change_exception=True)
455 >>> print commA.extra_strings
456 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
457 >>> print commA.xml()
460 <short-name>//012</short-name>
461 <author>John</author>
462 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
463 <content-type>text/plain</content-type>
464 <body>More insightful remarks</body>
465 <extra-string>TAG: favorite</extra-string>
466 <extra-string>TAG: useful</extra-string>
467 <extra-string>TAG: very helpful</extra-string>
470 for attr in other.explicit_attrs:
471 old = getattr(self, attr)
472 new = getattr(other, attr)
474 if accept_changes == True:
475 setattr(self, attr, new)
476 elif change_exception == True:
478 'Merge would change %s "%s"->"%s" for comment %s' \
479 % (attr, old, new, self.uuid)
480 if self.alt_id == self.uuid:
482 for estr in other.extra_strings:
483 if not estr in self.extra_strings:
484 if accept_extra_strings == True:
485 self.extra_strings.append(estr)
486 elif change_exception == True:
488 'Merge would add extra string "%s" to comment %s' \
491 def string(self, indent=0):
493 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
494 >>> comm.uuid = 'abcdef'
495 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
496 >>> print comm.string(indent=2)
497 --------- Comment ---------
500 Date: Thu, 01 Jan 1970 00:00:00 +0000
507 lines.append("--------- Comment ---------")
508 lines.append("Name: %s" % self.id.user())
509 lines.append("From: %s" % (self._setting_attr_string("author")))
510 lines.append("Date: %s" % self.date)
512 if self.content_type.startswith("text/"):
513 body = (self.body or "")
514 if self.bug != None and self.bug.bugdir != None:
515 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
516 lines.extend(body.splitlines())
518 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
522 return istring + sep.join(lines).rstrip('\n')
524 def string_thread(self, string_method_name="string",
525 indent=0, flatten=True):
527 Return a string displaying a thread of comments.
528 bug_shortname is only used if auto_name_map == True.
530 string_method_name (defaults to "string") is the name of the
531 Comment method used to generate the output string for each
532 Comment in the thread. The method must take the arguments
533 indent and shortname.
535 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
536 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
537 >>> b = a.new_reply("Critique original comment")
539 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
540 >>> c = b.new_reply("Begin flamewar :p")
542 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
543 >>> d = a.new_reply("Useful examples")
545 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
546 >>> a.sort(key=lambda comm : comm.time)
547 >>> print a.string_thread(flatten=True)
548 --------- Comment ---------
551 Date: Thu, 20 Nov 2008 01:00:00 +0000
554 --------- Comment ---------
557 Date: Thu, 20 Nov 2008 02:00:00 +0000
559 Critique original comment
560 --------- Comment ---------
563 Date: Thu, 20 Nov 2008 03:00:00 +0000
566 --------- Comment ---------
569 Date: Thu, 20 Nov 2008 04:00:00 +0000
572 >>> print a.string_thread()
573 --------- Comment ---------
576 Date: Thu, 20 Nov 2008 01:00:00 +0000
579 --------- Comment ---------
582 Date: Thu, 20 Nov 2008 02:00:00 +0000
584 Critique original comment
585 --------- Comment ---------
588 Date: Thu, 20 Nov 2008 03:00:00 +0000
591 --------- Comment ---------
594 Date: Thu, 20 Nov 2008 04:00:00 +0000
599 for depth,comment in self.thread(flatten=flatten):
601 string_fn = getattr(comment, string_method_name)
602 stringlist.append(string_fn(indent=ind))
603 return '\n'.join(stringlist)
605 def xml_thread(self, indent=0):
606 return self.string_thread(string_method_name="xml", indent=indent)
608 # methods for saving/loading/acessing settings and properties.
610 def load_settings(self, settings_mapfile=None):
611 if self.uuid == INVALID_UUID:
613 if settings_mapfile == None:
615 self.storage.get(self.id.storage("values"), default="\n")
617 settings = mapfile.parse(settings_mapfile)
618 except mapfile.InvalidMapfileContents, e:
619 raise Exception('Invalid settings file for comment %s\n'
620 '(BE version missmatch?)' % self.id.user())
621 self._setup_saved_settings(settings)
623 def save_settings(self):
624 if self.uuid == INVALID_UUID:
626 mf = mapfile.generate(self._get_saved_settings())
627 self.storage.set(self.id.storage("values"), mf)
631 Save any loaded contents to storage.
633 However, if ``self.storage.is_writeable() == True``, then any
634 changes are automatically written to storage as soon as they
635 happen, so calling this method will just waste time (unless
636 something else has been messing with your stored files).
638 if self.uuid == INVALID_UUID:
640 assert self.storage != None, "Can't save without storage"
641 assert self.body != None, "Can't save blank comment"
643 parent = self.bug.id.storage()
646 self.storage.add(self.id.storage(), parent=parent, directory=True)
647 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
649 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
652 self._set_comment_body(new=self.body, force=True)
657 if self.uuid != INVALID_UUID:
658 self.storage.recursive_remove(self.id.storage())
660 def add_reply(self, reply, allow_time_inversion=False):
661 if self.uuid != INVALID_UUID:
662 reply.in_reply_to = self.uuid
665 def new_reply(self, body=None, content_type=None):
667 >>> comm = Comment(bug=None, body="Some insightful remarks")
668 >>> repA = comm.new_reply("Critique original comment")
669 >>> repB = repA.new_reply("Begin flamewar :p")
670 >>> repB.in_reply_to == repA.uuid
673 reply = Comment(self.bug, body=body, content_type=content_type)
674 self.add_reply(reply)
677 def comment_from_uuid(self, uuid, match_alt_id=True):
678 """Use a uuid to look up a comment.
680 >>> a = Comment(bug=None, uuid="a")
681 >>> b = a.new_reply()
683 >>> c = b.new_reply()
685 >>> d = a.new_reply()
687 >>> d.alt_id = "d-alt"
688 >>> comm = a.comment_from_uuid("d")
689 >>> id(comm) == id(d)
691 >>> comm = a.comment_from_uuid("d-alt")
692 >>> id(comm) == id(d)
694 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
695 Traceback (most recent call last):
699 for comment in self.traverse():
700 if comment.uuid == uuid:
702 if match_alt_id == True and uuid != None \
703 and comment.alt_id == uuid:
707 # methods for id generation
709 def sibling_uuids(self):
711 return self.bug.uuids()
715 def cmp_attr(comment_1, comment_2, attr, invert=False):
717 Compare a general attribute between two comments using the conventional
718 comparison rule for that attribute type. If invert == True, sort
719 *against* that convention.
722 >>> commentA = Comment()
723 >>> commentB = Comment()
724 >>> commentA.author = "John Doe"
725 >>> commentB.author = "Jane Doe"
726 >>> cmp_attr(commentA, commentB, attr) > 0
728 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
730 >>> commentB.author = "John Doe"
731 >>> cmp_attr(commentA, commentB, attr) == 0
734 if not hasattr(comment_2, attr) :
736 val_1 = getattr(comment_1, attr)
737 val_2 = getattr(comment_2, attr)
738 if val_1 == None: val_1 = None
739 if val_2 == None: val_2 = None
742 return -cmp(val_1, val_2)
744 return cmp(val_1, val_2)
746 # alphabetical rankings (a < z)
747 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
748 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
749 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
750 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
751 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
752 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
753 # chronological rankings (newer < older)
754 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
757 DEFAULT_CMP_FULL_CMP_LIST = \
758 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
759 cmp_uuid, cmp_extra_strings)
761 class CommentCompoundComparator (object):
762 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
763 self.cmp_list = cmp_list
764 def __call__(self, comment_1, comment_2):
765 for comparison in self.cmp_list :
766 val = comparison(comment_1, comment_2)
771 cmp_full = CommentCompoundComparator()
773 if libbe.TESTING == True:
774 suite = doctest.DocTestSuite()