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, verbose=True):
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, verbose=True)
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)
400 elif verbose == True:
401 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
402 % (child.tag, comment.tag)
403 if uuid != self.uuid and self.alt_id == None:
404 self.explicit_attrs.append('alt_id')
407 if self.content_type.startswith('text/'):
408 self.body = body+'\n' # restore trailing newline
410 self.body = base64.decodestring(body)
411 self.extra_strings = estrs
413 def merge(self, other, accept_changes=True,
414 accept_extra_strings=True, change_exception=False):
416 Merge info from other into this comment. Overrides any
417 attributes in self that are listed in other.explicit_attrs.
419 >>> commA = Comment(bug=None, body='Some insightful remarks')
420 >>> commA.uuid = '0123'
421 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
422 >>> commA.author = 'Frank'
423 >>> commA.extra_strings += ['TAG: very helpful']
424 >>> commA.extra_strings += ['TAG: favorite']
425 >>> commB = Comment(bug=None, body='More insightful remarks')
426 >>> commB.uuid = '3210'
427 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
428 >>> commB.author = 'John'
429 >>> commB.explicit_attrs = ['author', 'body']
430 >>> commB.extra_strings += ['TAG: very helpful']
431 >>> commB.extra_strings += ['TAG: useful']
432 >>> commA.merge(commB, accept_changes=False,
433 ... accept_extra_strings=False, change_exception=False)
434 >>> commA.merge(commB, accept_changes=False,
435 ... accept_extra_strings=False, change_exception=True)
436 Traceback (most recent call last):
438 ValueError: Merge would change author "Frank"->"John" for comment 0123
439 >>> commA.merge(commB, accept_changes=True,
440 ... accept_extra_strings=False, change_exception=True)
441 Traceback (most recent call last):
443 ValueError: Merge would add extra string "TAG: useful" to comment 0123
444 >>> print commA.author
446 >>> print commA.extra_strings
447 ['TAG: favorite', 'TAG: very helpful']
448 >>> commA.merge(commB, accept_changes=True,
449 ... accept_extra_strings=True, change_exception=True)
450 >>> print commA.extra_strings
451 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
452 >>> print commA.xml()
455 <short-name>//012</short-name>
456 <author>John</author>
457 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
458 <content-type>text/plain</content-type>
459 <body>More insightful remarks</body>
460 <extra-string>TAG: favorite</extra-string>
461 <extra-string>TAG: useful</extra-string>
462 <extra-string>TAG: very helpful</extra-string>
465 if hasattr(other, 'explicit_attrs'):
466 for attr in other.explicit_attrs:
467 old = getattr(self, attr)
468 new = getattr(other, attr)
471 setattr(self, attr, new)
472 elif change_exception:
474 ('Merge would change {} "{}"->"{}" for comment {}'
475 ).format(attr, old, new, self.uuid))
476 if self.alt_id == self.uuid:
478 for estr in other.extra_strings:
479 if not estr in self.extra_strings:
480 if accept_extra_strings == True:
481 self.extra_strings.append(estr)
482 elif change_exception == True:
484 'Merge would add extra string "%s" to comment %s' \
487 def string(self, indent=0):
489 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
490 >>> comm.uuid = 'abcdef'
491 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
492 >>> print comm.string(indent=2)
493 --------- Comment ---------
496 Date: Thu, 01 Jan 1970 00:00:00 +0000
503 lines.append("--------- Comment ---------")
504 lines.append("Name: %s" % self.id.user())
505 lines.append("From: %s" % (self._setting_attr_string("author")))
506 lines.append("Date: %s" % self.date)
508 if self.content_type.startswith("text/"):
509 body = (self.body or "")
510 if self.bug != None and self.bug.bugdir != None:
511 body = libbe.util.id.long_to_short_text(
512 {self.bug.bugdir.uuid: self.bug.bugdir}, body)
513 lines.extend(body.splitlines())
515 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
519 return istring + sep.join(lines).rstrip('\n')
521 def string_thread(self, string_method_name="string",
522 indent=0, flatten=True):
524 Return a string displaying a thread of comments.
525 bug_shortname is only used if auto_name_map == True.
527 string_method_name (defaults to "string") is the name of the
528 Comment method used to generate the output string for each
529 Comment in the thread. The method must take the arguments
530 indent and shortname.
532 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
533 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
534 >>> b = a.new_reply("Critique original comment")
536 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
537 >>> c = b.new_reply("Begin flamewar :p")
539 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
540 >>> d = a.new_reply("Useful examples")
542 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
543 >>> a.sort(key=lambda comm : comm.time)
544 >>> print a.string_thread(flatten=True)
545 --------- Comment ---------
548 Date: Thu, 20 Nov 2008 01:00:00 +0000
551 --------- Comment ---------
554 Date: Thu, 20 Nov 2008 02:00:00 +0000
556 Critique original comment
557 --------- Comment ---------
560 Date: Thu, 20 Nov 2008 03:00:00 +0000
563 --------- Comment ---------
566 Date: Thu, 20 Nov 2008 04:00:00 +0000
569 >>> print a.string_thread()
570 --------- Comment ---------
573 Date: Thu, 20 Nov 2008 01:00:00 +0000
576 --------- Comment ---------
579 Date: Thu, 20 Nov 2008 02:00:00 +0000
581 Critique original comment
582 --------- Comment ---------
585 Date: Thu, 20 Nov 2008 03:00:00 +0000
588 --------- Comment ---------
591 Date: Thu, 20 Nov 2008 04:00:00 +0000
596 for depth,comment in self.thread(flatten=flatten):
598 string_fn = getattr(comment, string_method_name)
599 stringlist.append(string_fn(indent=ind))
600 return '\n'.join(stringlist)
602 def xml_thread(self, indent=0):
603 return self.string_thread(string_method_name="xml", indent=indent)
605 # methods for saving/loading/acessing settings and properties.
607 def load_settings(self, settings_mapfile=None):
608 if self.uuid == INVALID_UUID:
610 if settings_mapfile == None:
611 settings_mapfile = self.storage.get(
612 self.id.storage('values'), '{}\n')
614 settings = mapfile.parse(settings_mapfile)
615 except mapfile.InvalidMapfileContents, e:
616 raise Exception('Invalid settings file for comment %s\n'
617 '(BE version missmatch?)' % self.id.user())
618 self._setup_saved_settings(settings)
620 def save_settings(self):
621 if self.uuid == INVALID_UUID:
623 mf = mapfile.generate(self._get_saved_settings())
624 self.storage.set(self.id.storage("values"), mf)
628 Save any loaded contents to storage.
630 However, if ``self.storage.is_writeable() == True``, then any
631 changes are automatically written to storage as soon as they
632 happen, so calling this method will just waste time (unless
633 something else has been messing with your stored files).
635 if self.uuid == INVALID_UUID:
637 assert self.storage != None, "Can't save without storage"
638 assert self.body != None, "Can't save blank comment"
640 parent = self.bug.id.storage()
643 self.storage.add(self.id.storage(), parent=parent, directory=True)
644 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
646 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
649 self._set_comment_body(new=self.body, force=True)
654 if self.uuid != INVALID_UUID:
655 self.storage.recursive_remove(self.id.storage())
657 def add_reply(self, reply, allow_time_inversion=False):
658 if self.uuid != INVALID_UUID:
659 reply.in_reply_to = self.uuid
662 def new_reply(self, body=None, content_type=None):
664 >>> comm = Comment(bug=None, body="Some insightful remarks")
665 >>> repA = comm.new_reply("Critique original comment")
666 >>> repB = repA.new_reply("Begin flamewar :p")
667 >>> repB.in_reply_to == repA.uuid
670 reply = Comment(self.bug, body=body, content_type=content_type)
671 self.add_reply(reply)
674 def comment_from_uuid(self, uuid, match_alt_id=True):
675 """Use a uuid to look up a comment.
677 >>> a = Comment(bug=None, uuid="a")
678 >>> b = a.new_reply()
680 >>> c = b.new_reply()
682 >>> d = a.new_reply()
684 >>> d.alt_id = "d-alt"
685 >>> comm = a.comment_from_uuid("d")
686 >>> id(comm) == id(d)
688 >>> comm = a.comment_from_uuid("d-alt")
689 >>> id(comm) == id(d)
691 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
692 Traceback (most recent call last):
696 for comment in self.traverse():
697 if comment.uuid == uuid:
699 if match_alt_id == True and uuid != None \
700 and comment.alt_id == uuid:
704 # methods for id generation
706 def sibling_uuids(self):
708 return self.bug.uuids()
712 def cmp_attr(comment_1, comment_2, attr, invert=False):
714 Compare a general attribute between two comments using the conventional
715 comparison rule for that attribute type. If invert == True, sort
716 *against* that convention.
719 >>> commentA = Comment()
720 >>> commentB = Comment()
721 >>> commentA.author = "John Doe"
722 >>> commentB.author = "Jane Doe"
723 >>> cmp_attr(commentA, commentB, attr) > 0
725 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
727 >>> commentB.author = "John Doe"
728 >>> cmp_attr(commentA, commentB, attr) == 0
731 if not hasattr(comment_2, attr) :
733 val_1 = getattr(comment_1, attr)
734 val_2 = getattr(comment_2, attr)
735 if val_1 == None: val_1 = None
736 if val_2 == None: val_2 = None
739 return -cmp(val_1, val_2)
741 return cmp(val_1, val_2)
743 # alphabetical rankings (a < z)
744 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
745 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
746 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
747 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
748 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
749 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
750 # chronological rankings (newer < older)
751 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
754 DEFAULT_CMP_FULL_CMP_LIST = \
755 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
756 cmp_uuid, cmp_extra_strings)
758 class CommentCompoundComparator (object):
759 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
760 self.cmp_list = cmp_list
761 def __call__(self, comment_1, comment_2):
762 for comparison in self.cmp_list :
763 val = comparison(comment_1, comment_2)
768 cmp_full = CommentCompoundComparator()
770 if libbe.TESTING == True:
771 suite = doctest.DocTestSuite()