1 # Copyright (C) 2008-2010 Gianluca Montecchi <gian@grys.it>
2 # Thomas Habets <thomas@habets.pp.se>
3 # W. Trevor King <wking@drexel.edu>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19 """Define the :class:`Comment` class for representing bug comments.
29 from email.mime.base import MIMEBase
30 from email.encoders import encode_base64
32 # adjust to old python 2.4
33 from email.MIMEBase import MIMEBase
34 from email.Encoders import encode_base64
35 try: # import core module, Python >= 2.5
36 from xml.etree import ElementTree
37 except ImportError: # look for non-core module
38 from elementtree import ElementTree
39 import xml.sax.saxutils
43 from libbe.storage.util.properties import Property, doc_property, \
44 local_property, defaulting_property, checked_property, cached_property, \
45 primed_property, change_hook_property, settings_property
46 import libbe.storage.util.settings_object as settings_object
47 import libbe.storage.util.mapfile as mapfile
48 from libbe.util.tree import Tree
49 import libbe.util.utility as utility
51 if libbe.TESTING == True:
55 class InvalidShortname(KeyError):
56 def __init__(self, shortname, shortnames):
57 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
58 KeyError.__init__(self, msg)
59 self.shortname = shortname
60 self.shortnames = shortnames
62 class MissingReference(ValueError):
63 def __init__(self, comment):
64 msg = "Missing reference to %s" % (comment.in_reply_to)
65 ValueError.__init__(self, msg)
66 self.reference = comment.in_reply_to
67 self.comment = comment
69 class DiskAccessRequired (Exception):
70 def __init__(self, goal):
71 msg = "Cannot %s without accessing the disk" % goal
72 Exception.__init__(self, msg)
74 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
76 def load_comments(bug, load_full=False):
78 Set load_full=True when you want to load the comment completely
79 from disk *now*, rather than waiting and lazy loading as required.
82 for id in libbe.util.id.child_uuids(
88 comm = Comment(bug, uuid, from_storage=True)
91 dummy = comm.body # force the body to load
93 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
94 bug.add_comments(comments, ignore_missing_references=True)
95 return bug.comment_root
97 def save_comments(bug):
98 for comment in bug.comment_root.traverse():
102 class Comment (Tree, settings_object.SavedSettingsObject):
103 """Comments are a notes that attach to :class:`~libbe.bug.Bug`\s in
104 threaded trees. In mailing-list terms, a comment is analogous to
105 a single part of an email.
110 >>> c.uuid = "some-UUID"
111 >>> print c.content_type
115 settings_properties = []
116 required_saved_properties = []
117 _prop_save_settings = settings_object.prop_save_settings
118 _prop_load_settings = settings_object.prop_load_settings
119 def _versioned_property(settings_properties=settings_properties,
120 required_saved_properties=required_saved_properties,
122 if "settings_properties" not in kwargs:
123 kwargs["settings_properties"] = settings_properties
124 if "required_saved_properties" not in kwargs:
125 kwargs["required_saved_properties"]=required_saved_properties
126 return settings_object.versioned_property(**kwargs)
128 @_versioned_property(name="Alt-id",
129 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.")
130 def alt_id(): return {}
132 @_versioned_property(name="Author",
133 doc="The author of the comment")
134 def author(): return {}
136 @_versioned_property(name="In-reply-to",
137 doc="UUID for parent comment or bug")
138 def in_reply_to(): return {}
140 @_versioned_property(name="Content-type",
141 doc="Mime type for comment body",
142 default="text/plain",
144 def content_type(): return {}
146 @_versioned_property(name="Date",
147 doc="An RFC 2822 timestamp for comment creation")
148 def date(): return {}
151 if self.date == None:
153 return utility.str_to_time(self.date)
154 def _set_time(self, value):
155 self.date = utility.time_to_str(value)
156 time = property(fget=_get_time,
158 doc="An integer version of .date")
160 def _get_comment_body(self):
161 if self.storage != None and self.storage.is_readable() \
162 and self.uuid != INVALID_UUID:
163 return self.storage.get(self.id.storage("body"),
164 decode=self.content_type.startswith("text/"))
165 def _set_comment_body(self, old=None, new=None, force=False):
166 assert self.uuid != INVALID_UUID, self
167 if self.content_type.startswith('text/') \
168 and self.bug != None and self.bug.bugdir != None:
169 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
170 if (self.storage != None and self.storage.writeable == True) \
172 assert new != None, "Can't save empty comment"
173 self.storage.set(self.id.storage("body"), new)
176 @change_hook_property(hook=_set_comment_body)
177 @cached_property(generator=_get_comment_body)
178 @local_property("body")
179 @doc_property(doc="The meat of the comment")
180 def body(): return {}
182 def _extra_strings_check_fn(value):
183 return utility.iterable_full_of_strings(value, \
184 alternative=settings_object.EMPTY)
185 def _extra_strings_change_hook(self, old, new):
186 self.extra_strings.sort() # to make merging easier
187 self._prop_save_settings(old, new)
188 @_versioned_property(name="extra_strings",
189 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
191 check_fn=_extra_strings_check_fn,
192 change_hook=_extra_strings_change_hook,
194 def extra_strings(): return {}
196 def __init__(self, bug=None, uuid=None, from_storage=False,
197 in_reply_to=None, body=None, content_type=None):
199 Set ``from_storage=True`` to load an old comment.
200 Set ``from_storage=False`` to create a new comment.
202 The ``uuid`` option is required when ``from_storage==True``.
204 The in_reply_to, body, and content_type options are only used
205 if ``from_storage==False`` (the default). When
206 ``from_storage==True``, they are loaded from the bug database.
207 ``content_type`` decides if the body should be run through
208 :func:`util.id.short_to_long_text` before saving. See
209 :meth:`_set_comment_body` for details.
211 ``in_reply_to`` should be the uuid string of the parent comment.
214 settings_object.SavedSettingsObject.__init__(self)
218 self.id = libbe.util.id.ID(self, 'comment')
219 if from_storage == False:
221 self.uuid = libbe.util.id.uuid_gen()
222 self.time = int(time.time()) # only save to second precision
223 self.in_reply_to = in_reply_to
224 if content_type != None:
225 self.content_type = content_type
228 self.storage = self.bug.storage
229 if from_storage == False:
230 if self.storage != None and self.storage.is_writeable():
233 def __cmp__(self, other):
234 return cmp_full(self, other)
238 >>> comm = Comment(bug=None, body="Some insightful remarks")
239 >>> comm.uuid = "com-1"
240 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
241 >>> comm.author = "Jane Doe <jdoe@example.com>"
243 --------- Comment ---------
245 From: Jane Doe <jdoe@example.com>
246 Date: Thu, 20 Nov 2008 15:55:11 +0000
248 Some insightful remarks
252 def traverse(self, *args, **kwargs):
253 """Avoid working with the possible dummy root comment"""
254 for comment in Tree.traverse(self, *args, **kwargs):
255 if comment.uuid == INVALID_UUID:
259 # serializing methods
261 def _setting_attr_string(self, setting):
262 value = getattr(self, setting)
265 if type(value) not in types.StringTypes:
269 def safe_in_reply_to(self):
271 Return self.in_reply_to, except...
273 * if no comment matches that id, in which case return None.
274 * if that id matches another comments .alt_id, in which case
275 return the matching comments .uuid.
277 if self.in_reply_to == None:
281 irt_comment = self.bug.comment_from_uuid(
282 self.in_reply_to, match_alt_id=True)
283 return irt_comment.uuid
287 def xml(self, indent=0):
289 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
290 >>> comm.uuid = "0123"
291 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
292 >>> print comm.xml(indent=2)
295 <short-name>//012</short-name>
297 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
298 <content-type>text/plain</content-type>
303 >>> comm.content_type = 'image/png'
307 <short-name>//012</short-name>
309 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
310 <content-type>image/png</content-type>
311 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
315 if self.content_type.startswith('text/'):
316 body = (self.body or '').rstrip('\n')
318 maintype,subtype = self.content_type.split('/',1)
319 msg = MIMEBase(maintype, subtype)
320 msg.set_payload(self.body or '')
322 body = base64.encodestring(self.body or '')
323 info = [('uuid', self.uuid),
324 ('alt-id', self.alt_id),
325 ('short-name', self.id.user()),
326 ('in-reply-to', self.safe_in_reply_to()),
327 ('author', self._setting_attr_string('author')),
329 ('content-type', self.content_type),
331 lines = ['<comment>']
334 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
335 for estr in self.extra_strings:
336 lines.append(' <extra-string>%s</extra-string>' % estr)
337 lines.append('</comment>')
340 return istring + sep.join(lines).rstrip('\n')
342 def from_xml(self, xml_string, verbose=True):
344 Note: If alt-id is not given, translates any <uuid> fields to
346 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
347 >>> commA.uuid = "0123"
348 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
349 >>> commA.author = u'Fran\xe7ois'
350 >>> commA.extra_strings += ['TAG: very helpful']
351 >>> xml = commA.xml()
352 >>> commB = Comment()
353 >>> commB.from_xml(xml, verbose=True)
354 >>> commB.explicit_attrs
355 ['author', 'date', 'content_type', 'body', 'alt_id']
356 >>> commB.xml() == xml
358 >>> commB.uuid = commB.alt_id
359 >>> commB.alt_id = None
360 >>> commB.xml() == xml
363 if type(xml_string) == types.UnicodeType:
364 xml_string = xml_string.strip().encode('unicode_escape')
365 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
368 comment = ElementTree.XML(xml_string)
369 if comment.tag != 'comment':
370 raise utility.InvalidXML( \
371 'comment', comment, 'root element must be <comment>')
372 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
373 'body','extra-string']
374 self.explicit_attrs = []
378 for child in comment.getchildren():
379 if child.tag == 'short-name':
381 elif child.tag in tags:
382 if child.text == None or len(child.text) == 0:
383 text = settings_object.EMPTY
385 text = xml.sax.saxutils.unescape(child.text)
386 text = text.decode('unicode_escape').strip()
387 if child.tag == 'uuid':
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 for attr in other.explicit_attrs:
466 old = getattr(self, attr)
467 new = getattr(other, attr)
469 if accept_changes == True:
470 setattr(self, attr, new)
471 elif change_exception == True:
473 'Merge would change %s "%s"->"%s" for comment %s' \
474 % (attr, old, new, self.uuid)
475 if self.alt_id == self.uuid:
477 for estr in other.extra_strings:
478 if not estr in self.extra_strings:
479 if accept_extra_strings == True:
480 self.extra_strings.append(estr)
481 elif change_exception == True:
483 'Merge would add extra string "%s" to comment %s' \
486 def string(self, indent=0):
488 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
489 >>> comm.uuid = 'abcdef'
490 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
491 >>> print comm.string(indent=2)
492 --------- Comment ---------
495 Date: Thu, 01 Jan 1970 00:00:00 +0000
502 lines.append("--------- Comment ---------")
503 lines.append("Name: %s" % self.id.user())
504 lines.append("From: %s" % (self._setting_attr_string("author")))
505 lines.append("Date: %s" % self.date)
507 if self.content_type.startswith("text/"):
508 body = (self.body or "")
509 if self.bug != None and self.bug.bugdir != None:
510 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
511 lines.extend(body.splitlines())
513 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
517 return istring + sep.join(lines).rstrip('\n')
519 def string_thread(self, string_method_name="string",
520 indent=0, flatten=True):
522 Return a string displaying a thread of comments.
523 bug_shortname is only used if auto_name_map == True.
525 string_method_name (defaults to "string") is the name of the
526 Comment method used to generate the output string for each
527 Comment in the thread. The method must take the arguments
528 indent and shortname.
530 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
531 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
532 >>> b = a.new_reply("Critique original comment")
534 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
535 >>> c = b.new_reply("Begin flamewar :p")
537 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
538 >>> d = a.new_reply("Useful examples")
540 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
541 >>> a.sort(key=lambda comm : comm.time)
542 >>> print a.string_thread(flatten=True)
543 --------- Comment ---------
546 Date: Thu, 20 Nov 2008 01:00:00 +0000
549 --------- Comment ---------
552 Date: Thu, 20 Nov 2008 02:00:00 +0000
554 Critique original comment
555 --------- Comment ---------
558 Date: Thu, 20 Nov 2008 03:00:00 +0000
561 --------- Comment ---------
564 Date: Thu, 20 Nov 2008 04:00:00 +0000
567 >>> print a.string_thread()
568 --------- Comment ---------
571 Date: Thu, 20 Nov 2008 01:00:00 +0000
574 --------- Comment ---------
577 Date: Thu, 20 Nov 2008 02:00:00 +0000
579 Critique original comment
580 --------- Comment ---------
583 Date: Thu, 20 Nov 2008 03:00:00 +0000
586 --------- Comment ---------
589 Date: Thu, 20 Nov 2008 04:00:00 +0000
594 for depth,comment in self.thread(flatten=flatten):
596 string_fn = getattr(comment, string_method_name)
597 stringlist.append(string_fn(indent=ind))
598 return '\n'.join(stringlist)
600 def xml_thread(self, indent=0):
601 return self.string_thread(string_method_name="xml", indent=indent)
603 # methods for saving/loading/acessing settings and properties.
605 def load_settings(self, settings_mapfile=None):
606 if self.uuid == INVALID_UUID:
608 if settings_mapfile == None:
610 self.storage.get(self.id.storage("values"), default="\n")
612 settings = mapfile.parse(settings_mapfile)
613 except mapfile.InvalidMapfileContents, e:
614 raise Exception('Invalid settings file for comment %s\n'
615 '(BE version missmatch?)' % self.id.user())
616 self._setup_saved_settings(settings)
618 def save_settings(self):
619 if self.uuid == INVALID_UUID:
621 mf = mapfile.generate(self._get_saved_settings())
622 self.storage.set(self.id.storage("values"), mf)
626 Save any loaded contents to storage.
628 However, if ``self.storage.is_writeable() == True``, then any
629 changes are automatically written to storage as soon as they
630 happen, so calling this method will just waste time (unless
631 something else has been messing with your stored files).
633 if self.uuid == INVALID_UUID:
635 assert self.storage != None, "Can't save without storage"
636 assert self.body != None, "Can't save blank comment"
638 parent = self.bug.id.storage()
641 self.storage.add(self.id.storage(), parent=parent, directory=True)
642 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
644 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
647 self._set_comment_body(new=self.body, force=True)
652 if self.uuid != INVALID_UUID:
653 self.storage.recursive_remove(self.id.storage())
655 def add_reply(self, reply, allow_time_inversion=False):
656 if self.uuid != INVALID_UUID:
657 reply.in_reply_to = self.uuid
660 def new_reply(self, body=None, content_type=None):
662 >>> comm = Comment(bug=None, body="Some insightful remarks")
663 >>> repA = comm.new_reply("Critique original comment")
664 >>> repB = repA.new_reply("Begin flamewar :p")
665 >>> repB.in_reply_to == repA.uuid
668 reply = Comment(self.bug, body=body, content_type=content_type)
669 self.add_reply(reply)
672 def comment_from_uuid(self, uuid, match_alt_id=True):
673 """Use a uuid to look up a comment.
675 >>> a = Comment(bug=None, uuid="a")
676 >>> b = a.new_reply()
678 >>> c = b.new_reply()
680 >>> d = a.new_reply()
682 >>> d.alt_id = "d-alt"
683 >>> comm = a.comment_from_uuid("d")
684 >>> id(comm) == id(d)
686 >>> comm = a.comment_from_uuid("d-alt")
687 >>> id(comm) == id(d)
689 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
690 Traceback (most recent call last):
694 for comment in self.traverse():
695 if comment.uuid == uuid:
697 if match_alt_id == True and uuid != None \
698 and comment.alt_id == uuid:
702 # methods for id generation
704 def sibling_uuids(self):
706 return self.bug.uuids()
710 def cmp_attr(comment_1, comment_2, attr, invert=False):
712 Compare a general attribute between two comments using the conventional
713 comparison rule for that attribute type. If invert == True, sort
714 *against* that convention.
717 >>> commentA = Comment()
718 >>> commentB = Comment()
719 >>> commentA.author = "John Doe"
720 >>> commentB.author = "Jane Doe"
721 >>> cmp_attr(commentA, commentB, attr) > 0
723 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
725 >>> commentB.author = "John Doe"
726 >>> cmp_attr(commentA, commentB, attr) == 0
729 if not hasattr(comment_2, attr) :
731 val_1 = getattr(comment_1, attr)
732 val_2 = getattr(comment_2, attr)
733 if val_1 == None: val_1 = None
734 if val_2 == None: val_2 = None
737 return -cmp(val_1, val_2)
739 return cmp(val_1, val_2)
741 # alphabetical rankings (a < z)
742 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
743 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
744 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
745 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
746 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
747 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
748 # chronological rankings (newer < older)
749 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
752 DEFAULT_CMP_FULL_CMP_LIST = \
753 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
754 cmp_uuid, cmp_extra_strings)
756 class CommentCompoundComparator (object):
757 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
758 self.cmp_list = cmp_list
759 def __call__(self, comment_1, comment_2):
760 for comparison in self.cmp_list :
761 val = comparison(comment_1, comment_2)
766 cmp_full = CommentCompoundComparator()
768 if libbe.TESTING == True:
769 suite = doctest.DocTestSuite()