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(bug.storage.children()):
80 comm = Comment(bug, uuid, from_storage=True)
83 dummy = comm.body # force the body to load
85 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
86 bug.add_comments(comments)
87 return bug.comment_root
89 def save_comments(bug):
90 for comment in bug.comment_root.traverse():
94 class Comment(Tree, settings_object.SavedSettingsObject):
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 def _get_user_id(self):
123 return self.bug._get_user_id()
126 @_versioned_property(name="Author",
127 doc="The author of the comment",
128 generator=_get_user_id)
129 def author(): return {}
131 @_versioned_property(name="In-reply-to",
132 doc="UUID for parent comment or bug")
133 def in_reply_to(): return {}
135 @_versioned_property(name="Content-type",
136 doc="Mime type for comment body",
137 default="text/plain",
139 def content_type(): return {}
141 @_versioned_property(name="Date",
142 doc="An RFC 2822 timestamp for comment creation")
143 def date(): return {}
146 if self.date == None:
148 return utility.str_to_time(self.date)
149 def _set_time(self, value):
150 self.date = utility.time_to_str(value)
151 time = property(fget=_get_time,
153 doc="An integer version of .date")
155 def _get_comment_body(self):
156 if self.storage != None and self.storage.is_readable() \
157 and self.uuid != INVALID_UUID:
158 return self.storage.get(self.id.storage("body"),
159 decode=self.content_type.startswith("text/"))
160 def _set_comment_body(self, old=None, new=None, force=False):
161 assert self.uuid != INVALID_UUID, self
162 if (self.storage != None and self.storage.writeable == True) \
164 assert new != None, "Can't save empty comment"
165 self.storage.set(self.id.storage("body"), new)
168 @change_hook_property(hook=_set_comment_body)
169 @cached_property(generator=_get_comment_body)
170 @local_property("body")
171 @doc_property(doc="The meat of the comment")
172 def body(): return {}
174 def _extra_strings_check_fn(value):
175 return utility.iterable_full_of_strings(value, \
176 alternative=settings_object.EMPTY)
177 def _extra_strings_change_hook(self, old, new):
178 self.extra_strings.sort() # to make merging easier
179 self._prop_save_settings(old, new)
180 @_versioned_property(name="extra_strings",
181 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
183 check_fn=_extra_strings_check_fn,
184 change_hook=_extra_strings_change_hook,
186 def extra_strings(): return {}
188 def __init__(self, bug=None, uuid=None, from_storage=False,
189 in_reply_to=None, body=None):
191 Set from_storage=True to load an old comment.
192 Set from_storage=False to create a new comment.
194 The uuid option is required when from_storage==True.
196 The in_reply_to and body options are only used if
197 from_storage==False (the default). When from_storage==True,
198 they are loaded from the bug database.
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()
212 self._setup_saved_settings()
213 self.time = int(time.time()) # only save to second precision
214 self.in_reply_to = in_reply_to
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 xml(self, indent=0):
260 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
261 >>> comm.uuid = "0123"
262 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
263 >>> print comm.xml(indent=2)
266 <short-name>//012</short-name>
268 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
269 <content-type>text/plain</content-type>
275 if self.content_type.startswith('text/'):
276 body = (self.body or '').rstrip('\n')
278 maintype,subtype = self.content_type.split('/',1)
279 msg = email.mime.base.MIMEBase(maintype, subtype)
280 msg.set_payload(self.body or '')
281 email.encoders.encode_base64(msg)
282 body = base64.encodestring(self.body or '')
283 info = [('uuid', self.uuid),
284 ('alt-id', self.alt_id),
285 ('short-name', self.id.user()),
286 ('in-reply-to', self.in_reply_to),
287 ('author', self._setting_attr_string('author')),
289 ('content-type', self.content_type),
291 lines = ['<comment>']
294 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
295 for estr in self.extra_strings:
296 lines.append(' <extra-string>%s</extra-string>' % estr)
297 lines.append('</comment>')
300 return istring + sep.join(lines).rstrip('\n')
302 def from_xml(self, xml_string, verbose=True):
304 Note: If alt-id is not given, translates any <uuid> fields to
306 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
307 >>> commA.uuid = "0123"
308 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
309 >>> commA.author = u'Fran\xe7ois'
310 >>> commA.extra_strings += ['TAG: very helpful']
311 >>> xml = commA.xml()
312 >>> commB = Comment()
313 >>> commB.from_xml(xml, verbose=True)
314 >>> commB.explicit_attrs
315 ['author', 'date', 'content_type', 'body', 'alt_id']
316 >>> commB.xml() == xml
318 >>> commB.uuid = commB.alt_id
319 >>> commB.alt_id = None
320 >>> commB.xml() == xml
323 if type(xml_string) == types.UnicodeType:
324 xml_string = xml_string.strip().encode('unicode_escape')
325 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
328 comment = ElementTree.XML(xml_string)
329 if comment.tag != 'comment':
330 raise utility.InvalidXML( \
331 'comment', comment, 'root element must be <comment>')
332 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
333 'body','extra-string']
334 self.explicit_attrs = []
338 for child in comment.getchildren():
339 if child.tag == 'short-name':
341 elif child.tag in tags:
342 if child.text == None or len(child.text) == 0:
343 text = settings_object.EMPTY
345 text = xml.sax.saxutils.unescape(child.text)
346 text = text.decode('unicode_escape').strip()
347 if child.tag == 'uuid':
349 continue # don't set the comment's uuid tag.
350 elif child.tag == 'body':
352 self.explicit_attrs.append(child.tag)
353 continue # don't set the comment's body yet.
354 elif child.tag == 'extra-string':
356 continue # don't set the comment's extra_string yet.
357 attr_name = child.tag.replace('-','_')
358 self.explicit_attrs.append(attr_name)
359 setattr(self, attr_name, text)
360 elif verbose == True:
361 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
362 % (child.tag, comment.tag)
363 if uuid != self.uuid and self.alt_id == None:
364 self.explicit_attrs.append('alt_id')
367 if self.content_type.startswith('text/'):
368 self.body = body+'\n' # restore trailing newline
370 self.body = base64.decodestring(body)
371 self.extra_strings = estrs
373 def merge(self, other, accept_changes=True,
374 accept_extra_strings=True, change_exception=False):
376 Merge info from other into this comment. Overrides any
377 attributes in self that are listed in other.explicit_attrs.
378 >>> commA = Comment(bug=None, body='Some insightful remarks')
379 >>> commA.uuid = '0123'
380 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
381 >>> commA.author = 'Frank'
382 >>> commA.extra_strings += ['TAG: very helpful']
383 >>> commA.extra_strings += ['TAG: favorite']
384 >>> commB = Comment(bug=None, body='More insightful remarks')
385 >>> commB.uuid = '3210'
386 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
387 >>> commB.author = 'John'
388 >>> commB.explicit_attrs = ['author', 'body']
389 >>> commB.extra_strings += ['TAG: very helpful']
390 >>> commB.extra_strings += ['TAG: useful']
391 >>> commA.merge(commB, accept_changes=False,
392 ... accept_extra_strings=False, change_exception=False)
393 >>> commA.merge(commB, accept_changes=False,
394 ... accept_extra_strings=False, change_exception=True)
395 Traceback (most recent call last):
397 ValueError: Merge would change author "Frank"->"John" for comment 0123
398 >>> commA.merge(commB, accept_changes=True,
399 ... accept_extra_strings=False, change_exception=True)
400 Traceback (most recent call last):
402 ValueError: Merge would add extra string "TAG: useful" to comment 0123
403 >>> print commA.author
405 >>> print commA.extra_strings
406 ['TAG: favorite', 'TAG: very helpful']
407 >>> commA.merge(commB, accept_changes=True,
408 ... accept_extra_strings=True, change_exception=True)
409 >>> print commA.extra_strings
410 ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
411 >>> print commA.xml()
414 <short-name>//012</short-name>
415 <author>John</author>
416 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
417 <content-type>text/plain</content-type>
418 <body>More insightful remarks</body>
419 <extra-string>TAG: favorite</extra-string>
420 <extra-string>TAG: useful</extra-string>
421 <extra-string>TAG: very helpful</extra-string>
424 for attr in other.explicit_attrs:
425 old = getattr(self, attr)
426 new = getattr(other, attr)
428 if accept_changes == True:
429 setattr(self, attr, new)
430 elif change_exception == True:
432 'Merge would change %s "%s"->"%s" for comment %s' \
433 % (attr, old, new, self.uuid)
434 if self.alt_id == self.uuid:
436 for estr in other.extra_strings:
437 if not estr in self.extra_strings:
438 if accept_extra_strings == True:
439 self.extra_strings.append(estr)
440 elif change_exception == True:
442 'Merge would add extra string "%s" to comment %s' \
445 def string(self, indent=0):
447 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
448 >>> comm.uuid = 'abcdef'
449 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
450 >>> print comm.string(indent=2)
451 --------- Comment ---------
454 Date: Thu, 01 Jan 1970 00:00:00 +0000
461 lines.append("--------- Comment ---------")
462 lines.append("Name: %s" % self.id.user())
463 lines.append("From: %s" % (self._setting_attr_string("author")))
464 lines.append("Date: %s" % self.date)
466 if self.content_type.startswith("text/"):
467 lines.extend((self.body or "").splitlines())
469 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
473 return istring + sep.join(lines).rstrip('\n')
475 def string_thread(self, string_method_name="string",
476 indent=0, flatten=True):
478 Return a string displaying a thread of comments.
479 bug_shortname is only used if auto_name_map == True.
481 string_method_name (defaults to "string") is the name of the
482 Comment method used to generate the output string for each
483 Comment in the thread. The method must take the arguments
484 indent and shortname.
486 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
487 which will sort the tree by comment.time. Avoid by calling
489 for shortname,comment in comm.comment_shortnames(bug_shortname):
490 name_map[comment.uuid] = shortname
491 comm.sort(key=lambda c : c.author) # your sort
492 comm.string_thread(name_map=name_map)
494 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
495 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
496 >>> b = a.new_reply("Critique original comment")
498 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
499 >>> c = b.new_reply("Begin flamewar :p")
501 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
502 >>> d = a.new_reply("Useful examples")
504 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
505 >>> a.sort(key=lambda comm : comm.time)
506 >>> print a.string_thread(flatten=True)
507 --------- Comment ---------
510 Date: Thu, 20 Nov 2008 01:00:00 +0000
513 --------- Comment ---------
516 Date: Thu, 20 Nov 2008 02:00:00 +0000
518 Critique original comment
519 --------- Comment ---------
522 Date: Thu, 20 Nov 2008 03:00:00 +0000
525 --------- Comment ---------
528 Date: Thu, 20 Nov 2008 04:00:00 +0000
531 >>> print a.string_thread()
532 --------- Comment ---------
535 Date: Thu, 20 Nov 2008 01:00:00 +0000
538 --------- Comment ---------
541 Date: Thu, 20 Nov 2008 02:00:00 +0000
543 Critique original comment
544 --------- Comment ---------
547 Date: Thu, 20 Nov 2008 03:00:00 +0000
550 --------- Comment ---------
553 Date: Thu, 20 Nov 2008 04:00:00 +0000
558 for depth,comment in self.thread(flatten=flatten):
560 string_fn = getattr(comment, string_method_name)
561 stringlist.append(string_fn(indent=ind))
562 return '\n'.join(stringlist)
564 def xml_thread(self, indent=0):
565 return self.string_thread(string_method_name="xml", indent=indent)
567 # methods for saving/loading/acessing settings and properties.
569 def load_settings(self, settings_mapfile=None):
570 if settings_mapfile == None:
572 self.storage.get(self.id.storage("values"), default="\n")
573 self.settings = mapfile.parse(settings_mapfile)
574 self._setup_saved_settings()
576 def save_settings(self):
577 mf = mapfile.generate(self._get_saved_settings())
578 self.storage.set(self.id.storage("values"), mf)
582 Save any loaded contents to storage.
584 However, if self.storage.is_writeable() == True, then any
585 changes are automatically written to storage as soon as they
586 happen, so calling this method will just waste time (unless
587 something else has been messing with your stored files).
589 if self.uuid == INVALID_UUID:
591 assert self.storage != None, "Can't save without storage"
592 assert self.body != None, "Can't save blank comment"
594 parent = self.bug.id.storage()
597 self.storage.add(self.id.storage(), parent=parent, directory=True)
598 self.storage.add(self.id.storage('values'), parent=self.id.storage(),
600 self.storage.add(self.id.storage('body'), parent=self.id.storage(),
603 self._set_comment_body(new=self.body, force=True)
608 if self.uuid != INVALID_UUID:
609 self.storage.recursive_remove(self.id.storage())
611 def add_reply(self, reply, allow_time_inversion=False):
612 if self.uuid != INVALID_UUID:
613 reply.in_reply_to = self.uuid
616 def new_reply(self, body=None, content_type=None):
618 >>> comm = Comment(bug=None, body="Some insightful remarks")
619 >>> repA = comm.new_reply("Critique original comment")
620 >>> repB = repA.new_reply("Begin flamewar :p")
621 >>> repB.in_reply_to == repA.uuid
624 reply = Comment(self.bug, body=body)
625 if content_type != None: # set before saving body to decide binary format
626 reply.content_type = content_type
627 if reply.storage != None and reply.storage.is_writeable():
629 self.add_reply(reply)
632 def comment_from_uuid(self, uuid, match_alt_id=True):
634 Use a uuid to look up a comment.
635 >>> a = Comment(bug=None, uuid="a")
636 >>> b = a.new_reply()
638 >>> c = b.new_reply()
640 >>> d = a.new_reply()
642 >>> d.alt_id = "d-alt"
643 >>> comm = a.comment_from_uuid("d")
644 >>> id(comm) == id(d)
646 >>> comm = a.comment_from_uuid("d-alt")
647 >>> id(comm) == id(d)
649 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
650 Traceback (most recent call last):
654 for comment in self.traverse():
655 if comment.uuid == uuid:
657 if match_alt_id == True and uuid != None \
658 and comment.alt_id == uuid:
662 # methods for id generation
664 def sibling_uuids(self):
666 return self.bug.uuids()
670 def cmp_attr(comment_1, comment_2, attr, invert=False):
672 Compare a general attribute between two comments using the conventional
673 comparison rule for that attribute type. If invert == True, sort
674 *against* that convention.
676 >>> commentA = Comment()
677 >>> commentB = Comment()
678 >>> commentA.author = "John Doe"
679 >>> commentB.author = "Jane Doe"
680 >>> cmp_attr(commentA, commentB, attr) > 0
682 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
684 >>> commentB.author = "John Doe"
685 >>> cmp_attr(commentA, commentB, attr) == 0
688 if not hasattr(comment_2, attr) :
690 val_1 = getattr(comment_1, attr)
691 val_2 = getattr(comment_2, attr)
692 if val_1 == None: val_1 = None
693 if val_2 == None: val_2 = None
696 return -cmp(val_1, val_2)
698 return cmp(val_1, val_2)
700 # alphabetical rankings (a < z)
701 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
702 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
703 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
704 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
705 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
706 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
707 # chronological rankings (newer < older)
708 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
711 DEFAULT_CMP_FULL_CMP_LIST = \
712 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
713 cmp_uuid, cmp_extra_strings)
715 class CommentCompoundComparator (object):
716 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
717 self.cmp_list = cmp_list
718 def __call__(self, comment_1, comment_2):
719 for comparison in self.cmp_list :
720 val = comparison(comment_1, comment_2)
725 cmp_full = CommentCompoundComparator()
727 if libbe.TESTING == True:
728 suite = doctest.DocTestSuite()