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.bug != None and self.bug.bugdir != None:
166 new = libbe.util.id.short_to_long_text([self.bug.bugdir], new)
167 if (self.storage != None and self.storage.writeable == True) \
169 assert new != None, "Can't save empty comment"
170 self.storage.set(self.id.storage("body"), new)
173 @change_hook_property(hook=_set_comment_body)
174 @cached_property(generator=_get_comment_body)
175 @local_property("body")
176 @doc_property(doc="The meat of the comment")
177 def body(): return {}
179 def _extra_strings_check_fn(value):
180 return utility.iterable_full_of_strings(value, \
181 alternative=settings_object.EMPTY)
182 def _extra_strings_change_hook(self, old, new):
183 self.extra_strings.sort() # to make merging easier
184 self._prop_save_settings(old, new)
185 @_versioned_property(name="extra_strings",
186 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
188 check_fn=_extra_strings_check_fn,
189 change_hook=_extra_strings_change_hook,
191 def extra_strings(): return {}
193 def __init__(self, bug=None, uuid=None, from_storage=False,
194 in_reply_to=None, body=None):
196 Set from_storage=True to load an old comment.
197 Set from_storage=False to create a new comment.
199 The uuid option is required when from_storage==True.
201 The in_reply_to and body options are only used if
202 from_storage==False (the default). When from_storage==True,
203 they are loaded from the bug database.
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
220 self.storage = self.bug.storage
221 if from_storage == False:
222 if self.storage != None and self.storage.is_writeable():
225 def __cmp__(self, other):
226 return cmp_full(self, other)
230 >>> comm = Comment(bug=None, body="Some insightful remarks")
231 >>> comm.uuid = "com-1"
232 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
233 >>> comm.author = "Jane Doe <jdoe@example.com>"
235 --------- Comment ---------
237 From: Jane Doe <jdoe@example.com>
238 Date: Thu, 20 Nov 2008 15:55:11 +0000
240 Some insightful remarks
244 def traverse(self, *args, **kwargs):
245 """Avoid working with the possible dummy root comment"""
246 for comment in Tree.traverse(self, *args, **kwargs):
247 if comment.uuid == INVALID_UUID:
251 # serializing methods
253 def _setting_attr_string(self, setting):
254 value = getattr(self, setting)
257 if type(value) not in types.StringTypes:
261 def safe_in_reply_to(self):
263 Return self.in_reply_to, except...
264 * if no comment matches that id, in which case return None.
265 * if that id matches another comments .alt_id, in which case
266 return the matching comments .uuid.
268 if self.in_reply_to == None:
272 irt_comment = self.bug.comment_from_uuid(
273 self.in_reply_to, match_alt_id=True)
274 return irt_comment.uuid
278 def xml(self, indent=0):
280 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
281 >>> comm.uuid = "0123"
282 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
283 >>> print comm.xml(indent=2)
286 <short-name>//012</short-name>
288 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
289 <content-type>text/plain</content-type>
295 if self.content_type.startswith('text/'):
296 body = (self.body or '').rstrip('\n')
298 maintype,subtype = self.content_type.split('/',1)
299 msg = MIMEBase(maintype, subtype)
300 msg.set_payload(self.body or '')
302 body = base64.encodestring(self.body or '')
303 info = [('uuid', self.uuid),
304 ('alt-id', self.alt_id),
305 ('short-name', self.id.user()),
306 ('in-reply-to', self.safe_in_reply_to()),
307 ('author', self._setting_attr_string('author')),
309 ('content-type', self.content_type),
311 lines = ['<comment>']
314 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
315 for estr in self.extra_strings:
316 lines.append(' <extra-string>%s</extra-string>' % estr)
317 lines.append('</comment>')
320 return istring + sep.join(lines).rstrip('\n')
322 def from_xml(self, xml_string, verbose=True):
324 Note: If alt-id is not given, translates any <uuid> fields to
326 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
327 >>> commA.uuid = "0123"
328 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
329 >>> commA.author = u'Fran\xe7ois'
330 >>> commA.extra_strings += ['TAG: very helpful']
331 >>> xml = commA.xml()
332 >>> commB = Comment()
333 >>> commB.from_xml(xml, verbose=True)
334 >>> commB.explicit_attrs
335 ['author', 'date', 'content_type', 'body', 'alt_id']
336 >>> commB.xml() == xml
338 >>> commB.uuid = commB.alt_id
339 >>> commB.alt_id = None
340 >>> commB.xml() == xml
343 if type(xml_string) == types.UnicodeType:
344 xml_string = xml_string.strip().encode('unicode_escape')
345 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
348 comment = ElementTree.XML(xml_string)
349 if comment.tag != 'comment':
350 raise utility.InvalidXML( \
351 'comment', comment, 'root element must be <comment>')
352 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
353 'body','extra-string']
354 self.explicit_attrs = []
358 for child in comment.getchildren():
359 if child.tag == 'short-name':
361 elif child.tag in tags:
362 if child.text == None or len(child.text) == 0:
363 text = settings_object.EMPTY
365 text = xml.sax.saxutils.unescape(child.text)
366 text = text.decode('unicode_escape').strip()
367 if child.tag == 'uuid':
369 continue # don't set the comment's uuid tag.
370 elif child.tag == 'body':
372 self.explicit_attrs.append(child.tag)
373 continue # don't set the comment's body yet.
374 elif child.tag == 'extra-string':
376 continue # don't set the comment's extra_string yet.
377 attr_name = child.tag.replace('-','_')
378 self.explicit_attrs.append(attr_name)
379 setattr(self, attr_name, text)
380 elif verbose == True:
381 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
382 % (child.tag, comment.tag)
383 if uuid != self.uuid and self.alt_id == None:
384 self.explicit_attrs.append('alt_id')
387 if self.content_type.startswith('text/'):
388 self.body = body+'\n' # restore trailing newline
390 self.body = base64.decodestring(body)
391 self.extra_strings = estrs
393 def merge(self, other, accept_changes=True,
394 accept_extra_strings=True, change_exception=False):
396 Merge info from other into this comment. Overrides any
397 attributes in self that are listed in other.explicit_attrs.
398 >>> commA = Comment(bug=None, body='Some insightful remarks')
399 >>> commA.uuid = '0123'
400 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
401 >>> commA.author = 'Frank'
402 >>> commA.extra_strings += ['TAG: very helpful']
403 >>> commA.extra_strings += ['TAG: favorite']
404 >>> commB = Comment(bug=None, body='More insightful remarks')
405 >>> commB.uuid = '3210'
406 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
407 >>> commB.author = 'John'
408 >>> commB.explicit_attrs = ['author', 'body']
409 >>> commB.extra_strings += ['TAG: very helpful']
410 >>> commB.extra_strings += ['TAG: useful']
411 >>> commA.merge(commB, accept_changes=False,
412 ... accept_extra_strings=False, change_exception=False)
413 >>> commA.merge(commB, accept_changes=False,
414 ... accept_extra_strings=False, change_exception=True)
415 Traceback (most recent call last):
417 ValueError: Merge would change author "Frank"->"John" for comment 0123
418 >>> commA.merge(commB, accept_changes=True,
419 ... accept_extra_strings=False, change_exception=True)
420 Traceback (most recent call last):
422 ValueError: Merge would add extra string "TAG: useful" to comment 0123
423 >>> print commA.author
425 >>> print commA.extra_strings
426 ['TAG: favorite', 'TAG: very helpful']
427 >>> commA.merge(commB, accept_changes=True,
428 ... accept_extra_strings=True, change_exception=True)
429 >>> print commA.extra_strings
430 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
431 >>> print commA.xml()
434 <short-name>//012</short-name>
435 <author>John</author>
436 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
437 <content-type>text/plain</content-type>
438 <body>More insightful remarks</body>
439 <extra-string>TAG: favorite</extra-string>
440 <extra-string>TAG: useful</extra-string>
441 <extra-string>TAG: very helpful</extra-string>
444 for attr in other.explicit_attrs:
445 old = getattr(self, attr)
446 new = getattr(other, attr)
448 if accept_changes == True:
449 setattr(self, attr, new)
450 elif change_exception == True:
452 'Merge would change %s "%s"->"%s" for comment %s' \
453 % (attr, old, new, self.uuid)
454 if self.alt_id == self.uuid:
456 for estr in other.extra_strings:
457 if not estr in self.extra_strings:
458 if accept_extra_strings == True:
459 self.extra_strings.append(estr)
460 elif change_exception == True:
462 'Merge would add extra string "%s" to comment %s' \
465 def string(self, indent=0):
467 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
468 >>> comm.uuid = 'abcdef'
469 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
470 >>> print comm.string(indent=2)
471 --------- Comment ---------
474 Date: Thu, 01 Jan 1970 00:00:00 +0000
481 lines.append("--------- Comment ---------")
482 lines.append("Name: %s" % self.id.user())
483 lines.append("From: %s" % (self._setting_attr_string("author")))
484 lines.append("Date: %s" % self.date)
486 if self.content_type.startswith("text/"):
487 body = (self.body or "")
488 if self.bug != None and self.bug.bugdir != None:
489 body = libbe.util.id.long_to_short_text([self.bug.bugdir], body)
490 lines.extend(body.splitlines())
492 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
496 return istring + sep.join(lines).rstrip('\n')
498 def string_thread(self, string_method_name="string",
499 indent=0, flatten=True):
501 Return a string displaying a thread of comments.
502 bug_shortname is only used if auto_name_map == True.
504 string_method_name (defaults to "string") is the name of the
505 Comment method used to generate the output string for each
506 Comment in the thread. The method must take the arguments
507 indent and shortname.
509 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
510 which will sort the tree by comment.time. Avoid by calling
512 for shortname,comment in comm.comment_shortnames(bug_shortname):
513 name_map[comment.uuid] = shortname
514 comm.sort(key=lambda c : c.author) # your sort
515 comm.string_thread(name_map=name_map)
517 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
518 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
519 >>> b = a.new_reply("Critique original comment")
521 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
522 >>> c = b.new_reply("Begin flamewar :p")
524 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
525 >>> d = a.new_reply("Useful examples")
527 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
528 >>> a.sort(key=lambda comm : comm.time)
529 >>> print a.string_thread(flatten=True)
530 --------- Comment ---------
533 Date: Thu, 20 Nov 2008 01:00:00 +0000
536 --------- Comment ---------
539 Date: Thu, 20 Nov 2008 02:00:00 +0000
541 Critique original comment
542 --------- Comment ---------
545 Date: Thu, 20 Nov 2008 03:00:00 +0000
548 --------- Comment ---------
551 Date: Thu, 20 Nov 2008 04:00:00 +0000
554 >>> print a.string_thread()
555 --------- Comment ---------
558 Date: Thu, 20 Nov 2008 01:00:00 +0000
561 --------- Comment ---------
564 Date: Thu, 20 Nov 2008 02:00:00 +0000
566 Critique original comment
567 --------- Comment ---------
570 Date: Thu, 20 Nov 2008 03:00:00 +0000
573 --------- Comment ---------
576 Date: Thu, 20 Nov 2008 04:00:00 +0000
581 for depth,comment in self.thread(flatten=flatten):
583 string_fn = getattr(comment, string_method_name)
584 stringlist.append(string_fn(indent=ind))
585 return '\n'.join(stringlist)
587 def xml_thread(self, indent=0):
588 return self.string_thread(string_method_name="xml", indent=indent)
590 # methods for saving/loading/acessing settings and properties.
592 def load_settings(self, settings_mapfile=None):
593 if settings_mapfile == None:
595 self.storage.get(self.id.storage("values"), default="\n")
597 settings = mapfile.parse(settings_mapfile)
598 except mapfile.InvalidMapfileContents, e:
599 raise Exception('Invalid settings file for comment %s\n'
600 '(BE version missmatch?)' % self.id.user())
601 self._setup_saved_settings(settings)
603 def save_settings(self):
604 mf = mapfile.generate(self._get_saved_settings())
605 self.storage.set(self.id.storage("values"), mf)
609 Save any loaded contents to storage.
611 However, if self.storage.is_writeable() == True, then any
612 changes are automatically written to storage as soon as they
613 happen, so calling this method will just waste time (unless
614 something else has been messing with your stored files).
616 if self.uuid == INVALID_UUID:
618 assert self.storage != None, "Can't save without storage"
619 assert self.body != None, "Can't save blank comment"
621 parent = self.bug.id.storage()
624 self.storage.add(self.id.storage(), parent=parent, directory=True)
625 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
627 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
630 self._set_comment_body(new=self.body, force=True)
635 if self.uuid != INVALID_UUID:
636 self.storage.recursive_remove(self.id.storage())
638 def add_reply(self, reply, allow_time_inversion=False):
639 if self.uuid != INVALID_UUID:
640 reply.in_reply_to = self.uuid
643 def new_reply(self, body=None):
645 >>> comm = Comment(bug=None, body="Some insightful remarks")
646 >>> repA = comm.new_reply("Critique original comment")
647 >>> repB = repA.new_reply("Begin flamewar :p")
648 >>> repB.in_reply_to == repA.uuid
651 reply = Comment(self.bug, body=body)
652 self.add_reply(reply)
655 def comment_from_uuid(self, uuid, match_alt_id=True):
657 Use a uuid to look up a comment.
658 >>> a = Comment(bug=None, uuid="a")
659 >>> b = a.new_reply()
661 >>> c = b.new_reply()
663 >>> d = a.new_reply()
665 >>> d.alt_id = "d-alt"
666 >>> comm = a.comment_from_uuid("d")
667 >>> id(comm) == id(d)
669 >>> comm = a.comment_from_uuid("d-alt")
670 >>> id(comm) == id(d)
672 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
673 Traceback (most recent call last):
677 for comment in self.traverse():
678 if comment.uuid == uuid:
680 if match_alt_id == True and uuid != None \
681 and comment.alt_id == uuid:
685 # methods for id generation
687 def sibling_uuids(self):
689 return self.bug.uuids()
693 def cmp_attr(comment_1, comment_2, attr, invert=False):
695 Compare a general attribute between two comments using the conventional
696 comparison rule for that attribute type. If invert == True, sort
697 *against* that convention.
699 >>> commentA = Comment()
700 >>> commentB = Comment()
701 >>> commentA.author = "John Doe"
702 >>> commentB.author = "Jane Doe"
703 >>> cmp_attr(commentA, commentB, attr) > 0
705 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
707 >>> commentB.author = "John Doe"
708 >>> cmp_attr(commentA, commentB, attr) == 0
711 if not hasattr(comment_2, attr) :
713 val_1 = getattr(comment_1, attr)
714 val_2 = getattr(comment_2, attr)
715 if val_1 == None: val_1 = None
716 if val_2 == None: val_2 = None
719 return -cmp(val_1, val_2)
721 return cmp(val_1, val_2)
723 # alphabetical rankings (a < z)
724 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
725 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
726 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
727 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
728 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
729 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
730 # chronological rankings (newer < older)
731 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
734 DEFAULT_CMP_FULL_CMP_LIST = \
735 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
736 cmp_uuid, cmp_extra_strings)
738 class CommentCompoundComparator (object):
739 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
740 self.cmp_list = cmp_list
741 def __call__(self, comment_1, comment_2):
742 for comparison in self.cmp_list :
743 val = comparison(comment_1, comment_2)
748 cmp_full = CommentCompoundComparator()
750 if libbe.TESTING == True:
751 suite = doctest.DocTestSuite()