1 # Bugs Everywhere, a distributed bugtracker
2 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
3 # Thomas Habets <thomas@habets.pp.se>
4 # W. Trevor King <wking@drexel.edu>
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License along
17 # with this program; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21 Define the Comment class for representing bug comments.
30 try: # import core module, Python >= 2.5
31 from xml.etree import ElementTree
32 except ImportError: # look for non-core module
33 from elementtree import ElementTree
34 import xml.sax.saxutils
37 from beuuid import uuid_gen
38 from properties import Property, doc_property, local_property, \
39 defaulting_property, checked_property, cached_property, \
40 primed_property, change_hook_property, settings_property
41 import settings_object
47 class InvalidShortname(KeyError):
48 def __init__(self, shortname, shortnames):
49 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
50 KeyError.__init__(self, msg)
51 self.shortname = shortname
52 self.shortnames = shortnames
54 class MissingReference(ValueError):
55 def __init__(self, comment):
56 msg = "Missing reference to %s" % (comment.in_reply_to)
57 ValueError.__init__(self, msg)
58 self.reference = comment.in_reply_to
59 self.comment = comment
61 class DiskAccessRequired (Exception):
62 def __init__(self, goal):
63 msg = "Cannot %s without accessing the disk" % goal
64 Exception.__init__(self, msg)
66 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
68 def list_to_root(comments, bug, root=None,
69 ignore_missing_references=False):
71 Convert a raw list of comments to single root comment. We use a
72 dummy root comment by default, because there can be several
73 comment threads rooted on the same parent bug. To simplify
74 comment interaction, we condense these threads into a single
75 thread with a Comment dummy root. Can also be used to append
76 a list of subcomments to a non-dummy root comment, so long as
77 all the new comments are descendants of the root comment.
79 No Comment method should use the dummy comment.
83 for comment in comments:
84 assert comment.uuid != None
85 uuid_map[comment.uuid] = comment
86 for comment in comments:
87 if comment.alt_id != None and comment.alt_id not in uuid_map:
88 uuid_map[comment.alt_id] = comment
90 root = Comment(bug, uuid=INVALID_UUID)
92 uuid_map[root.uuid] = root
94 if comm.in_reply_to == INVALID_UUID:
95 comm.in_reply_to = None
96 rep = comm.in_reply_to
97 if rep == None or rep == bug.uuid:
98 root_comments.append(comm)
100 parentUUID = comm.in_reply_to
102 parent = uuid_map[parentUUID]
103 parent.add_reply(comm)
105 if ignore_missing_references == True:
106 print >> sys.stderr, \
107 "Ignoring missing reference to %s" % parentUUID
108 comm.in_reply_to = None
109 root_comments.append(comm)
111 raise MissingReference(comm)
112 root.extend(root_comments)
115 def loadComments(bug, load_full=False):
117 Set load_full=True when you want to load the comment completely
118 from disk *now*, rather than waiting and lazy loading as required.
120 if bug.sync_with_disk == False:
121 raise DiskAccessRequired("load comments")
122 path = bug.get_path("comments")
123 if not os.path.exists(path):
124 return Comment(bug, uuid=INVALID_UUID)
126 for uuid in os.listdir(path):
127 if uuid.startswith('.'):
129 comm = Comment(bug, uuid, from_disk=True)
130 comm.set_sync_with_disk(bug.sync_with_disk)
131 if load_full == True:
133 dummy = comm.body # force the body to load
134 comments.append(comm)
135 return list_to_root(comments, bug)
137 def saveComments(bug):
138 if bug.sync_with_disk == False:
139 raise DiskAccessRequired("save comments")
140 for comment in bug.comment_root.traverse():
144 class Comment(Tree, settings_object.SavedSettingsObject):
149 >>> c.uuid = "some-UUID"
150 >>> print c.content_type
154 settings_properties = []
155 required_saved_properties = []
156 _prop_save_settings = settings_object.prop_save_settings
157 _prop_load_settings = settings_object.prop_load_settings
158 def _versioned_property(settings_properties=settings_properties,
159 required_saved_properties=required_saved_properties,
161 if "settings_properties" not in kwargs:
162 kwargs["settings_properties"] = settings_properties
163 if "required_saved_properties" not in kwargs:
164 kwargs["required_saved_properties"]=required_saved_properties
165 return settings_object.versioned_property(**kwargs)
167 @_versioned_property(name="Alt-id",
168 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.")
169 def alt_id(): return {}
171 @_versioned_property(name="Author",
172 doc="The author of the comment")
173 def author(): return {}
175 @_versioned_property(name="In-reply-to",
176 doc="UUID for parent comment or bug")
177 def in_reply_to(): return {}
179 @_versioned_property(name="Content-type",
180 doc="Mime type for comment body",
181 default="text/plain",
183 def content_type(): return {}
185 @_versioned_property(name="Date",
186 doc="An RFC 2822 timestamp for comment creation")
187 def date(): return {}
190 if self.date == None:
192 return utility.str_to_time(self.date)
193 def _set_time(self, value):
194 self.date = utility.time_to_str(value)
195 time = property(fget=_get_time,
197 doc="An integer version of .date")
199 def _get_comment_body(self):
200 if self.vcs != None and self.sync_with_disk == True:
202 binary = not self.content_type.startswith("text/")
203 return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
204 def _set_comment_body(self, old=None, new=None, force=False):
205 if (self.vcs != None and self.sync_with_disk == True) or force==True:
206 assert new != None, "Can't save empty comment"
207 binary = not self.content_type.startswith("text/")
208 self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
211 @change_hook_property(hook=_set_comment_body)
212 @cached_property(generator=_get_comment_body)
213 @local_property("body")
214 @doc_property(doc="The meat of the comment")
215 def body(): return {}
218 if hasattr(self.bug, "vcs"):
222 @cached_property(generator=_get_vcs)
223 @local_property("vcs")
224 @doc_property(doc="A revision control system instance.")
227 def _extra_strings_check_fn(value):
228 return utility.iterable_full_of_strings(value, \
229 alternative=settings_object.EMPTY)
230 def _extra_strings_change_hook(self, old, new):
231 self.extra_strings.sort() # to make merging easier
232 self._prop_save_settings(old, new)
233 @_versioned_property(name="extra_strings",
234 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
236 check_fn=_extra_strings_check_fn,
237 change_hook=_extra_strings_change_hook,
239 def extra_strings(): return {}
241 def __init__(self, bug=None, uuid=None, from_disk=False,
242 in_reply_to=None, body=None):
244 Set from_disk=True to load an old comment.
245 Set from_disk=False to create a new comment.
247 The uuid option is required when from_disk==True.
249 The in_reply_to and body options are only used if
250 from_disk==False (the default). When from_disk==True, they are
251 loaded from the bug database.
253 in_reply_to should be the uuid string of the parent comment.
256 settings_object.SavedSettingsObject.__init__(self)
259 if from_disk == True:
260 self.sync_with_disk = True
262 self.sync_with_disk = False
264 self.uuid = uuid_gen()
265 self.time = int(time.time()) # only save to second precision
267 self.author = self.vcs.get_user_id()
268 self.in_reply_to = in_reply_to
271 def __cmp__(self, other):
272 return cmp_full(self, other)
276 >>> comm = Comment(bug=None, body="Some insightful remarks")
277 >>> comm.uuid = "com-1"
278 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
279 >>> comm.author = "Jane Doe <jdoe@example.com>"
281 --------- Comment ---------
283 From: Jane Doe <jdoe@example.com>
284 Date: Thu, 20 Nov 2008 15:55:11 +0000
286 Some insightful remarks
290 def traverse(self, *args, **kwargs):
291 """Avoid working with the possible dummy root comment"""
292 for comment in Tree.traverse(self, *args, **kwargs):
293 if comment.uuid == INVALID_UUID:
297 # serializing methods
299 def _setting_attr_string(self, setting):
300 value = getattr(self, setting)
303 if type(value) not in types.StringTypes:
307 def xml(self, indent=0, shortname=None):
309 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
310 >>> comm.uuid = "0123"
311 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
312 >>> print comm.xml(indent=2, shortname="com-1")
315 <short-name>com-1</short-name>
317 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
318 <content-type>text/plain</content-type>
324 if shortname == None:
325 shortname = self.uuid
326 if self.content_type.startswith('text/'):
327 body = (self.body or '').rstrip('\n')
329 maintype,subtype = self.content_type.split('/',1)
330 msg = email.mime.base.MIMEBase(maintype, subtype)
331 msg.set_payload(self.body or '')
332 email.encoders.encode_base64(msg)
333 body = base64.encodestring(self.body or '')
334 info = [('uuid', self.uuid),
335 ('alt-id', self.alt_id),
336 ('short-name', shortname),
337 ('in-reply-to', self.in_reply_to),
338 ('author', self._setting_attr_string('author')),
340 ('content-type', self.content_type),
342 lines = ['<comment>']
345 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
346 for estr in self.extra_strings:
347 lines.append(' <extra-string>%s</extra-string>\n' % estr)
348 lines.append('</comment>')
351 return istring + sep.join(lines).rstrip('\n')
353 def from_xml(self, xml_string, verbose=True):
355 Note: If alt-id is not given, translates any <uuid> fields to
357 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
358 >>> commA.uuid = "0123"
359 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
360 >>> commA.author = u'Fran\xe7ois'
361 >>> commA.extra_strings += ['TAG: very helpful']
362 >>> xml = commA.xml(shortname="com-1")
363 >>> commB = Comment()
364 >>> commB.from_xml(xml, verbose=True)
365 >>> commB.xml(shortname="com-1") == xml
367 >>> commB.uuid = commB.alt_id
368 >>> commB.alt_id = None
369 >>> commB.xml(shortname="com-1") == xml
372 if type(xml_string) == types.UnicodeType:
373 xml_string = xml_string.strip().encode('unicode_escape')
374 comment = ElementTree.XML(xml_string)
375 if comment.tag != 'comment':
376 raise utility.InvalidXML( \
377 'comment', comment, 'root element must be <comment>')
378 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
379 'body','extra-string']
383 for child in comment.getchildren():
384 if child.tag == 'short-name':
386 elif child.tag in tags:
387 if child.text == None or len(child.text) == 0:
388 text = settings_object.EMPTY
390 text = xml.sax.saxutils.unescape(child.text)
391 text = text.decode('unicode_escape').strip()
392 if child.tag == 'uuid':
394 continue # don't set the comment's uuid tag.
395 if child.tag == 'body':
397 continue # don't set the comment's body yet.
398 if child.tag == 'extra-string':
400 continue # don't set the comment's extra_string yet.
402 attr_name = child.tag.replace('-','_')
403 setattr(self, attr_name, text)
404 elif verbose == True:
405 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
406 % (child.tag, comment.tag)
407 if self.alt_id == None and uuid not in [None, self.uuid]:
410 if self.content_type.startswith('text/'):
411 self.body = body+'\n' # restore trailing newline
413 self.body = base64.decodestring(body)
414 self.extra_strings = estrs
416 def string(self, indent=0, shortname=None):
418 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
419 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
420 >>> print comm.string(indent=2, shortname="com-1")
421 --------- Comment ---------
424 Date: Thu, 01 Jan 1970 00:00:00 +0000
430 if shortname == None:
431 shortname = self.uuid
433 lines.append("--------- Comment ---------")
434 lines.append("Name: %s" % shortname)
435 lines.append("From: %s" % (self._setting_attr_string("author")))
436 lines.append("Date: %s" % self.date)
438 if self.content_type.startswith("text/"):
439 lines.extend((self.body or "").splitlines())
441 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
445 return istring + sep.join(lines).rstrip('\n')
447 def string_thread(self, string_method_name="string", name_map={},
448 indent=0, flatten=True,
449 auto_name_map=False, bug_shortname=None):
451 Return a string displaying a thread of comments.
452 bug_shortname is only used if auto_name_map == True.
454 string_method_name (defaults to "string") is the name of the
455 Comment method used to generate the output string for each
456 Comment in the thread. The method must take the arguments
457 indent and shortname.
459 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
460 which will sort the tree by comment.time. Avoid by calling
462 for shortname,comment in comm.comment_shortnames(bug_shortname):
463 name_map[comment.uuid] = shortname
464 comm.sort(key=lambda c : c.author) # your sort
465 comm.string_thread(name_map=name_map)
467 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
468 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
469 >>> b = a.new_reply("Critique original comment")
471 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
472 >>> c = b.new_reply("Begin flamewar :p")
474 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
475 >>> d = a.new_reply("Useful examples")
477 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
478 >>> a.sort(key=lambda comm : comm.time)
479 >>> print a.string_thread(flatten=True)
480 --------- Comment ---------
483 Date: Thu, 20 Nov 2008 01:00:00 +0000
486 --------- Comment ---------
489 Date: Thu, 20 Nov 2008 02:00:00 +0000
491 Critique original comment
492 --------- Comment ---------
495 Date: Thu, 20 Nov 2008 03:00:00 +0000
498 --------- Comment ---------
501 Date: Thu, 20 Nov 2008 04:00:00 +0000
504 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
505 --------- Comment ---------
508 Date: Thu, 20 Nov 2008 01:00:00 +0000
511 --------- Comment ---------
514 Date: Thu, 20 Nov 2008 02:00:00 +0000
516 Critique original comment
517 --------- Comment ---------
520 Date: Thu, 20 Nov 2008 03:00:00 +0000
523 --------- Comment ---------
526 Date: Thu, 20 Nov 2008 04:00:00 +0000
530 if auto_name_map == True:
532 for shortname,comment in self.comment_shortnames(bug_shortname):
533 name_map[comment.uuid] = shortname
535 for depth,comment in self.thread(flatten=flatten):
537 if comment.uuid in name_map:
538 sname = name_map[comment.uuid]
541 string_fn = getattr(comment, string_method_name)
542 stringlist.append(string_fn(indent=ind, shortname=sname))
543 return '\n'.join(stringlist)
545 def xml_thread(self, name_map={}, indent=0,
546 auto_name_map=False, bug_shortname=None):
547 return self.string_thread(string_method_name="xml", name_map=name_map,
548 indent=indent, auto_name_map=auto_name_map,
549 bug_shortname=bug_shortname)
551 # methods for saving/loading/acessing settings and properties.
553 def get_path(self, *args):
554 dir = os.path.join(self.bug.get_path("comments"), self.uuid)
557 assert args[0] in ["values", "body"], str(args)
558 return os.path.join(dir, *args)
560 def set_sync_with_disk(self, value):
561 self.sync_with_disk = value
563 def load_settings(self):
564 if self.sync_with_disk == False:
565 raise DiskAccessRequired("load settings")
566 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
567 self._setup_saved_settings()
569 def save_settings(self):
570 if self.sync_with_disk == False:
571 raise DiskAccessRequired("save settings")
572 self.vcs.mkdir(self.get_path())
573 path = self.get_path("values")
574 mapfile.map_save(self.vcs, path, self._get_saved_settings())
578 Save any loaded contents to disk.
580 However, if self.sync_with_disk = True, then any changes are
581 automatically written to disk as soon as they happen, so
582 calling this method will just waste time (unless something
583 else has been messing with your on-disk files).
585 sync_with_disk = self.sync_with_disk
586 if sync_with_disk == False:
587 self.set_sync_with_disk(True)
588 assert self.body != None, "Can't save blank comment"
590 self._set_comment_body(new=self.body, force=True)
591 if sync_with_disk == False:
592 self.set_sync_with_disk(False)
595 if self.sync_with_disk == False and self.uuid != INVALID_UUID:
596 raise DiskAccessRequired("remove")
597 for comment in self.traverse():
598 path = comment.get_path()
599 self.vcs.recursive_remove(path)
601 def add_reply(self, reply, allow_time_inversion=False):
602 if self.uuid != INVALID_UUID:
603 reply.in_reply_to = self.uuid
606 def new_reply(self, body=None, content_type=None):
608 >>> comm = Comment(bug=None, body="Some insightful remarks")
609 >>> repA = comm.new_reply("Critique original comment")
610 >>> repB = repA.new_reply("Begin flamewar :p")
611 >>> repB.in_reply_to == repA.uuid
614 reply = Comment(self.bug, body=body)
615 if content_type != None: # set before saving body to decide binary format
616 reply.content_type = content_type
618 reply.set_sync_with_disk(self.bug.sync_with_disk)
619 if reply.sync_with_disk == True:
621 self.add_reply(reply)
624 def comment_shortnames(self, bug_shortname=None):
626 Iterate through (id, comment) pairs, in time order.
627 (This is a user-friendly id, not the comment uuid).
629 SIDE-EFFECT : will sort the comment tree by comment.time
631 >>> a = Comment(bug=None, uuid="a")
632 >>> b = a.new_reply()
634 >>> c = b.new_reply()
636 >>> d = a.new_reply()
638 >>> for id,name in a.comment_shortnames("bug-1"):
639 ... print id, name.uuid
644 >>> for id,name in a.comment_shortnames():
645 ... print id, name.uuid
651 if bug_shortname == None:
653 self.sort(key=lambda comm : comm.time)
654 for num,comment in enumerate(self.traverse()):
655 yield ("%s:%d" % (bug_shortname, num+1), comment)
657 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
659 Use a comment shortname to look up a comment.
660 >>> a = Comment(bug=None, uuid="a")
661 >>> b = a.new_reply()
663 >>> c = b.new_reply()
665 >>> d = a.new_reply()
667 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
668 >>> id(comm) == id(c)
671 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
672 if comment_shortname == cur_name:
674 raise InvalidShortname(comment_shortname,
675 list(self.comment_shortnames(*args, **kwargs)))
677 def comment_from_uuid(self, uuid):
679 Use a comment shortname to look up a comment.
680 >>> a = Comment(bug=None, uuid="a")
681 >>> b = a.new_reply()
683 >>> c = b.new_reply()
685 >>> d = a.new_reply()
687 >>> comm = a.comment_from_uuid("d")
688 >>> id(comm) == id(d)
691 for comment in self.traverse():
692 if comment.uuid == uuid:
696 def cmp_attr(comment_1, comment_2, attr, invert=False):
698 Compare a general attribute between two comments using the conventional
699 comparison rule for that attribute type. If invert == True, sort
700 *against* that convention.
702 >>> commentA = Comment()
703 >>> commentB = Comment()
704 >>> commentA.author = "John Doe"
705 >>> commentB.author = "Jane Doe"
706 >>> cmp_attr(commentA, commentB, attr) > 0
708 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
710 >>> commentB.author = "John Doe"
711 >>> cmp_attr(commentA, commentB, attr) == 0
714 if not hasattr(comment_2, attr) :
716 val_1 = getattr(comment_1, attr)
717 val_2 = getattr(comment_2, attr)
718 if val_1 == None: val_1 = None
719 if val_2 == None: val_2 = None
722 return -cmp(val_1, val_2)
724 return cmp(val_1, val_2)
726 # alphabetical rankings (a < z)
727 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
728 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
729 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
730 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
731 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
732 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
733 # chronological rankings (newer < older)
734 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
737 DEFAULT_CMP_FULL_CMP_LIST = \
738 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
739 cmp_uuid, cmp_extra_strings)
741 class CommentCompoundComparator (object):
742 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
743 self.cmp_list = cmp_list
744 def __call__(self, comment_1, comment_2):
745 for comparison in self.cmp_list :
746 val = comparison(comment_1, comment_2)
751 cmp_full = CommentCompoundComparator()
753 suite = doctest.DocTestSuite()