1 # Copyright (C) 2008-2012 Chris Ball <cjb@laptop.org>
2 # Gianluca Montecchi <gian@grys.it>
3 # Thomas Habets <thomas@habets.pp.se>
4 # W. Trevor King <wking@drexel.edu>
6 # This file is part of Bugs Everywhere.
8 # Bugs Everywhere is free software: you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by the Free
10 # Software Foundation, either version 2 of the License, or (at your option) any
13 # Bugs Everywhere is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
15 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
18 # You should have received a copy of the GNU General Public License along with
19 # Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
21 """Define the :class:`Comment` class for representing bug comments.
31 from email.mime.base import MIMEBase
32 from email.encoders import encode_base64
34 # adjust to old python 2.4
35 from email.MIMEBase import MIMEBase
36 from email.Encoders import encode_base64
37 try: # import core module, Python >= 2.5
38 from xml.etree import ElementTree
39 except ImportError: # look for non-core module
40 from elementtree import ElementTree
41 import xml.sax.saxutils
45 from libbe.storage.util.properties import Property, doc_property, \
46 local_property, defaulting_property, checked_property, cached_property, \
47 primed_property, change_hook_property, settings_property
48 import libbe.storage.util.settings_object as settings_object
49 import libbe.storage.util.mapfile as mapfile
50 from libbe.util.tree import Tree
51 import libbe.util.utility as utility
53 if libbe.TESTING == True:
57 class MissingReference(ValueError):
58 def __init__(self, comment):
59 msg = "Missing reference to %s" % (comment.in_reply_to)
60 ValueError.__init__(self, msg)
61 self.reference = comment.in_reply_to
62 self.comment = comment
64 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
66 def load_comments(bug, load_full=False):
68 Set load_full=True when you want to load the comment completely
69 from disk *now*, rather than waiting and lazy loading as required.
72 for id in libbe.util.id.child_uuids(
78 comm = Comment(bug, uuid, from_storage=True)
81 dummy = comm.body # force the body to load
83 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
84 bug.add_comments(comments, ignore_missing_references=True)
85 return bug.comment_root
87 def save_comments(bug):
88 for comment in bug.comment_root.traverse():
92 class Comment (Tree, settings_object.SavedSettingsObject):
93 """Comments are a notes that attach to :class:`~libbe.bug.Bug`\s in
94 threaded trees. In mailing-list terms, a comment is analogous to
95 a single part of an email.
100 >>> c.uuid = "some-UUID"
101 >>> print c.content_type
105 settings_properties = []
106 required_saved_properties = []
107 _prop_save_settings = settings_object.prop_save_settings
108 _prop_load_settings = settings_object.prop_load_settings
109 def _versioned_property(settings_properties=settings_properties,
110 required_saved_properties=required_saved_properties,
112 if "settings_properties" not in kwargs:
113 kwargs["settings_properties"] = settings_properties
114 if "required_saved_properties" not in kwargs:
115 kwargs["required_saved_properties"]=required_saved_properties
116 return settings_object.versioned_property(**kwargs)
118 @_versioned_property(name="Alt-id",
119 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.")
120 def alt_id(): return {}
122 @_versioned_property(name="Author",
123 doc="The author of the comment")
124 def author(): return {}
126 @_versioned_property(name="In-reply-to",
127 doc="UUID for parent comment or bug")
128 def in_reply_to(): return {}
130 @_versioned_property(name="Content-type",
131 doc="Mime type for comment body",
132 default="text/plain",
134 def content_type(): return {}
136 @_versioned_property(name="Date",
137 doc="An RFC 2822 timestamp for comment creation")
138 def date(): return {}
141 if self.date == None:
143 return utility.str_to_time(self.date)
144 def _set_time(self, value):
145 self.date = utility.time_to_str(value)
146 time = property(fget=_get_time,
148 doc="An integer version of .date")
150 def _get_comment_body(self):
151 if self.storage != None and self.storage.is_readable() \
152 and self.uuid != INVALID_UUID:
153 return self.storage.get(self.id.storage("body"),
154 decode=self.content_type.startswith("text/"))
155 def _set_comment_body(self, old=None, new=None, force=False):
156 assert self.uuid != INVALID_UUID, self
157 if self.content_type.startswith('text/') \
158 and self.bug != None and self.bug.bugdir != None:
159 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
160 if (self.storage != None and self.storage.writeable == True) \
162 assert new != None, "Can't save empty comment"
163 self.storage.set(self.id.storage("body"), new)
166 @change_hook_property(hook=_set_comment_body)
167 @cached_property(generator=_get_comment_body)
168 @local_property("body")
169 @doc_property(doc="The meat of the comment")
170 def body(): return {}
172 def _extra_strings_check_fn(value):
173 return utility.iterable_full_of_strings(value, \
174 alternative=settings_object.EMPTY)
175 def _extra_strings_change_hook(self, old, new):
176 self.extra_strings.sort() # to make merging easier
177 self._prop_save_settings(old, new)
178 @_versioned_property(name="extra_strings",
179 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
181 check_fn=_extra_strings_check_fn,
182 change_hook=_extra_strings_change_hook,
184 def extra_strings(): return {}
186 def __init__(self, bug=None, uuid=None, from_storage=False,
187 in_reply_to=None, body=None, content_type=None):
189 Set ``from_storage=True`` to load an old comment.
190 Set ``from_storage=False`` to create a new comment.
192 The ``uuid`` option is required when ``from_storage==True``.
194 The in_reply_to, body, and content_type options are only used
195 if ``from_storage==False`` (the default). When
196 ``from_storage==True``, they are loaded from the bug database.
197 ``content_type`` decides if the body should be run through
198 :func:`util.id.short_to_long_text` before saving. See
199 :meth:`_set_comment_body` for details.
201 ``in_reply_to`` should be the uuid string of the parent comment.
204 settings_object.SavedSettingsObject.__init__(self)
208 self.id = libbe.util.id.ID(self, 'comment')
209 if from_storage == False:
211 self.uuid = libbe.util.id.uuid_gen()
212 self.time = int(time.time()) # only save to second precision
213 self.in_reply_to = in_reply_to
214 if content_type != None:
215 self.content_type = content_type
218 self.storage = self.bug.storage
219 if from_storage == False:
220 if self.storage != None and self.storage.is_writeable():
223 def __cmp__(self, other):
224 return cmp_full(self, other)
228 >>> comm = Comment(bug=None, body="Some insightful remarks")
229 >>> comm.uuid = "com-1"
230 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
231 >>> comm.author = "Jane Doe <jdoe@example.com>"
233 --------- Comment ---------
235 From: Jane Doe <jdoe@example.com>
236 Date: Thu, 20 Nov 2008 15:55:11 +0000
238 Some insightful remarks
242 def traverse(self, *args, **kwargs):
243 """Avoid working with the possible dummy root comment"""
244 for comment in Tree.traverse(self, *args, **kwargs):
245 if comment.uuid == INVALID_UUID:
249 # serializing methods
251 def _setting_attr_string(self, setting):
252 value = getattr(self, setting)
255 if type(value) not in types.StringTypes:
259 def safe_in_reply_to(self):
261 Return self.in_reply_to, except...
263 * if no comment matches that id, in which case return None.
264 * if that id matches another comments .alt_id, in which case
265 return the matching comments .uuid.
267 if self.in_reply_to == None:
271 irt_comment = self.bug.comment_from_uuid(
272 self.in_reply_to, match_alt_id=True)
273 return irt_comment.uuid
277 def xml(self, indent=0):
279 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
280 >>> comm.uuid = "0123"
281 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
282 >>> print comm.xml(indent=2)
285 <short-name>//012</short-name>
287 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
288 <content-type>text/plain</content-type>
293 >>> comm.content_type = 'image/png'
297 <short-name>//012</short-name>
299 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
300 <content-type>image/png</content-type>
301 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
305 if self.content_type.startswith('text/'):
306 body = (self.body or '').rstrip('\n')
308 maintype,subtype = self.content_type.split('/',1)
309 msg = MIMEBase(maintype, subtype)
310 msg.set_payload(self.body or '')
312 body = base64.encodestring(self.body or '')
313 info = [('uuid', self.uuid),
314 ('alt-id', self.alt_id),
315 ('short-name', self.id.user()),
316 ('in-reply-to', self.safe_in_reply_to()),
317 ('author', self._setting_attr_string('author')),
319 ('content-type', self.content_type),
321 lines = ['<comment>']
324 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
325 for estr in self.extra_strings:
326 lines.append(' <extra-string>%s</extra-string>' % estr)
327 lines.append('</comment>')
330 return istring + sep.join(lines).rstrip('\n')
332 def from_xml(self, xml_string, preserve_uuids=False, verbose=True):
334 Note: If alt-id is not given, translates any <uuid> fields to
336 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
337 >>> commA.uuid = "0123"
338 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
339 >>> commA.author = u'Fran\xe7ois'
340 >>> commA.extra_strings += ['TAG: very helpful']
341 >>> xml = commA.xml()
342 >>> commB = Comment()
343 >>> commB.from_xml(xml, verbose=True)
344 >>> commB.explicit_attrs
345 ['author', 'date', 'content_type', 'body', 'alt_id']
346 >>> commB.xml() == xml
348 >>> commB.uuid = commB.alt_id
349 >>> commB.alt_id = None
350 >>> commB.xml() == xml
352 >>> commC = Comment()
353 >>> commC.from_xml(xml, preserve_uuids=True)
354 >>> commC.uuid == commA.uuid
357 if type(xml_string) == types.UnicodeType:
358 xml_string = xml_string.strip().encode('unicode_escape')
359 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
362 comment = ElementTree.XML(xml_string)
363 if comment.tag != 'comment':
364 raise utility.InvalidXML( \
365 'comment', comment, 'root element must be <comment>')
366 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
367 'body','extra-string']
368 self.explicit_attrs = []
372 for child in comment.getchildren():
373 if child.tag == 'short-name':
375 elif child.tag in tags:
376 if child.text == None or len(child.text) == 0:
377 text = settings_object.EMPTY
379 text = xml.sax.saxutils.unescape(child.text)
380 # Sometimes saxutils returns unicode
381 if not isinstance(text, unicode):
382 text = text.decode('unicode_escape')
384 if child.tag == 'uuid' and not preserve_uuids:
386 continue # don't set the comment's uuid tag.
387 elif child.tag == 'body':
389 self.explicit_attrs.append(child.tag)
390 continue # don't set the comment's body yet.
391 elif child.tag == 'extra-string':
393 continue # don't set the comment's extra_string yet.
394 attr_name = child.tag.replace('-','_')
395 self.explicit_attrs.append(attr_name)
396 setattr(self, attr_name, text)
397 elif verbose == True:
398 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
399 % (child.tag, comment.tag)
400 if uuid != self.uuid and self.alt_id == None:
401 self.explicit_attrs.append('alt_id')
404 if self.content_type.startswith('text/'):
405 self.body = body+'\n' # restore trailing newline
407 self.body = base64.decodestring(body)
408 self.extra_strings = estrs
410 def merge(self, other, accept_changes=True,
411 accept_extra_strings=True, change_exception=False):
413 Merge info from other into this comment. Overrides any
414 attributes in self that are listed in other.explicit_attrs.
416 >>> commA = Comment(bug=None, body='Some insightful remarks')
417 >>> commA.uuid = '0123'
418 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
419 >>> commA.author = 'Frank'
420 >>> commA.extra_strings += ['TAG: very helpful']
421 >>> commA.extra_strings += ['TAG: favorite']
422 >>> commB = Comment(bug=None, body='More insightful remarks')
423 >>> commB.uuid = '3210'
424 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
425 >>> commB.author = 'John'
426 >>> commB.explicit_attrs = ['author', 'body']
427 >>> commB.extra_strings += ['TAG: very helpful']
428 >>> commB.extra_strings += ['TAG: useful']
429 >>> commA.merge(commB, accept_changes=False,
430 ... accept_extra_strings=False, change_exception=False)
431 >>> commA.merge(commB, accept_changes=False,
432 ... accept_extra_strings=False, change_exception=True)
433 Traceback (most recent call last):
435 ValueError: Merge would change author "Frank"->"John" for comment 0123
436 >>> commA.merge(commB, accept_changes=True,
437 ... accept_extra_strings=False, change_exception=True)
438 Traceback (most recent call last):
440 ValueError: Merge would add extra string "TAG: useful" to comment 0123
441 >>> print commA.author
443 >>> print commA.extra_strings
444 ['TAG: favorite', 'TAG: very helpful']
445 >>> commA.merge(commB, accept_changes=True,
446 ... accept_extra_strings=True, change_exception=True)
447 >>> print commA.extra_strings
448 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
449 >>> print commA.xml()
452 <short-name>//012</short-name>
453 <author>John</author>
454 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
455 <content-type>text/plain</content-type>
456 <body>More insightful remarks</body>
457 <extra-string>TAG: favorite</extra-string>
458 <extra-string>TAG: useful</extra-string>
459 <extra-string>TAG: very helpful</extra-string>
462 for attr in other.explicit_attrs:
463 old = getattr(self, attr)
464 new = getattr(other, attr)
466 if accept_changes == True:
467 setattr(self, attr, new)
468 elif change_exception == True:
470 'Merge would change %s "%s"->"%s" for comment %s' \
471 % (attr, old, new, self.uuid)
472 if self.alt_id == self.uuid:
474 for estr in other.extra_strings:
475 if not estr in self.extra_strings:
476 if accept_extra_strings == True:
477 self.extra_strings.append(estr)
478 elif change_exception == True:
480 'Merge would add extra string "%s" to comment %s' \
483 def string(self, indent=0):
485 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
486 >>> comm.uuid = 'abcdef'
487 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
488 >>> print comm.string(indent=2)
489 --------- Comment ---------
492 Date: Thu, 01 Jan 1970 00:00:00 +0000
499 lines.append("--------- Comment ---------")
500 lines.append("Name: %s" % self.id.user())
501 lines.append("From: %s" % (self._setting_attr_string("author")))
502 lines.append("Date: %s" % self.date)
504 if self.content_type.startswith("text/"):
505 body = (self.body or "")
506 if self.bug != None and self.bug.bugdir != None:
507 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
508 lines.extend(body.splitlines())
510 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
514 return istring + sep.join(lines).rstrip('\n')
516 def string_thread(self, string_method_name="string",
517 indent=0, flatten=True):
519 Return a string displaying a thread of comments.
520 bug_shortname is only used if auto_name_map == True.
522 string_method_name (defaults to "string") is the name of the
523 Comment method used to generate the output string for each
524 Comment in the thread. The method must take the arguments
525 indent and shortname.
527 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
528 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
529 >>> b = a.new_reply("Critique original comment")
531 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
532 >>> c = b.new_reply("Begin flamewar :p")
534 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
535 >>> d = a.new_reply("Useful examples")
537 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
538 >>> a.sort(key=lambda comm : comm.time)
539 >>> print a.string_thread(flatten=True)
540 --------- Comment ---------
543 Date: Thu, 20 Nov 2008 01:00:00 +0000
546 --------- Comment ---------
549 Date: Thu, 20 Nov 2008 02:00:00 +0000
551 Critique original comment
552 --------- Comment ---------
555 Date: Thu, 20 Nov 2008 03:00:00 +0000
558 --------- Comment ---------
561 Date: Thu, 20 Nov 2008 04:00:00 +0000
564 >>> print a.string_thread()
565 --------- Comment ---------
568 Date: Thu, 20 Nov 2008 01:00:00 +0000
571 --------- Comment ---------
574 Date: Thu, 20 Nov 2008 02:00:00 +0000
576 Critique original comment
577 --------- Comment ---------
580 Date: Thu, 20 Nov 2008 03:00:00 +0000
583 --------- Comment ---------
586 Date: Thu, 20 Nov 2008 04:00:00 +0000
591 for depth,comment in self.thread(flatten=flatten):
593 string_fn = getattr(comment, string_method_name)
594 stringlist.append(string_fn(indent=ind))
595 return '\n'.join(stringlist)
597 def xml_thread(self, indent=0):
598 return self.string_thread(string_method_name="xml", indent=indent)
600 # methods for saving/loading/acessing settings and properties.
602 def load_settings(self, settings_mapfile=None):
603 if self.uuid == INVALID_UUID:
605 if settings_mapfile == None:
606 settings_mapfile = self.storage.get(
607 self.id.storage('values'), '\n')
609 settings = mapfile.parse(settings_mapfile)
610 except mapfile.InvalidMapfileContents, e:
611 raise Exception('Invalid settings file for comment %s\n'
612 '(BE version missmatch?)' % self.id.user())
613 self._setup_saved_settings(settings)
615 def save_settings(self):
616 if self.uuid == INVALID_UUID:
618 mf = mapfile.generate(self._get_saved_settings())
619 self.storage.set(self.id.storage("values"), mf)
623 Save any loaded contents to storage.
625 However, if ``self.storage.is_writeable() == True``, then any
626 changes are automatically written to storage as soon as they
627 happen, so calling this method will just waste time (unless
628 something else has been messing with your stored files).
630 if self.uuid == INVALID_UUID:
632 assert self.storage != None, "Can't save without storage"
633 assert self.body != None, "Can't save blank comment"
635 parent = self.bug.id.storage()
638 self.storage.add(self.id.storage(), parent=parent, directory=True)
639 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
641 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
644 self._set_comment_body(new=self.body, force=True)
649 if self.uuid != INVALID_UUID:
650 self.storage.recursive_remove(self.id.storage())
652 def add_reply(self, reply, allow_time_inversion=False):
653 if self.uuid != INVALID_UUID:
654 reply.in_reply_to = self.uuid
657 def new_reply(self, body=None, content_type=None):
659 >>> comm = Comment(bug=None, body="Some insightful remarks")
660 >>> repA = comm.new_reply("Critique original comment")
661 >>> repB = repA.new_reply("Begin flamewar :p")
662 >>> repB.in_reply_to == repA.uuid
665 reply = Comment(self.bug, body=body, content_type=content_type)
666 self.add_reply(reply)
669 def comment_from_uuid(self, uuid, match_alt_id=True):
670 """Use a uuid to look up a comment.
672 >>> a = Comment(bug=None, uuid="a")
673 >>> b = a.new_reply()
675 >>> c = b.new_reply()
677 >>> d = a.new_reply()
679 >>> d.alt_id = "d-alt"
680 >>> comm = a.comment_from_uuid("d")
681 >>> id(comm) == id(d)
683 >>> comm = a.comment_from_uuid("d-alt")
684 >>> id(comm) == id(d)
686 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
687 Traceback (most recent call last):
691 for comment in self.traverse():
692 if comment.uuid == uuid:
694 if match_alt_id == True and uuid != None \
695 and comment.alt_id == uuid:
699 # methods for id generation
701 def sibling_uuids(self):
703 return self.bug.uuids()
707 def cmp_attr(comment_1, comment_2, attr, invert=False):
709 Compare a general attribute between two comments using the conventional
710 comparison rule for that attribute type. If invert == True, sort
711 *against* that convention.
714 >>> commentA = Comment()
715 >>> commentB = Comment()
716 >>> commentA.author = "John Doe"
717 >>> commentB.author = "Jane Doe"
718 >>> cmp_attr(commentA, commentB, attr) > 0
720 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
722 >>> commentB.author = "John Doe"
723 >>> cmp_attr(commentA, commentB, attr) == 0
726 if not hasattr(comment_2, attr) :
728 val_1 = getattr(comment_1, attr)
729 val_2 = getattr(comment_2, attr)
730 if val_1 == None: val_1 = None
731 if val_2 == None: val_2 = None
734 return -cmp(val_1, val_2)
736 return cmp(val_1, val_2)
738 # alphabetical rankings (a < z)
739 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
740 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
741 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
742 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
743 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
744 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
745 # chronological rankings (newer < older)
746 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
749 DEFAULT_CMP_FULL_CMP_LIST = \
750 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
751 cmp_uuid, cmp_extra_strings)
753 class CommentCompoundComparator (object):
754 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
755 self.cmp_list = cmp_list
756 def __call__(self, comment_1, comment_2):
757 for comparison in self.cmp_list :
758 val = comparison(comment_1, comment_2)
763 cmp_full = CommentCompoundComparator()
765 if libbe.TESTING == True:
766 suite = doctest.DocTestSuite()