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 # Thomas Habets <thomas@habets.pp.se>
5 # W. Trevor King <wking@tremily.us>
7 # This file is part of Bugs Everywhere.
9 # Bugs Everywhere is free software: you can redistribute it and/or modify it
10 # under the terms of the GNU General Public License as published by the Free
11 # Software Foundation, either version 2 of the License, or (at your option) any
14 # Bugs Everywhere is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
19 # You should have received a copy of the GNU General Public License along with
20 # Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
22 """Define :py:class:`Comment` for representing bug comments.
32 from email.mime.base import MIMEBase
33 from email.encoders import encode_base64
35 # adjust to old python 2.4
36 from email.MIMEBase import MIMEBase
37 from email.Encoders import encode_base64
38 try: # import core module, Python >= 2.5
39 from xml.etree import ElementTree
40 except ImportError: # look for non-core module
41 from elementtree import ElementTree
42 import xml.sax.saxutils
46 from libbe.storage.util.properties import Property, doc_property, \
47 local_property, defaulting_property, checked_property, cached_property, \
48 primed_property, change_hook_property, settings_property
49 import libbe.storage.util.settings_object as settings_object
50 import libbe.storage.util.mapfile as mapfile
51 from libbe.util.tree import Tree
52 import libbe.util.utility as utility
54 if libbe.TESTING == True:
58 class MissingReference(ValueError):
59 def __init__(self, comment):
60 msg = "Missing reference to %s" % (comment.in_reply_to)
61 ValueError.__init__(self, msg)
62 self.reference = comment.in_reply_to
63 self.comment = comment
65 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
67 def load_comments(bug, load_full=False):
69 Set load_full=True when you want to load the comment completely
70 from disk *now*, rather than waiting and lazy loading as required.
73 for id in libbe.util.id.child_uuids(
79 comm = Comment(bug, uuid, from_storage=True)
82 dummy = comm.body # force the body to load
84 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
85 bug.add_comments(comments, ignore_missing_references=True)
86 return bug.comment_root
88 def save_comments(bug):
89 for comment in bug.comment_root.traverse():
91 comment.storage = bug.storage
95 class Comment (Tree, settings_object.SavedSettingsObject):
96 """Comments are a notes that attach to :py:class:`~libbe.bug.Bug`\s in
97 threaded trees. In mailing-list terms, a comment is analogous to
98 a single part of an email.
103 >>> c.uuid = "some-UUID"
104 >>> print c.content_type
108 settings_properties = []
109 required_saved_properties = []
110 _prop_save_settings = settings_object.prop_save_settings
111 _prop_load_settings = settings_object.prop_load_settings
112 def _versioned_property(settings_properties=settings_properties,
113 required_saved_properties=required_saved_properties,
115 if "settings_properties" not in kwargs:
116 kwargs["settings_properties"] = settings_properties
117 if "required_saved_properties" not in kwargs:
118 kwargs["required_saved_properties"]=required_saved_properties
119 return settings_object.versioned_property(**kwargs)
121 @_versioned_property(name="Alt-id",
122 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.")
123 def alt_id(): return {}
125 @_versioned_property(name="Author",
126 doc="The author of the comment")
127 def author(): return {}
129 @_versioned_property(name="In-reply-to",
130 doc="UUID for parent comment or bug")
131 def in_reply_to(): return {}
133 @_versioned_property(name="Content-type",
134 doc="Mime type for comment body",
135 default="text/plain",
137 def content_type(): return {}
139 @_versioned_property(name="Date",
140 doc="An RFC 2822 timestamp for comment creation")
141 def date(): return {}
144 if self.date == None:
146 return utility.str_to_time(self.date)
147 def _set_time(self, value):
148 self.date = utility.time_to_str(value)
149 time = property(fget=_get_time,
151 doc="An integer version of .date")
153 def _get_comment_body(self):
154 if self.storage != None and self.storage.is_readable() \
155 and self.uuid != INVALID_UUID:
156 return self.storage.get(self.id.storage("body"),
157 decode=self.content_type.startswith("text/"))
158 def _set_comment_body(self, old=None, new=None, force=False):
159 assert self.uuid != INVALID_UUID, self
160 if self.content_type.startswith('text/') \
161 and self.bug != None and self.bug.bugdir != None:
162 new = libbe.util.id.short_to_long_text(
163 {self.bug.bugdir.uuid: self.bug.bugdir}, new)
164 if (self.storage != None and self.storage.writeable == True) \
166 assert new != None, "Can't save empty comment"
167 self.storage.set(self.id.storage("body"), new)
170 @change_hook_property(hook=_set_comment_body)
171 @cached_property(generator=_get_comment_body)
172 @local_property("body")
173 @doc_property(doc="The meat of the comment")
174 def body(): return {}
176 def _extra_strings_check_fn(value):
177 return utility.iterable_full_of_strings(value, \
178 alternative=settings_object.EMPTY)
179 def _extra_strings_change_hook(self, old, new):
180 self.extra_strings.sort() # to make merging easier
181 self._prop_save_settings(old, new)
182 @_versioned_property(name="extra_strings",
183 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
185 check_fn=_extra_strings_check_fn,
186 change_hook=_extra_strings_change_hook,
188 def extra_strings(): return {}
190 def __init__(self, bug=None, uuid=None, from_storage=False,
191 in_reply_to=None, body=None, content_type=None):
193 Set ``from_storage=True`` to load an old comment.
194 Set ``from_storage=False`` to create a new comment.
196 The ``uuid`` option is required when ``from_storage==True``.
198 The in_reply_to, body, and content_type options are only used
199 if ``from_storage==False`` (the default). When
200 ``from_storage==True``, they are loaded from the bug database.
201 ``content_type`` decides if the body should be run through
202 :py:func:`util.id.short_to_long_text` before saving. See
203 :py:meth:`_set_comment_body` for details.
205 ``in_reply_to`` should be the uuid string of the parent comment.
208 settings_object.SavedSettingsObject.__init__(self)
212 self.id = libbe.util.id.ID(self, 'comment')
213 if from_storage == False:
215 self.uuid = libbe.util.id.uuid_gen()
216 self.time = int(time.time()) # only save to second precision
217 self.in_reply_to = in_reply_to
218 if content_type != None:
219 self.content_type = content_type
222 self.storage = self.bug.storage
223 if from_storage == False:
224 if self.storage != None and self.storage.is_writeable():
227 def __cmp__(self, other):
228 return cmp_full(self, other)
232 >>> comm = Comment(bug=None, body="Some insightful remarks")
233 >>> comm.uuid = "com-1"
234 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
235 >>> comm.author = "Jane Doe <jdoe@example.com>"
237 --------- Comment ---------
239 From: Jane Doe <jdoe@example.com>
240 Date: Thu, 20 Nov 2008 15:55:11 +0000
242 Some insightful remarks
246 def traverse(self, *args, **kwargs):
247 """Avoid working with the possible dummy root comment"""
248 for comment in Tree.traverse(self, *args, **kwargs):
249 if comment.uuid == INVALID_UUID:
253 # serializing methods
255 def _setting_attr_string(self, setting):
256 value = getattr(self, setting)
259 if type(value) not in types.StringTypes:
263 def safe_in_reply_to(self):
265 Return self.in_reply_to, except...
267 * if no comment matches that id, in which case return None.
268 * if that id matches another comments .alt_id, in which case
269 return the matching comments .uuid.
271 if self.in_reply_to == None:
275 irt_comment = self.bug.comment_from_uuid(
276 self.in_reply_to, match_alt_id=True)
277 return irt_comment.uuid
281 def xml(self, indent=0):
283 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
284 >>> comm.uuid = "0123"
285 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
286 >>> print comm.xml(indent=2)
289 <short-name>//012</short-name>
291 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
292 <content-type>text/plain</content-type>
297 >>> comm.content_type = 'image/png'
301 <short-name>//012</short-name>
303 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
304 <content-type>image/png</content-type>
305 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
309 if self.content_type.startswith('text/'):
310 body = (self.body or '').rstrip('\n')
312 maintype,subtype = self.content_type.split('/',1)
313 msg = MIMEBase(maintype, subtype)
314 msg.set_payload(self.body or '')
316 body = base64.encodestring(self.body or '')
317 info = [('uuid', self.uuid),
318 ('alt-id', self.alt_id),
319 ('short-name', self.id.user()),
320 ('in-reply-to', self.safe_in_reply_to()),
321 ('author', self._setting_attr_string('author')),
323 ('content-type', self.content_type),
325 lines = ['<comment>']
328 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
329 for estr in self.extra_strings:
330 lines.append(' <extra-string>%s</extra-string>' % estr)
331 lines.append('</comment>')
334 return istring + sep.join(lines).rstrip('\n')
336 def from_xml(self, xml_string, preserve_uuids=False):
338 Note: If alt-id is not given, translates any <uuid> fields to
340 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
341 >>> commA.uuid = "0123"
342 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
343 >>> commA.author = u'Fran\xe7ois'
344 >>> commA.extra_strings += ['TAG: very helpful']
345 >>> xml = commA.xml()
346 >>> commB = Comment()
347 >>> commB.from_xml(xml)
348 >>> commB.explicit_attrs
349 ['author', 'date', 'content_type', 'body', 'alt_id']
350 >>> commB.xml() == xml
352 >>> commB.uuid = commB.alt_id
353 >>> commB.alt_id = None
354 >>> commB.xml() == xml
356 >>> commC = Comment()
357 >>> commC.from_xml(xml, preserve_uuids=True)
358 >>> commC.uuid == commA.uuid
361 if type(xml_string) == types.UnicodeType:
362 xml_string = xml_string.strip().encode('unicode_escape')
363 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
366 comment = ElementTree.XML(xml_string)
367 if comment.tag != 'comment':
368 raise utility.InvalidXML( \
369 'comment', comment, 'root element must be <comment>')
370 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
371 'body','extra-string']
372 self.explicit_attrs = []
376 for child in comment.getchildren():
377 if child.tag == 'short-name':
379 elif child.tag in tags:
380 if child.text == None or len(child.text) == 0:
381 text = settings_object.EMPTY
383 text = xml.sax.saxutils.unescape(child.text)
384 if not isinstance(text, unicode):
385 text = text.decode('unicode_escape')
387 if child.tag == 'uuid' and not preserve_uuids:
389 continue # don't set the comment's uuid tag.
390 elif child.tag == 'body':
392 self.explicit_attrs.append(child.tag)
393 continue # don't set the comment's body yet.
394 elif child.tag == 'extra-string':
396 continue # don't set the comment's extra_string yet.
397 attr_name = child.tag.replace('-','_')
398 self.explicit_attrs.append(attr_name)
399 setattr(self, attr_name, text)
402 'ignoring unknown tag {0} in {1}'.format(
403 child.tag, comment.tag))
404 if uuid != self.uuid and self.alt_id == None:
405 self.explicit_attrs.append('alt_id')
408 if self.content_type.startswith('text/'):
409 self.body = body+'\n' # restore trailing newline
411 self.body = base64.decodestring(body)
412 self.extra_strings = estrs
414 def merge(self, other, accept_changes=True,
415 accept_extra_strings=True, change_exception=False):
417 Merge info from other into this comment. Overrides any
418 attributes in self that are listed in other.explicit_attrs.
420 >>> commA = Comment(bug=None, body='Some insightful remarks')
421 >>> commA.uuid = '0123'
422 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
423 >>> commA.author = 'Frank'
424 >>> commA.extra_strings += ['TAG: very helpful']
425 >>> commA.extra_strings += ['TAG: favorite']
426 >>> commB = Comment(bug=None, body='More insightful remarks')
427 >>> commB.uuid = '3210'
428 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
429 >>> commB.author = 'John'
430 >>> commB.explicit_attrs = ['author', 'body']
431 >>> commB.extra_strings += ['TAG: very helpful']
432 >>> commB.extra_strings += ['TAG: useful']
433 >>> commA.merge(commB, accept_changes=False,
434 ... accept_extra_strings=False, change_exception=False)
435 >>> commA.merge(commB, accept_changes=False,
436 ... accept_extra_strings=False, change_exception=True)
437 Traceback (most recent call last):
439 ValueError: Merge would change author "Frank"->"John" for comment 0123
440 >>> commA.merge(commB, accept_changes=True,
441 ... accept_extra_strings=False, change_exception=True)
442 Traceback (most recent call last):
444 ValueError: Merge would add extra string "TAG: useful" to comment 0123
445 >>> print commA.author
447 >>> print commA.extra_strings
448 ['TAG: favorite', 'TAG: very helpful']
449 >>> commA.merge(commB, accept_changes=True,
450 ... accept_extra_strings=True, change_exception=True)
451 >>> print commA.extra_strings
452 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
453 >>> print commA.xml()
456 <short-name>//012</short-name>
457 <author>John</author>
458 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
459 <content-type>text/plain</content-type>
460 <body>More insightful remarks</body>
461 <extra-string>TAG: favorite</extra-string>
462 <extra-string>TAG: useful</extra-string>
463 <extra-string>TAG: very helpful</extra-string>
466 if hasattr(other, 'explicit_attrs'):
467 for attr in other.explicit_attrs:
468 old = getattr(self, attr)
469 new = getattr(other, attr)
472 setattr(self, attr, new)
473 elif change_exception:
475 ('Merge would change {} "{}"->"{}" for comment {}'
476 ).format(attr, old, new, self.uuid))
477 if self.alt_id == self.uuid:
479 for estr in other.extra_strings:
480 if not estr in self.extra_strings:
481 if accept_extra_strings == True:
482 self.extra_strings.append(estr)
483 elif change_exception == True:
485 'Merge would add extra string "%s" to comment %s' \
488 def string(self, indent=0):
490 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
491 >>> comm.uuid = 'abcdef'
492 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
493 >>> print comm.string(indent=2)
494 --------- Comment ---------
497 Date: Thu, 01 Jan 1970 00:00:00 +0000
504 lines.append("--------- Comment ---------")
505 lines.append("Name: %s" % self.id.user())
506 lines.append("From: %s" % (self._setting_attr_string("author")))
507 lines.append("Date: %s" % self.date)
509 if self.content_type.startswith("text/"):
510 body = (self.body or "")
511 if self.bug != None and self.bug.bugdir != None:
512 body = libbe.util.id.long_to_short_text(
513 {self.bug.bugdir.uuid: self.bug.bugdir}, body)
514 lines.extend(body.splitlines())
516 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
520 return istring + sep.join(lines).rstrip('\n')
522 def string_thread(self, string_method_name="string",
523 indent=0, flatten=True):
525 Return a string displaying a thread of comments.
526 bug_shortname is only used if auto_name_map == True.
528 string_method_name (defaults to "string") is the name of the
529 Comment method used to generate the output string for each
530 Comment in the thread. The method must take the arguments
531 indent and shortname.
533 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
534 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
535 >>> b = a.new_reply("Critique original comment")
537 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
538 >>> c = b.new_reply("Begin flamewar :p")
540 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
541 >>> d = a.new_reply("Useful examples")
543 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
544 >>> a.sort(key=lambda comm : comm.time)
545 >>> print a.string_thread(flatten=True)
546 --------- Comment ---------
549 Date: Thu, 20 Nov 2008 01:00:00 +0000
552 --------- Comment ---------
555 Date: Thu, 20 Nov 2008 02:00:00 +0000
557 Critique original comment
558 --------- Comment ---------
561 Date: Thu, 20 Nov 2008 03:00:00 +0000
564 --------- Comment ---------
567 Date: Thu, 20 Nov 2008 04:00:00 +0000
570 >>> print a.string_thread()
571 --------- Comment ---------
574 Date: Thu, 20 Nov 2008 01:00:00 +0000
577 --------- Comment ---------
580 Date: Thu, 20 Nov 2008 02:00:00 +0000
582 Critique original comment
583 --------- Comment ---------
586 Date: Thu, 20 Nov 2008 03:00:00 +0000
589 --------- Comment ---------
592 Date: Thu, 20 Nov 2008 04:00:00 +0000
597 for depth,comment in self.thread(flatten=flatten):
599 string_fn = getattr(comment, string_method_name)
600 stringlist.append(string_fn(indent=ind))
601 return '\n'.join(stringlist)
603 def xml_thread(self, indent=0):
604 return self.string_thread(string_method_name="xml", indent=indent)
606 # methods for saving/loading/acessing settings and properties.
608 def load_settings(self, settings_mapfile=None):
609 if self.uuid == INVALID_UUID:
611 if settings_mapfile == None:
612 settings_mapfile = self.storage.get(
613 self.id.storage('values'), '{}\n')
615 settings = mapfile.parse(settings_mapfile)
616 except mapfile.InvalidMapfileContents, e:
617 raise Exception('Invalid settings file for comment %s\n'
618 '(BE version missmatch?)' % self.id.user())
619 self._setup_saved_settings(settings)
621 def save_settings(self):
622 if self.uuid == INVALID_UUID:
624 mf = mapfile.generate(self._get_saved_settings())
625 self.storage.set(self.id.storage("values"), mf)
629 Save any loaded contents to storage.
631 However, if ``self.storage.is_writeable() == True``, then any
632 changes are automatically written to storage as soon as they
633 happen, so calling this method will just waste time (unless
634 something else has been messing with your stored files).
636 if self.uuid == INVALID_UUID:
638 assert self.storage != None, "Can't save without storage"
639 assert self.body != None, "Can't save blank comment"
641 parent = self.bug.id.storage()
644 self.storage.add(self.id.storage(), parent=parent, directory=True)
645 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
647 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
650 self._set_comment_body(new=self.body, force=True)
655 if self.uuid != INVALID_UUID:
656 self.storage.recursive_remove(self.id.storage())
658 def add_reply(self, reply, allow_time_inversion=False):
659 if self.uuid != INVALID_UUID:
660 reply.in_reply_to = self.uuid
663 def new_reply(self, body=None, content_type=None):
665 >>> comm = Comment(bug=None, body="Some insightful remarks")
666 >>> repA = comm.new_reply("Critique original comment")
667 >>> repB = repA.new_reply("Begin flamewar :p")
668 >>> repB.in_reply_to == repA.uuid
671 reply = Comment(self.bug, body=body, content_type=content_type)
672 self.add_reply(reply)
675 def comment_from_uuid(self, uuid, match_alt_id=True):
676 """Use a uuid to look up a comment.
678 >>> a = Comment(bug=None, uuid="a")
679 >>> b = a.new_reply()
681 >>> c = b.new_reply()
683 >>> d = a.new_reply()
685 >>> d.alt_id = "d-alt"
686 >>> comm = a.comment_from_uuid("d")
687 >>> id(comm) == id(d)
689 >>> comm = a.comment_from_uuid("d-alt")
690 >>> id(comm) == id(d)
692 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
693 Traceback (most recent call last):
697 for comment in self.traverse():
698 if comment.uuid == uuid:
700 if match_alt_id == True and uuid != None \
701 and comment.alt_id == uuid:
705 # methods for id generation
707 def sibling_uuids(self):
709 return self.bug.uuids()
713 def cmp_attr(comment_1, comment_2, attr, invert=False):
715 Compare a general attribute between two comments using the conventional
716 comparison rule for that attribute type. If invert == True, sort
717 *against* that convention.
720 >>> commentA = Comment()
721 >>> commentB = Comment()
722 >>> commentA.author = "John Doe"
723 >>> commentB.author = "Jane Doe"
724 >>> cmp_attr(commentA, commentB, attr) > 0
726 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
728 >>> commentB.author = "John Doe"
729 >>> cmp_attr(commentA, commentB, attr) == 0
732 if not hasattr(comment_2, attr) :
734 val_1 = getattr(comment_1, attr)
735 val_2 = getattr(comment_2, attr)
736 if val_1 == None: val_1 = None
737 if val_2 == None: val_2 = None
740 return -cmp(val_1, val_2)
742 return cmp(val_1, val_2)
744 # alphabetical rankings (a < z)
745 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
746 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
747 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
748 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
749 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
750 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
751 # chronological rankings (newer < older)
752 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
755 DEFAULT_CMP_FULL_CMP_LIST = \
756 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
757 cmp_uuid, cmp_extra_strings)
759 class CommentCompoundComparator (object):
760 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
761 self.cmp_list = cmp_list
762 def __call__(self, comment_1, comment_2):
763 for comparison in self.cmp_list :
764 val = comparison(comment_1, comment_2)
769 cmp_full = CommentCompoundComparator()
771 if libbe.TESTING == True:
772 suite = doctest.DocTestSuite()