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 MissingReference(ValueError):
57 def __init__(self, comment):
58 msg = "Missing reference to %s" % (comment.in_reply_to)
59 ValueError.__init__(self, msg)
60 self.reference = comment.in_reply_to
61 self.comment = comment
63 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
65 def load_comments(bug, load_full=False):
67 Set load_full=True when you want to load the comment completely
68 from disk *now*, rather than waiting and lazy loading as required.
71 for id in libbe.util.id.child_uuids(
77 comm = Comment(bug, uuid, from_storage=True)
80 dummy = comm.body # force the body to load
82 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
83 bug.add_comments(comments, ignore_missing_references=True)
84 return bug.comment_root
86 def save_comments(bug):
87 for comment in bug.comment_root.traverse():
91 class Comment (Tree, settings_object.SavedSettingsObject):
92 """Comments are a notes that attach to :class:`~libbe.bug.Bug`\s in
93 threaded trees. In mailing-list terms, a comment is analogous to
94 a single part of an email.
99 >>> c.uuid = "some-UUID"
100 >>> print c.content_type
104 settings_properties = []
105 required_saved_properties = []
106 _prop_save_settings = settings_object.prop_save_settings
107 _prop_load_settings = settings_object.prop_load_settings
108 def _versioned_property(settings_properties=settings_properties,
109 required_saved_properties=required_saved_properties,
111 if "settings_properties" not in kwargs:
112 kwargs["settings_properties"] = settings_properties
113 if "required_saved_properties" not in kwargs:
114 kwargs["required_saved_properties"]=required_saved_properties
115 return settings_object.versioned_property(**kwargs)
117 @_versioned_property(name="Alt-id",
118 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.")
119 def alt_id(): return {}
121 @_versioned_property(name="Author",
122 doc="The author of the comment")
123 def author(): return {}
125 @_versioned_property(name="In-reply-to",
126 doc="UUID for parent comment or bug")
127 def in_reply_to(): return {}
129 @_versioned_property(name="Content-type",
130 doc="Mime type for comment body",
131 default="text/plain",
133 def content_type(): return {}
135 @_versioned_property(name="Date",
136 doc="An RFC 2822 timestamp for comment creation")
137 def date(): return {}
140 if self.date == None:
142 return utility.str_to_time(self.date)
143 def _set_time(self, value):
144 self.date = utility.time_to_str(value)
145 time = property(fget=_get_time,
147 doc="An integer version of .date")
149 def _get_comment_body(self):
150 if self.storage != None and self.storage.is_readable() \
151 and self.uuid != INVALID_UUID:
152 return self.storage.get(self.id.storage("body"),
153 decode=self.content_type.startswith("text/"))
154 def _set_comment_body(self, old=None, new=None, force=False):
155 assert self.uuid != INVALID_UUID, self
156 if self.content_type.startswith('text/') \
157 and self.bug != None and self.bug.bugdir != None:
158 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
159 if (self.storage != None and self.storage.writeable == True) \
161 assert new != None, "Can't save empty comment"
162 self.storage.set(self.id.storage("body"), new)
165 @change_hook_property(hook=_set_comment_body)
166 @cached_property(generator=_get_comment_body)
167 @local_property("body")
168 @doc_property(doc="The meat of the comment")
169 def body(): return {}
171 def _extra_strings_check_fn(value):
172 return utility.iterable_full_of_strings(value, \
173 alternative=settings_object.EMPTY)
174 def _extra_strings_change_hook(self, old, new):
175 self.extra_strings.sort() # to make merging easier
176 self._prop_save_settings(old, new)
177 @_versioned_property(name="extra_strings",
178 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
180 check_fn=_extra_strings_check_fn,
181 change_hook=_extra_strings_change_hook,
183 def extra_strings(): return {}
185 def __init__(self, bug=None, uuid=None, from_storage=False,
186 in_reply_to=None, body=None, content_type=None):
188 Set ``from_storage=True`` to load an old comment.
189 Set ``from_storage=False`` to create a new comment.
191 The ``uuid`` option is required when ``from_storage==True``.
193 The in_reply_to, body, and content_type options are only used
194 if ``from_storage==False`` (the default). When
195 ``from_storage==True``, they are loaded from the bug database.
196 ``content_type`` decides if the body should be run through
197 :func:`util.id.short_to_long_text` before saving. See
198 :meth:`_set_comment_body` for details.
200 ``in_reply_to`` should be the uuid string of the parent comment.
203 settings_object.SavedSettingsObject.__init__(self)
207 self.id = libbe.util.id.ID(self, 'comment')
208 if from_storage == False:
210 self.uuid = libbe.util.id.uuid_gen()
211 self.time = int(time.time()) # only save to second precision
212 self.in_reply_to = in_reply_to
213 if content_type != None:
214 self.content_type = content_type
217 self.storage = self.bug.storage
218 if from_storage == False:
219 if self.storage != None and self.storage.is_writeable():
222 def __cmp__(self, other):
223 return cmp_full(self, other)
227 >>> comm = Comment(bug=None, body="Some insightful remarks")
228 >>> comm.uuid = "com-1"
229 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
230 >>> comm.author = "Jane Doe <jdoe@example.com>"
232 --------- Comment ---------
234 From: Jane Doe <jdoe@example.com>
235 Date: Thu, 20 Nov 2008 15:55:11 +0000
237 Some insightful remarks
241 def traverse(self, *args, **kwargs):
242 """Avoid working with the possible dummy root comment"""
243 for comment in Tree.traverse(self, *args, **kwargs):
244 if comment.uuid == INVALID_UUID:
248 # serializing methods
250 def _setting_attr_string(self, setting):
251 value = getattr(self, setting)
254 if type(value) not in types.StringTypes:
258 def safe_in_reply_to(self):
260 Return self.in_reply_to, except...
262 * if no comment matches that id, in which case return None.
263 * if that id matches another comments .alt_id, in which case
264 return the matching comments .uuid.
266 if self.in_reply_to == None:
270 irt_comment = self.bug.comment_from_uuid(
271 self.in_reply_to, match_alt_id=True)
272 return irt_comment.uuid
276 def xml(self, indent=0):
278 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
279 >>> comm.uuid = "0123"
280 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
281 >>> print comm.xml(indent=2)
284 <short-name>//012</short-name>
286 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
287 <content-type>text/plain</content-type>
292 >>> comm.content_type = 'image/png'
296 <short-name>//012</short-name>
298 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
299 <content-type>image/png</content-type>
300 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
304 if self.content_type.startswith('text/'):
305 body = (self.body or '').rstrip('\n')
307 maintype,subtype = self.content_type.split('/',1)
308 msg = MIMEBase(maintype, subtype)
309 msg.set_payload(self.body or '')
311 body = base64.encodestring(self.body or '')
312 info = [('uuid', self.uuid),
313 ('alt-id', self.alt_id),
314 ('short-name', self.id.user()),
315 ('in-reply-to', self.safe_in_reply_to()),
316 ('author', self._setting_attr_string('author')),
318 ('content-type', self.content_type),
320 lines = ['<comment>']
323 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
324 for estr in self.extra_strings:
325 lines.append(' <extra-string>%s</extra-string>' % estr)
326 lines.append('</comment>')
329 return istring + sep.join(lines).rstrip('\n')
331 def from_xml(self, xml_string, preserve_uuids=False, verbose=True):
333 Note: If alt-id is not given, translates any <uuid> fields to
335 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
336 >>> commA.uuid = "0123"
337 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
338 >>> commA.author = u'Fran\xe7ois'
339 >>> commA.extra_strings += ['TAG: very helpful']
340 >>> xml = commA.xml()
341 >>> commB = Comment()
342 >>> commB.from_xml(xml, verbose=True)
343 >>> commB.explicit_attrs
344 ['author', 'date', 'content_type', 'body', 'alt_id']
345 >>> commB.xml() == xml
347 >>> commB.uuid = commB.alt_id
348 >>> commB.alt_id = None
349 >>> commB.xml() == xml
351 >>> commC = Comment()
352 >>> commC.from_xml(xml, preserve_uuids=True)
353 >>> commC.uuid == commA.uuid
356 if type(xml_string) == types.UnicodeType:
357 xml_string = xml_string.strip().encode('unicode_escape')
358 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
361 comment = ElementTree.XML(xml_string)
362 if comment.tag != 'comment':
363 raise utility.InvalidXML( \
364 'comment', comment, 'root element must be <comment>')
365 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
366 'body','extra-string']
367 self.explicit_attrs = []
371 for child in comment.getchildren():
372 if child.tag == 'short-name':
374 elif child.tag in tags:
375 if child.text == None or len(child.text) == 0:
376 text = settings_object.EMPTY
378 text = xml.sax.saxutils.unescape(child.text)
379 text = text.decode('unicode_escape').strip()
380 if child.tag == 'uuid' and not preserve_uuids:
382 continue # don't set the comment's uuid tag.
383 elif child.tag == 'body':
385 self.explicit_attrs.append(child.tag)
386 continue # don't set the comment's body yet.
387 elif child.tag == 'extra-string':
389 continue # don't set the comment's extra_string yet.
390 attr_name = child.tag.replace('-','_')
391 self.explicit_attrs.append(attr_name)
392 setattr(self, attr_name, text)
393 elif verbose == True:
394 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
395 % (child.tag, comment.tag)
396 if uuid != self.uuid and self.alt_id == None:
397 self.explicit_attrs.append('alt_id')
400 if self.content_type.startswith('text/'):
401 self.body = body+'\n' # restore trailing newline
403 self.body = base64.decodestring(body)
404 self.extra_strings = estrs
406 def merge(self, other, accept_changes=True,
407 accept_extra_strings=True, change_exception=False):
409 Merge info from other into this comment. Overrides any
410 attributes in self that are listed in other.explicit_attrs.
412 >>> commA = Comment(bug=None, body='Some insightful remarks')
413 >>> commA.uuid = '0123'
414 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
415 >>> commA.author = 'Frank'
416 >>> commA.extra_strings += ['TAG: very helpful']
417 >>> commA.extra_strings += ['TAG: favorite']
418 >>> commB = Comment(bug=None, body='More insightful remarks')
419 >>> commB.uuid = '3210'
420 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
421 >>> commB.author = 'John'
422 >>> commB.explicit_attrs = ['author', 'body']
423 >>> commB.extra_strings += ['TAG: very helpful']
424 >>> commB.extra_strings += ['TAG: useful']
425 >>> commA.merge(commB, accept_changes=False,
426 ... accept_extra_strings=False, change_exception=False)
427 >>> commA.merge(commB, accept_changes=False,
428 ... accept_extra_strings=False, change_exception=True)
429 Traceback (most recent call last):
431 ValueError: Merge would change author "Frank"->"John" for comment 0123
432 >>> commA.merge(commB, accept_changes=True,
433 ... accept_extra_strings=False, change_exception=True)
434 Traceback (most recent call last):
436 ValueError: Merge would add extra string "TAG: useful" to comment 0123
437 >>> print commA.author
439 >>> print commA.extra_strings
440 ['TAG: favorite', 'TAG: very helpful']
441 >>> commA.merge(commB, accept_changes=True,
442 ... accept_extra_strings=True, change_exception=True)
443 >>> print commA.extra_strings
444 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
445 >>> print commA.xml()
448 <short-name>//012</short-name>
449 <author>John</author>
450 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
451 <content-type>text/plain</content-type>
452 <body>More insightful remarks</body>
453 <extra-string>TAG: favorite</extra-string>
454 <extra-string>TAG: useful</extra-string>
455 <extra-string>TAG: very helpful</extra-string>
458 for attr in other.explicit_attrs:
459 old = getattr(self, attr)
460 new = getattr(other, attr)
462 if accept_changes == True:
463 setattr(self, attr, new)
464 elif change_exception == True:
466 'Merge would change %s "%s"->"%s" for comment %s' \
467 % (attr, old, new, self.uuid)
468 if self.alt_id == self.uuid:
470 for estr in other.extra_strings:
471 if not estr in self.extra_strings:
472 if accept_extra_strings == True:
473 self.extra_strings.append(estr)
474 elif change_exception == True:
476 'Merge would add extra string "%s" to comment %s' \
479 def string(self, indent=0):
481 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
482 >>> comm.uuid = 'abcdef'
483 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
484 >>> print comm.string(indent=2)
485 --------- Comment ---------
488 Date: Thu, 01 Jan 1970 00:00:00 +0000
495 lines.append("--------- Comment ---------")
496 lines.append("Name: %s" % self.id.user())
497 lines.append("From: %s" % (self._setting_attr_string("author")))
498 lines.append("Date: %s" % self.date)
500 if self.content_type.startswith("text/"):
501 body = (self.body or "")
502 if self.bug != None and self.bug.bugdir != None:
503 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
504 lines.extend(body.splitlines())
506 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
510 return istring + sep.join(lines).rstrip('\n')
512 def string_thread(self, string_method_name="string",
513 indent=0, flatten=True):
515 Return a string displaying a thread of comments.
516 bug_shortname is only used if auto_name_map == True.
518 string_method_name (defaults to "string") is the name of the
519 Comment method used to generate the output string for each
520 Comment in the thread. The method must take the arguments
521 indent and shortname.
523 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
524 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
525 >>> b = a.new_reply("Critique original comment")
527 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
528 >>> c = b.new_reply("Begin flamewar :p")
530 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
531 >>> d = a.new_reply("Useful examples")
533 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
534 >>> a.sort(key=lambda comm : comm.time)
535 >>> print a.string_thread(flatten=True)
536 --------- Comment ---------
539 Date: Thu, 20 Nov 2008 01:00:00 +0000
542 --------- Comment ---------
545 Date: Thu, 20 Nov 2008 02:00:00 +0000
547 Critique original comment
548 --------- Comment ---------
551 Date: Thu, 20 Nov 2008 03:00:00 +0000
554 --------- Comment ---------
557 Date: Thu, 20 Nov 2008 04:00:00 +0000
560 >>> print a.string_thread()
561 --------- Comment ---------
564 Date: Thu, 20 Nov 2008 01:00:00 +0000
567 --------- Comment ---------
570 Date: Thu, 20 Nov 2008 02:00:00 +0000
572 Critique original comment
573 --------- Comment ---------
576 Date: Thu, 20 Nov 2008 03:00:00 +0000
579 --------- Comment ---------
582 Date: Thu, 20 Nov 2008 04:00:00 +0000
587 for depth,comment in self.thread(flatten=flatten):
589 string_fn = getattr(comment, string_method_name)
590 stringlist.append(string_fn(indent=ind))
591 return '\n'.join(stringlist)
593 def xml_thread(self, indent=0):
594 return self.string_thread(string_method_name="xml", indent=indent)
596 # methods for saving/loading/acessing settings and properties.
598 def load_settings(self, settings_mapfile=None):
599 if self.uuid == INVALID_UUID:
601 if settings_mapfile == None:
602 settings_mapfile = self.storage.get(
603 self.id.storage('values'), '\n')
605 settings = mapfile.parse(settings_mapfile)
606 except mapfile.InvalidMapfileContents, e:
607 raise Exception('Invalid settings file for comment %s\n'
608 '(BE version missmatch?)' % self.id.user())
609 self._setup_saved_settings(settings)
611 def save_settings(self):
612 if self.uuid == INVALID_UUID:
614 mf = mapfile.generate(self._get_saved_settings())
615 self.storage.set(self.id.storage("values"), mf)
619 Save any loaded contents to storage.
621 However, if ``self.storage.is_writeable() == True``, then any
622 changes are automatically written to storage as soon as they
623 happen, so calling this method will just waste time (unless
624 something else has been messing with your stored files).
626 if self.uuid == INVALID_UUID:
628 assert self.storage != None, "Can't save without storage"
629 assert self.body != None, "Can't save blank comment"
631 parent = self.bug.id.storage()
634 self.storage.add(self.id.storage(), parent=parent, directory=True)
635 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
637 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
640 self._set_comment_body(new=self.body, force=True)
645 if self.uuid != INVALID_UUID:
646 self.storage.recursive_remove(self.id.storage())
648 def add_reply(self, reply, allow_time_inversion=False):
649 if self.uuid != INVALID_UUID:
650 reply.in_reply_to = self.uuid
653 def new_reply(self, body=None, content_type=None):
655 >>> comm = Comment(bug=None, body="Some insightful remarks")
656 >>> repA = comm.new_reply("Critique original comment")
657 >>> repB = repA.new_reply("Begin flamewar :p")
658 >>> repB.in_reply_to == repA.uuid
661 reply = Comment(self.bug, body=body, content_type=content_type)
662 self.add_reply(reply)
665 def comment_from_uuid(self, uuid, match_alt_id=True):
666 """Use a uuid to look up a comment.
668 >>> a = Comment(bug=None, uuid="a")
669 >>> b = a.new_reply()
671 >>> c = b.new_reply()
673 >>> d = a.new_reply()
675 >>> d.alt_id = "d-alt"
676 >>> comm = a.comment_from_uuid("d")
677 >>> id(comm) == id(d)
679 >>> comm = a.comment_from_uuid("d-alt")
680 >>> id(comm) == id(d)
682 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
683 Traceback (most recent call last):
687 for comment in self.traverse():
688 if comment.uuid == uuid:
690 if match_alt_id == True and uuid != None \
691 and comment.alt_id == uuid:
695 # methods for id generation
697 def sibling_uuids(self):
699 return self.bug.uuids()
703 def cmp_attr(comment_1, comment_2, attr, invert=False):
705 Compare a general attribute between two comments using the conventional
706 comparison rule for that attribute type. If invert == True, sort
707 *against* that convention.
710 >>> commentA = Comment()
711 >>> commentB = Comment()
712 >>> commentA.author = "John Doe"
713 >>> commentB.author = "Jane Doe"
714 >>> cmp_attr(commentA, commentB, attr) > 0
716 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
718 >>> commentB.author = "John Doe"
719 >>> cmp_attr(commentA, commentB, attr) == 0
722 if not hasattr(comment_2, attr) :
724 val_1 = getattr(comment_1, attr)
725 val_2 = getattr(comment_2, attr)
726 if val_1 == None: val_1 = None
727 if val_2 == None: val_2 = None
730 return -cmp(val_1, val_2)
732 return cmp(val_1, val_2)
734 # alphabetical rankings (a < z)
735 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
736 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
737 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
738 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
739 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
740 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
741 # chronological rankings (newer < older)
742 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
745 DEFAULT_CMP_FULL_CMP_LIST = \
746 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
747 cmp_uuid, cmp_extra_strings)
749 class CommentCompoundComparator (object):
750 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
751 self.cmp_list = cmp_list
752 def __call__(self, comment_1, comment_2):
753 for comparison in self.cmp_list :
754 val = comparison(comment_1, comment_2)
759 cmp_full = CommentCompoundComparator()
761 if libbe.TESTING == True:
762 suite = doctest.DocTestSuite()