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.
20 Define the 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 InvalidShortname(KeyError):
57 def __init__(self, shortname, shortnames):
58 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
59 KeyError.__init__(self, msg)
60 self.shortname = shortname
61 self.shortnames = shortnames
63 class MissingReference(ValueError):
64 def __init__(self, comment):
65 msg = "Missing reference to %s" % (comment.in_reply_to)
66 ValueError.__init__(self, msg)
67 self.reference = comment.in_reply_to
68 self.comment = comment
70 class DiskAccessRequired (Exception):
71 def __init__(self, goal):
72 msg = "Cannot %s without accessing the disk" % goal
73 Exception.__init__(self, msg)
75 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
77 def load_comments(bug, load_full=False):
79 Set load_full=True when you want to load the comment completely
80 from disk *now*, rather than waiting and lazy loading as required.
83 for id in libbe.util.id.child_uuids(
89 comm = Comment(bug, uuid, from_storage=True)
92 dummy = comm.body # force the body to load
94 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
95 bug.add_comments(comments, ignore_missing_references=True)
96 return bug.comment_root
98 def save_comments(bug):
99 for comment in bug.comment_root.traverse():
103 class Comment (Tree, settings_object.SavedSettingsObject):
108 >>> c.uuid = "some-UUID"
109 >>> print c.content_type
113 settings_properties = []
114 required_saved_properties = []
115 _prop_save_settings = settings_object.prop_save_settings
116 _prop_load_settings = settings_object.prop_load_settings
117 def _versioned_property(settings_properties=settings_properties,
118 required_saved_properties=required_saved_properties,
120 if "settings_properties" not in kwargs:
121 kwargs["settings_properties"] = settings_properties
122 if "required_saved_properties" not in kwargs:
123 kwargs["required_saved_properties"]=required_saved_properties
124 return settings_object.versioned_property(**kwargs)
126 @_versioned_property(name="Alt-id",
127 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.")
128 def alt_id(): return {}
130 @_versioned_property(name="Author",
131 doc="The author of the comment")
132 def author(): return {}
134 @_versioned_property(name="In-reply-to",
135 doc="UUID for parent comment or bug")
136 def in_reply_to(): return {}
138 @_versioned_property(name="Content-type",
139 doc="Mime type for comment body",
140 default="text/plain",
142 def content_type(): return {}
144 @_versioned_property(name="Date",
145 doc="An RFC 2822 timestamp for comment creation")
146 def date(): return {}
149 if self.date == None:
151 return utility.str_to_time(self.date)
152 def _set_time(self, value):
153 self.date = utility.time_to_str(value)
154 time = property(fget=_get_time,
156 doc="An integer version of .date")
158 def _get_comment_body(self):
159 if self.storage != None and self.storage.is_readable() \
160 and self.uuid != INVALID_UUID:
161 return self.storage.get(self.id.storage("body"),
162 decode=self.content_type.startswith("text/"))
163 def _set_comment_body(self, old=None, new=None, force=False):
164 assert self.uuid != INVALID_UUID, self
165 if self.content_type.startswith('text/') \
166 and self.bug != None and self.bug.bugdir != None:
167 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
168 if (self.storage != None and self.storage.writeable == True) \
170 assert new != None, "Can't save empty comment"
171 self.storage.set(self.id.storage("body"), new)
174 @change_hook_property(hook=_set_comment_body)
175 @cached_property(generator=_get_comment_body)
176 @local_property("body")
177 @doc_property(doc="The meat of the comment")
178 def body(): return {}
180 def _extra_strings_check_fn(value):
181 return utility.iterable_full_of_strings(value, \
182 alternative=settings_object.EMPTY)
183 def _extra_strings_change_hook(self, old, new):
184 self.extra_strings.sort() # to make merging easier
185 self._prop_save_settings(old, new)
186 @_versioned_property(name="extra_strings",
187 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
189 check_fn=_extra_strings_check_fn,
190 change_hook=_extra_strings_change_hook,
192 def extra_strings(): return {}
194 def __init__(self, bug=None, uuid=None, from_storage=False,
195 in_reply_to=None, body=None, content_type=None):
197 Set from_storage=True to load an old comment.
198 Set from_storage=False to create a new comment.
200 The uuid option is required when from_storage==True.
202 The in_reply_to, body, and content_type options are only used
203 if from_storage==False (the default). When
204 from_storage==True, they are loaded from the bug database.
205 content_type decides if the body should be run through
206 libbe.util.id.short_to_long_text() before saving. See
207 ._set_comment_body() for details.
209 in_reply_to should be the uuid string of the parent comment.
212 settings_object.SavedSettingsObject.__init__(self)
216 self.id = libbe.util.id.ID(self, 'comment')
217 if from_storage == False:
219 self.uuid = libbe.util.id.uuid_gen()
220 self.time = int(time.time()) # only save to second precision
221 self.in_reply_to = in_reply_to
222 if content_type != None:
223 self.content_type = content_type
226 self.storage = self.bug.storage
227 if from_storage == False:
228 if self.storage != None and self.storage.is_writeable():
231 def __cmp__(self, other):
232 return cmp_full(self, other)
236 >>> comm = Comment(bug=None, body="Some insightful remarks")
237 >>> comm.uuid = "com-1"
238 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
239 >>> comm.author = "Jane Doe <jdoe@example.com>"
241 --------- Comment ---------
243 From: Jane Doe <jdoe@example.com>
244 Date: Thu, 20 Nov 2008 15:55:11 +0000
246 Some insightful remarks
250 def traverse(self, *args, **kwargs):
251 """Avoid working with the possible dummy root comment"""
252 for comment in Tree.traverse(self, *args, **kwargs):
253 if comment.uuid == INVALID_UUID:
257 # serializing methods
259 def _setting_attr_string(self, setting):
260 value = getattr(self, setting)
263 if type(value) not in types.StringTypes:
267 def safe_in_reply_to(self):
269 Return self.in_reply_to, except...
270 * if no comment matches that id, in which case return None.
271 * if that id matches another comments .alt_id, in which case
272 return the matching comments .uuid.
274 if self.in_reply_to == None:
278 irt_comment = self.bug.comment_from_uuid(
279 self.in_reply_to, match_alt_id=True)
280 return irt_comment.uuid
284 def xml(self, indent=0):
286 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
287 >>> comm.uuid = "0123"
288 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
289 >>> print comm.xml(indent=2)
292 <short-name>//012</short-name>
294 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
295 <content-type>text/plain</content-type>
300 >>> comm.content_type = 'image/png'
304 <short-name>//012</short-name>
306 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
307 <content-type>image/png</content-type>
308 <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK
312 if self.content_type.startswith('text/'):
313 body = (self.body or '').rstrip('\n')
315 maintype,subtype = self.content_type.split('/',1)
316 msg = MIMEBase(maintype, subtype)
317 msg.set_payload(self.body or '')
319 body = base64.encodestring(self.body or '')
320 info = [('uuid', self.uuid),
321 ('alt-id', self.alt_id),
322 ('short-name', self.id.user()),
323 ('in-reply-to', self.safe_in_reply_to()),
324 ('author', self._setting_attr_string('author')),
326 ('content-type', self.content_type),
328 lines = ['<comment>']
331 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
332 for estr in self.extra_strings:
333 lines.append(' <extra-string>%s</extra-string>' % estr)
334 lines.append('</comment>')
337 return istring + sep.join(lines).rstrip('\n')
339 def from_xml(self, xml_string, verbose=True):
341 Note: If alt-id is not given, translates any <uuid> fields to
343 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
344 >>> commA.uuid = "0123"
345 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
346 >>> commA.author = u'Fran\xe7ois'
347 >>> commA.extra_strings += ['TAG: very helpful']
348 >>> xml = commA.xml()
349 >>> commB = Comment()
350 >>> commB.from_xml(xml, verbose=True)
351 >>> commB.explicit_attrs
352 ['author', 'date', 'content_type', 'body', 'alt_id']
353 >>> commB.xml() == xml
355 >>> commB.uuid = commB.alt_id
356 >>> commB.alt_id = None
357 >>> commB.xml() == xml
360 if type(xml_string) == types.UnicodeType:
361 xml_string = xml_string.strip().encode('unicode_escape')
362 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
365 comment = ElementTree.XML(xml_string)
366 if comment.tag != 'comment':
367 raise utility.InvalidXML( \
368 'comment', comment, 'root element must be <comment>')
369 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
370 'body','extra-string']
371 self.explicit_attrs = []
375 for child in comment.getchildren():
376 if child.tag == 'short-name':
378 elif child.tag in tags:
379 if child.text == None or len(child.text) == 0:
380 text = settings_object.EMPTY
382 text = xml.sax.saxutils.unescape(child.text)
383 text = text.decode('unicode_escape').strip()
384 if child.tag == 'uuid':
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.
415 >>> commA = Comment(bug=None, body='Some insightful remarks')
416 >>> commA.uuid = '0123'
417 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
418 >>> commA.author = 'Frank'
419 >>> commA.extra_strings += ['TAG: very helpful']
420 >>> commA.extra_strings += ['TAG: favorite']
421 >>> commB = Comment(bug=None, body='More insightful remarks')
422 >>> commB.uuid = '3210'
423 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
424 >>> commB.author = 'John'
425 >>> commB.explicit_attrs = ['author', 'body']
426 >>> commB.extra_strings += ['TAG: very helpful']
427 >>> commB.extra_strings += ['TAG: useful']
428 >>> commA.merge(commB, accept_changes=False,
429 ... accept_extra_strings=False, change_exception=False)
430 >>> commA.merge(commB, accept_changes=False,
431 ... accept_extra_strings=False, change_exception=True)
432 Traceback (most recent call last):
434 ValueError: Merge would change author "Frank"->"John" for comment 0123
435 >>> commA.merge(commB, accept_changes=True,
436 ... accept_extra_strings=False, change_exception=True)
437 Traceback (most recent call last):
439 ValueError: Merge would add extra string "TAG: useful" to comment 0123
440 >>> print commA.author
442 >>> print commA.extra_strings
443 ['TAG: favorite', 'TAG: very helpful']
444 >>> commA.merge(commB, accept_changes=True,
445 ... accept_extra_strings=True, change_exception=True)
446 >>> print commA.extra_strings
447 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
448 >>> print commA.xml()
451 <short-name>//012</short-name>
452 <author>John</author>
453 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
454 <content-type>text/plain</content-type>
455 <body>More insightful remarks</body>
456 <extra-string>TAG: favorite</extra-string>
457 <extra-string>TAG: useful</extra-string>
458 <extra-string>TAG: very helpful</extra-string>
461 for attr in other.explicit_attrs:
462 old = getattr(self, attr)
463 new = getattr(other, attr)
465 if accept_changes == True:
466 setattr(self, attr, new)
467 elif change_exception == True:
469 'Merge would change %s "%s"->"%s" for comment %s' \
470 % (attr, old, new, self.uuid)
471 if self.alt_id == self.uuid:
473 for estr in other.extra_strings:
474 if not estr in self.extra_strings:
475 if accept_extra_strings == True:
476 self.extra_strings.append(estr)
477 elif change_exception == True:
479 'Merge would add extra string "%s" to comment %s' \
482 def string(self, indent=0):
484 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
485 >>> comm.uuid = 'abcdef'
486 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
487 >>> print comm.string(indent=2)
488 --------- Comment ---------
491 Date: Thu, 01 Jan 1970 00:00:00 +0000
498 lines.append("--------- Comment ---------")
499 lines.append("Name: %s" % self.id.user())
500 lines.append("From: %s" % (self._setting_attr_string("author")))
501 lines.append("Date: %s" % self.date)
503 if self.content_type.startswith("text/"):
504 body = (self.body or "")
505 if self.bug != None and self.bug.bugdir != None:
506 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
507 lines.extend(body.splitlines())
509 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
513 return istring + sep.join(lines).rstrip('\n')
515 def string_thread(self, string_method_name="string",
516 indent=0, flatten=True):
518 Return a string displaying a thread of comments.
519 bug_shortname is only used if auto_name_map == True.
521 string_method_name (defaults to "string") is the name of the
522 Comment method used to generate the output string for each
523 Comment in the thread. The method must take the arguments
524 indent and shortname.
526 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
527 which will sort the tree by comment.time. Avoid by calling
529 for shortname,comment in comm.comment_shortnames(bug_shortname):
530 name_map[comment.uuid] = shortname
531 comm.sort(key=lambda c : c.author) # your sort
532 comm.string_thread(name_map=name_map)
534 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
535 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
536 >>> b = a.new_reply("Critique original comment")
538 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
539 >>> c = b.new_reply("Begin flamewar :p")
541 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
542 >>> d = a.new_reply("Useful examples")
544 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
545 >>> a.sort(key=lambda comm : comm.time)
546 >>> print a.string_thread(flatten=True)
547 --------- Comment ---------
550 Date: Thu, 20 Nov 2008 01:00:00 +0000
553 --------- Comment ---------
556 Date: Thu, 20 Nov 2008 02:00:00 +0000
558 Critique original comment
559 --------- Comment ---------
562 Date: Thu, 20 Nov 2008 03:00:00 +0000
565 --------- Comment ---------
568 Date: Thu, 20 Nov 2008 04:00:00 +0000
571 >>> print a.string_thread()
572 --------- Comment ---------
575 Date: Thu, 20 Nov 2008 01:00:00 +0000
578 --------- Comment ---------
581 Date: Thu, 20 Nov 2008 02:00:00 +0000
583 Critique original comment
584 --------- Comment ---------
587 Date: Thu, 20 Nov 2008 03:00:00 +0000
590 --------- Comment ---------
593 Date: Thu, 20 Nov 2008 04:00:00 +0000
598 for depth,comment in self.thread(flatten=flatten):
600 string_fn = getattr(comment, string_method_name)
601 stringlist.append(string_fn(indent=ind))
602 return '\n'.join(stringlist)
604 def xml_thread(self, indent=0):
605 return self.string_thread(string_method_name="xml", indent=indent)
607 # methods for saving/loading/acessing settings and properties.
609 def load_settings(self, settings_mapfile=None):
610 if self.uuid == INVALID_UUID:
612 if settings_mapfile == None:
614 self.storage.get(self.id.storage("values"), default="\n")
616 settings = mapfile.parse(settings_mapfile)
617 except mapfile.InvalidMapfileContents, e:
618 raise Exception('Invalid settings file for comment %s\n'
619 '(BE version missmatch?)' % self.id.user())
620 self._setup_saved_settings(settings)
622 def save_settings(self):
623 if self.uuid == INVALID_UUID:
625 mf = mapfile.generate(self._get_saved_settings())
626 self.storage.set(self.id.storage("values"), mf)
630 Save any loaded contents to storage.
632 However, if self.storage.is_writeable() == True, then any
633 changes are automatically written to storage as soon as they
634 happen, so calling this method will just waste time (unless
635 something else has been messing with your stored files).
637 if self.uuid == INVALID_UUID:
639 assert self.storage != None, "Can't save without storage"
640 assert self.body != None, "Can't save blank comment"
642 parent = self.bug.id.storage()
645 self.storage.add(self.id.storage(), parent=parent, directory=True)
646 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
648 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
651 self._set_comment_body(new=self.body, force=True)
656 if self.uuid != INVALID_UUID:
657 self.storage.recursive_remove(self.id.storage())
659 def add_reply(self, reply, allow_time_inversion=False):
660 if self.uuid != INVALID_UUID:
661 reply.in_reply_to = self.uuid
664 def new_reply(self, body=None, content_type=None):
666 >>> comm = Comment(bug=None, body="Some insightful remarks")
667 >>> repA = comm.new_reply("Critique original comment")
668 >>> repB = repA.new_reply("Begin flamewar :p")
669 >>> repB.in_reply_to == repA.uuid
672 reply = Comment(self.bug, body=body, content_type=content_type)
673 self.add_reply(reply)
676 def comment_from_uuid(self, uuid, match_alt_id=True):
678 Use a uuid to look up a comment.
679 >>> a = Comment(bug=None, uuid="a")
680 >>> b = a.new_reply()
682 >>> c = b.new_reply()
684 >>> d = a.new_reply()
686 >>> d.alt_id = "d-alt"
687 >>> comm = a.comment_from_uuid("d")
688 >>> id(comm) == id(d)
690 >>> comm = a.comment_from_uuid("d-alt")
691 >>> id(comm) == id(d)
693 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
694 Traceback (most recent call last):
698 for comment in self.traverse():
699 if comment.uuid == uuid:
701 if match_alt_id == True and uuid != None \
702 and comment.alt_id == uuid:
706 # methods for id generation
708 def sibling_uuids(self):
710 return self.bug.uuids()
714 def cmp_attr(comment_1, comment_2, attr, invert=False):
716 Compare a general attribute between two comments using the conventional
717 comparison rule for that attribute type. If invert == True, sort
718 *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()