1 # Copyright (C) 2008-2009 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.
29 try: # import core module, Python >= 2.5
30 from xml.etree import ElementTree
31 except ImportError: # look for non-core module
32 from elementtree import ElementTree
33 import xml.sax.saxutils
37 from libbe.storage.util.properties import Property, doc_property, \
38 local_property, defaulting_property, checked_property, cached_property, \
39 primed_property, change_hook_property, settings_property
40 import libbe.storage.util.settings_object as settings_object
41 import libbe.storage.util.mapfile as mapfile
42 from libbe.util.tree import Tree
43 import libbe.util.utility as utility
45 if libbe.TESTING == True:
49 class InvalidShortname(KeyError):
50 def __init__(self, shortname, shortnames):
51 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
52 KeyError.__init__(self, msg)
53 self.shortname = shortname
54 self.shortnames = shortnames
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 class DiskAccessRequired (Exception):
64 def __init__(self, goal):
65 msg = "Cannot %s without accessing the disk" % goal
66 Exception.__init__(self, msg)
68 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
70 def load_comments(bug, load_full=False):
72 Set load_full=True when you want to load the comment completely
73 from disk *now*, rather than waiting and lazy loading as required.
76 for id in libbe.util.id.child_uuids(
82 comm = Comment(bug, uuid, from_storage=True)
85 dummy = comm.body # force the body to load
87 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
88 bug.add_comments(comments, ignore_missing_references=True)
89 return bug.comment_root
91 def save_comments(bug):
92 for comment in bug.comment_root.traverse():
96 class Comment(Tree, settings_object.SavedSettingsObject):
101 >>> c.uuid = "some-UUID"
102 >>> print c.content_type
106 settings_properties = []
107 required_saved_properties = []
108 _prop_save_settings = settings_object.prop_save_settings
109 _prop_load_settings = settings_object.prop_load_settings
110 def _versioned_property(settings_properties=settings_properties,
111 required_saved_properties=required_saved_properties,
113 if "settings_properties" not in kwargs:
114 kwargs["settings_properties"] = settings_properties
115 if "required_saved_properties" not in kwargs:
116 kwargs["required_saved_properties"]=required_saved_properties
117 return settings_object.versioned_property(**kwargs)
119 @_versioned_property(name="Alt-id",
120 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.")
121 def alt_id(): return {}
123 @_versioned_property(name="Author",
124 doc="The author of the comment")
125 def author(): return {}
127 @_versioned_property(name="In-reply-to",
128 doc="UUID for parent comment or bug")
129 def in_reply_to(): return {}
131 @_versioned_property(name="Content-type",
132 doc="Mime type for comment body",
133 default="text/plain",
135 def content_type(): return {}
137 @_versioned_property(name="Date",
138 doc="An RFC 2822 timestamp for comment creation")
139 def date(): return {}
142 if self.date == None:
144 return utility.str_to_time(self.date)
145 def _set_time(self, value):
146 self.date = utility.time_to_str(value)
147 time = property(fget=_get_time,
149 doc="An integer version of .date")
151 def _get_comment_body(self):
152 if self.storage != None and self.storage.is_readable() \
153 and self.uuid != INVALID_UUID:
154 return self.storage.get(self.id.storage("body"),
155 decode=self.content_type.startswith("text/"))
156 def _set_comment_body(self, old=None, new=None, force=False):
157 assert self.uuid != INVALID_UUID, self
158 if self.bug != None and self.bug.bugdir != None:
159 new = libbe.util.id.short_to_long_user([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):
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 and body options are only used if
195 from_storage==False (the default). When from_storage==True,
196 they are loaded from the bug database.
198 in_reply_to should be the uuid string of the parent comment.
201 settings_object.SavedSettingsObject.__init__(self)
205 self.id = libbe.util.id.ID(self, 'comment')
206 if from_storage == False:
208 self.uuid = libbe.util.id.uuid_gen()
210 self._setup_saved_settings()
211 self.time = int(time.time()) # only save to second precision
212 self.in_reply_to = in_reply_to
215 self.storage = self.bug.storage
216 if from_storage == False:
217 if self.storage != None and self.storage.is_writeable():
220 def __cmp__(self, other):
221 return cmp_full(self, other)
225 >>> comm = Comment(bug=None, body="Some insightful remarks")
226 >>> comm.uuid = "com-1"
227 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
228 >>> comm.author = "Jane Doe <jdoe@example.com>"
230 --------- Comment ---------
232 From: Jane Doe <jdoe@example.com>
233 Date: Thu, 20 Nov 2008 15:55:11 +0000
235 Some insightful remarks
239 def traverse(self, *args, **kwargs):
240 """Avoid working with the possible dummy root comment"""
241 for comment in Tree.traverse(self, *args, **kwargs):
242 if comment.uuid == INVALID_UUID:
246 # serializing methods
248 def _setting_attr_string(self, setting):
249 value = getattr(self, setting)
252 if type(value) not in types.StringTypes:
256 def xml(self, indent=0):
258 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
259 >>> comm.uuid = "0123"
260 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
261 >>> print comm.xml(indent=2)
264 <short-name>//012</short-name>
266 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
267 <content-type>text/plain</content-type>
273 if self.content_type.startswith('text/'):
274 body = (self.body or '').rstrip('\n')
276 maintype,subtype = self.content_type.split('/',1)
277 msg = email.mime.base.MIMEBase(maintype, subtype)
278 msg.set_payload(self.body or '')
279 email.encoders.encode_base64(msg)
280 body = base64.encodestring(self.body or '')
281 info = [('uuid', self.uuid),
282 ('alt-id', self.alt_id),
283 ('short-name', self.id.user()),
284 ('in-reply-to', self.in_reply_to),
285 ('author', self._setting_attr_string('author')),
287 ('content-type', self.content_type),
289 lines = ['<comment>']
292 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
293 for estr in self.extra_strings:
294 lines.append(' <extra-string>%s</extra-string>' % estr)
295 lines.append('</comment>')
298 return istring + sep.join(lines).rstrip('\n')
300 def from_xml(self, xml_string, verbose=True):
302 Note: If alt-id is not given, translates any <uuid> fields to
304 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
305 >>> commA.uuid = "0123"
306 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
307 >>> commA.author = u'Fran\xe7ois'
308 >>> commA.extra_strings += ['TAG: very helpful']
309 >>> xml = commA.xml()
310 >>> commB = Comment()
311 >>> commB.from_xml(xml, verbose=True)
312 >>> commB.explicit_attrs
313 ['author', 'date', 'content_type', 'body', 'alt_id']
314 >>> commB.xml() == xml
316 >>> commB.uuid = commB.alt_id
317 >>> commB.alt_id = None
318 >>> commB.xml() == xml
321 if type(xml_string) == types.UnicodeType:
322 xml_string = xml_string.strip().encode('unicode_escape')
323 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
326 comment = ElementTree.XML(xml_string)
327 if comment.tag != 'comment':
328 raise utility.InvalidXML( \
329 'comment', comment, 'root element must be <comment>')
330 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
331 'body','extra-string']
332 self.explicit_attrs = []
336 for child in comment.getchildren():
337 if child.tag == 'short-name':
339 elif child.tag in tags:
340 if child.text == None or len(child.text) == 0:
341 text = settings_object.EMPTY
343 text = xml.sax.saxutils.unescape(child.text)
344 text = text.decode('unicode_escape').strip()
345 if child.tag == 'uuid':
347 continue # don't set the comment's uuid tag.
348 elif child.tag == 'body':
350 self.explicit_attrs.append(child.tag)
351 continue # don't set the comment's body yet.
352 elif child.tag == 'extra-string':
354 continue # don't set the comment's extra_string yet.
355 attr_name = child.tag.replace('-','_')
356 self.explicit_attrs.append(attr_name)
357 setattr(self, attr_name, text)
358 elif verbose == True:
359 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
360 % (child.tag, comment.tag)
361 if uuid != self.uuid and self.alt_id == None:
362 self.explicit_attrs.append('alt_id')
365 if self.content_type.startswith('text/'):
366 self.body = body+'\n' # restore trailing newline
368 self.body = base64.decodestring(body)
369 self.extra_strings = estrs
371 def merge(self, other, accept_changes=True,
372 accept_extra_strings=True, change_exception=False):
374 Merge info from other into this comment. Overrides any
375 attributes in self that are listed in other.explicit_attrs.
376 >>> commA = Comment(bug=None, body='Some insightful remarks')
377 >>> commA.uuid = '0123'
378 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
379 >>> commA.author = 'Frank'
380 >>> commA.extra_strings += ['TAG: very helpful']
381 >>> commA.extra_strings += ['TAG: favorite']
382 >>> commB = Comment(bug=None, body='More insightful remarks')
383 >>> commB.uuid = '3210'
384 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
385 >>> commB.author = 'John'
386 >>> commB.explicit_attrs = ['author', 'body']
387 >>> commB.extra_strings += ['TAG: very helpful']
388 >>> commB.extra_strings += ['TAG: useful']
389 >>> commA.merge(commB, accept_changes=False,
390 ... accept_extra_strings=False, change_exception=False)
391 >>> commA.merge(commB, accept_changes=False,
392 ... accept_extra_strings=False, change_exception=True)
393 Traceback (most recent call last):
395 ValueError: Merge would change author "Frank"->"John" for comment 0123
396 >>> commA.merge(commB, accept_changes=True,
397 ... accept_extra_strings=False, change_exception=True)
398 Traceback (most recent call last):
400 ValueError: Merge would add extra string "TAG: useful" to comment 0123
401 >>> print commA.author
403 >>> print commA.extra_strings
404 ['TAG: favorite', 'TAG: very helpful']
405 >>> commA.merge(commB, accept_changes=True,
406 ... accept_extra_strings=True, change_exception=True)
407 >>> print commA.extra_strings
408 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
409 >>> print commA.xml()
412 <short-name>//012</short-name>
413 <author>John</author>
414 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
415 <content-type>text/plain</content-type>
416 <body>More insightful remarks</body>
417 <extra-string>TAG: favorite</extra-string>
418 <extra-string>TAG: useful</extra-string>
419 <extra-string>TAG: very helpful</extra-string>
422 for attr in other.explicit_attrs:
423 old = getattr(self, attr)
424 new = getattr(other, attr)
426 if accept_changes == True:
427 setattr(self, attr, new)
428 elif change_exception == True:
430 'Merge would change %s "%s"->"%s" for comment %s' \
431 % (attr, old, new, self.uuid)
432 if self.alt_id == self.uuid:
434 for estr in other.extra_strings:
435 if not estr in self.extra_strings:
436 if accept_extra_strings == True:
437 self.extra_strings.append(estr)
438 elif change_exception == True:
440 'Merge would add extra string "%s" to comment %s' \
443 def string(self, indent=0):
445 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
446 >>> comm.uuid = 'abcdef'
447 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
448 >>> print comm.string(indent=2)
449 --------- Comment ---------
452 Date: Thu, 01 Jan 1970 00:00:00 +0000
459 lines.append("--------- Comment ---------")
460 lines.append("Name: %s" % self.id.user())
461 lines.append("From: %s" % (self._setting_attr_string("author")))
462 lines.append("Date: %s" % self.date)
464 if self.content_type.startswith("text/"):
465 body = (self.body or "")
466 if self.bug != None and self.bug.bugdir != None:
467 body = libbe.util.id.long_to_short_user([self.bug.bugdir], body)
468 lines.extend(body.splitlines())
470 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
474 return istring + sep.join(lines).rstrip('\n')
476 def string_thread(self, string_method_name="string",
477 indent=0, flatten=True):
479 Return a string displaying a thread of comments.
480 bug_shortname is only used if auto_name_map == True.
482 string_method_name (defaults to "string") is the name of the
483 Comment method used to generate the output string for each
484 Comment in the thread. The method must take the arguments
485 indent and shortname.
487 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
488 which will sort the tree by comment.time. Avoid by calling
490 for shortname,comment in comm.comment_shortnames(bug_shortname):
491 name_map[comment.uuid] = shortname
492 comm.sort(key=lambda c : c.author) # your sort
493 comm.string_thread(name_map=name_map)
495 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
496 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
497 >>> b = a.new_reply("Critique original comment")
499 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
500 >>> c = b.new_reply("Begin flamewar :p")
502 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
503 >>> d = a.new_reply("Useful examples")
505 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
506 >>> a.sort(key=lambda comm : comm.time)
507 >>> print a.string_thread(flatten=True)
508 --------- Comment ---------
511 Date: Thu, 20 Nov 2008 01:00:00 +0000
514 --------- Comment ---------
517 Date: Thu, 20 Nov 2008 02:00:00 +0000
519 Critique original comment
520 --------- Comment ---------
523 Date: Thu, 20 Nov 2008 03:00:00 +0000
526 --------- Comment ---------
529 Date: Thu, 20 Nov 2008 04:00:00 +0000
532 >>> print a.string_thread()
533 --------- Comment ---------
536 Date: Thu, 20 Nov 2008 01:00:00 +0000
539 --------- Comment ---------
542 Date: Thu, 20 Nov 2008 02:00:00 +0000
544 Critique original comment
545 --------- Comment ---------
548 Date: Thu, 20 Nov 2008 03:00:00 +0000
551 --------- Comment ---------
554 Date: Thu, 20 Nov 2008 04:00:00 +0000
559 for depth,comment in self.thread(flatten=flatten):
561 string_fn = getattr(comment, string_method_name)
562 stringlist.append(string_fn(indent=ind))
563 return '\n'.join(stringlist)
565 def xml_thread(self, indent=0):
566 return self.string_thread(string_method_name="xml", indent=indent)
568 # methods for saving/loading/acessing settings and properties.
570 def load_settings(self, settings_mapfile=None):
571 if settings_mapfile == None:
573 self.storage.get(self.id.storage("values"), default="\n")
575 self.settings = mapfile.parse(settings_mapfile)
576 except mapfile.InvalidMapfileContents, e:
577 raise Exception('Invalid settings file for comment %s\n'
578 '(BE version missmatch?)' % self.id.user())
579 self._setup_saved_settings()
581 def save_settings(self):
582 mf = mapfile.generate(self._get_saved_settings())
583 self.storage.set(self.id.storage("values"), mf)
587 Save any loaded contents to storage.
589 However, if self.storage.is_writeable() == True, then any
590 changes are automatically written to storage as soon as they
591 happen, so calling this method will just waste time (unless
592 something else has been messing with your stored files).
594 if self.uuid == INVALID_UUID:
596 assert self.storage != None, "Can't save without storage"
597 assert self.body != None, "Can't save blank comment"
599 parent = self.bug.id.storage()
602 self.storage.add(self.id.storage(), parent=parent, directory=True)
603 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
605 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
608 self._set_comment_body(new=self.body, force=True)
613 if self.uuid != INVALID_UUID:
614 self.storage.recursive_remove(self.id.storage())
616 def add_reply(self, reply, allow_time_inversion=False):
617 if self.uuid != INVALID_UUID:
618 reply.in_reply_to = self.uuid
621 def new_reply(self, body=None):
623 >>> comm = Comment(bug=None, body="Some insightful remarks")
624 >>> repA = comm.new_reply("Critique original comment")
625 >>> repB = repA.new_reply("Begin flamewar :p")
626 >>> repB.in_reply_to == repA.uuid
629 reply = Comment(self.bug, body=body)
630 self.add_reply(reply)
633 def comment_from_uuid(self, uuid, match_alt_id=True):
635 Use a uuid to look up a comment.
636 >>> a = Comment(bug=None, uuid="a")
637 >>> b = a.new_reply()
639 >>> c = b.new_reply()
641 >>> d = a.new_reply()
643 >>> d.alt_id = "d-alt"
644 >>> comm = a.comment_from_uuid("d")
645 >>> id(comm) == id(d)
647 >>> comm = a.comment_from_uuid("d-alt")
648 >>> id(comm) == id(d)
650 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
651 Traceback (most recent call last):
655 for comment in self.traverse():
656 if comment.uuid == uuid:
658 if match_alt_id == True and uuid != None \
659 and comment.alt_id == uuid:
663 # methods for id generation
665 def sibling_uuids(self):
667 return self.bug.uuids()
671 def cmp_attr(comment_1, comment_2, attr, invert=False):
673 Compare a general attribute between two comments using the conventional
674 comparison rule for that attribute type. If invert == True, sort
675 *against* that convention.
677 >>> commentA = Comment()
678 >>> commentB = Comment()
679 >>> commentA.author = "John Doe"
680 >>> commentB.author = "Jane Doe"
681 >>> cmp_attr(commentA, commentB, attr) > 0
683 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
685 >>> commentB.author = "John Doe"
686 >>> cmp_attr(commentA, commentB, attr) == 0
689 if not hasattr(comment_2, attr) :
691 val_1 = getattr(comment_1, attr)
692 val_2 = getattr(comment_2, attr)
693 if val_1 == None: val_1 = None
694 if val_2 == None: val_2 = None
697 return -cmp(val_1, val_2)
699 return cmp(val_1, val_2)
701 # alphabetical rankings (a < z)
702 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
703 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
704 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
705 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
706 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
707 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
708 # chronological rankings (newer < older)
709 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
712 DEFAULT_CMP_FULL_CMP_LIST = \
713 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
714 cmp_uuid, cmp_extra_strings)
716 class CommentCompoundComparator (object):
717 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
718 self.cmp_list = cmp_list
719 def __call__(self, comment_1, comment_2):
720 for comparison in self.cmp_list :
721 val = comparison(comment_1, comment_2)
726 cmp_full = CommentCompoundComparator()
728 if libbe.TESTING == True:
729 suite = doctest.DocTestSuite()