1 # Bugs Everywhere, a distributed bugtracker
2 # Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
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 InvalidXML(ValueError):
55 def __init__(self, element, comment):
56 msg = "Invalid comment xml: %s\n %s\n" \
57 % (comment, ElementTree.tostring(element))
58 ValueError.__init__(self, msg)
59 self.element = element
60 self.comment = comment
62 class MissingReference(ValueError):
63 def __init__(self, comment):
64 msg = "Missing reference to %s" % (comment.in_reply_to)
65 ValueError.__init__(self, msg)
66 self.reference = comment.in_reply_to
67 self.comment = comment
69 class DiskAccessRequired (Exception):
70 def __init__(self, goal):
71 msg = "Cannot %s without accessing the disk" % goal
72 Exception.__init__(self, msg)
74 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
76 def list_to_root(comments, bug, root=None,
77 ignore_missing_references=False):
79 Convert a raw list of comments to single root comment. We use a
80 dummy root comment by default, because there can be several
81 comment threads rooted on the same parent bug. To simplify
82 comment interaction, we condense these threads into a single
83 thread with a Comment dummy root. Can also be used to append
84 a list of subcomments to a non-dummy root comment, so long as
85 all the new comments are descendants of the root comment.
87 No Comment method should use the dummy comment.
91 for comment in comments:
92 assert comment.uuid != None
93 uuid_map[comment.uuid] = comment
94 for comment in comments:
95 if comment.alt_id != None and comment.alt_id not in uuid_map:
96 uuid_map[comment.alt_id] = comment
98 root = Comment(bug, uuid=INVALID_UUID)
100 uuid_map[root.uuid] = root
101 for comm in comments:
102 if comm.in_reply_to == INVALID_UUID:
103 comm.in_reply_to = None
104 rep = comm.in_reply_to
105 if rep == None or rep == bug.uuid:
106 root_comments.append(comm)
108 parentUUID = comm.in_reply_to
110 parent = uuid_map[parentUUID]
111 parent.add_reply(comm)
113 if ignore_missing_references == True:
114 print >> sys.stderr, \
115 "Ignoring missing reference to %s" % parentUUID
116 comm.in_reply_to = None
117 root_comments.append(comm)
119 raise MissingReference(comm)
120 root.extend(root_comments)
123 def loadComments(bug, load_full=False):
125 Set load_full=True when you want to load the comment completely
126 from disk *now*, rather than waiting and lazy loading as required.
128 if bug.sync_with_disk == False:
129 raise DiskAccessRequired("load comments")
130 path = bug.get_path("comments")
131 if not os.path.exists(path):
132 return Comment(bug, uuid=INVALID_UUID)
134 for uuid in os.listdir(path):
135 if uuid.startswith('.'):
137 comm = Comment(bug, uuid, from_disk=True)
138 comm.set_sync_with_disk(bug.sync_with_disk)
139 if load_full == True:
141 dummy = comm.body # force the body to load
142 comments.append(comm)
143 return list_to_root(comments, bug)
145 def saveComments(bug):
146 if bug.sync_with_disk == False:
147 raise DiskAccessRequired("save comments")
148 for comment in bug.comment_root.traverse():
152 class Comment(Tree, settings_object.SavedSettingsObject):
157 >>> c.uuid = "some-UUID"
158 >>> print c.content_type
162 settings_properties = []
163 required_saved_properties = []
164 _prop_save_settings = settings_object.prop_save_settings
165 _prop_load_settings = settings_object.prop_load_settings
166 def _versioned_property(settings_properties=settings_properties,
167 required_saved_properties=required_saved_properties,
169 if "settings_properties" not in kwargs:
170 kwargs["settings_properties"] = settings_properties
171 if "required_saved_properties" not in kwargs:
172 kwargs["required_saved_properties"]=required_saved_properties
173 return settings_object.versioned_property(**kwargs)
175 @_versioned_property(name="Alt-id",
176 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.")
177 def alt_id(): return {}
179 @_versioned_property(name="Author",
180 doc="The author of the comment")
181 def author(): return {}
183 @_versioned_property(name="In-reply-to",
184 doc="UUID for parent comment or bug")
185 def in_reply_to(): return {}
187 @_versioned_property(name="Content-type",
188 doc="Mime type for comment body",
189 default="text/plain",
191 def content_type(): return {}
193 @_versioned_property(name="Date",
194 doc="An RFC 2822 timestamp for comment creation")
195 def date(): return {}
198 if self.date == None:
200 return utility.str_to_time(self.date)
201 def _set_time(self, value):
202 self.date = utility.time_to_str(value)
203 time = property(fget=_get_time,
205 doc="An integer version of .date")
207 def _get_comment_body(self):
208 if self.vcs != None and self.sync_with_disk == True:
210 binary = not self.content_type.startswith("text/")
211 return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
212 def _set_comment_body(self, old=None, new=None, force=False):
213 if (self.vcs != None and self.sync_with_disk == True) or force==True:
214 assert new != None, "Can't save empty comment"
215 binary = not self.content_type.startswith("text/")
216 self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
219 @change_hook_property(hook=_set_comment_body)
220 @cached_property(generator=_get_comment_body)
221 @local_property("body")
222 @doc_property(doc="The meat of the comment")
223 def body(): return {}
226 if hasattr(self.bug, "vcs"):
230 @cached_property(generator=_get_vcs)
231 @local_property("vcs")
232 @doc_property(doc="A revision control system instance.")
235 def _extra_strings_check_fn(value):
236 return utility.iterable_full_of_strings(value, \
237 alternative=settings_object.EMPTY)
238 def _extra_strings_change_hook(self, old, new):
239 self.extra_strings.sort() # to make merging easier
240 self._prop_save_settings(old, new)
241 @_versioned_property(name="extra_strings",
242 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
244 check_fn=_extra_strings_check_fn,
245 change_hook=_extra_strings_change_hook,
247 def extra_strings(): return {}
249 def __init__(self, bug=None, uuid=None, from_disk=False,
250 in_reply_to=None, body=None):
252 Set from_disk=True to load an old comment.
253 Set from_disk=False to create a new comment.
255 The uuid option is required when from_disk==True.
257 The in_reply_to and body options are only used if
258 from_disk==False (the default). When from_disk==True, they are
259 loaded from the bug database.
261 in_reply_to should be the uuid string of the parent comment.
264 settings_object.SavedSettingsObject.__init__(self)
267 if from_disk == True:
268 self.sync_with_disk = True
270 self.sync_with_disk = False
272 self.uuid = uuid_gen()
273 self.time = int(time.time()) # only save to second precision
275 self.author = self.vcs.get_user_id()
276 self.in_reply_to = in_reply_to
279 def __cmp__(self, other):
280 return cmp_full(self, other)
284 >>> comm = Comment(bug=None, body="Some insightful remarks")
285 >>> comm.uuid = "com-1"
286 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
287 >>> comm.author = "Jane Doe <jdoe@example.com>"
289 --------- Comment ---------
291 From: Jane Doe <jdoe@example.com>
292 Date: Thu, 20 Nov 2008 15:55:11 +0000
294 Some insightful remarks
298 def traverse(self, *args, **kwargs):
299 """Avoid working with the possible dummy root comment"""
300 for comment in Tree.traverse(self, *args, **kwargs):
301 if comment.uuid == INVALID_UUID:
305 # serializing methods
307 def _setting_attr_string(self, setting):
308 value = getattr(self, setting)
313 def xml(self, indent=0, shortname=None):
315 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
316 >>> comm.uuid = "0123"
317 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
318 >>> print comm.xml(indent=2, shortname="com-1")
321 <short-name>com-1</short-name>
323 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
324 <content-type>text/plain</content-type>
330 if shortname == None:
331 shortname = self.uuid
332 if self.content_type.startswith("text/"):
333 body = (self.body or "").rstrip('\n')
335 maintype,subtype = self.content_type.split('/',1)
336 msg = email.mime.base.MIMEBase(maintype, subtype)
337 msg.set_payload(self.body or "")
338 email.encoders.encode_base64(msg)
339 body = base64.encodestring(self.body or "")
340 info = [("uuid", self.uuid),
341 ("alt-id", self.alt_id),
342 ("short-name", shortname),
343 ("in-reply-to", self.in_reply_to),
344 ("author", self._setting_attr_string("author")),
346 ("content-type", self.content_type),
348 lines = ["<comment>"]
351 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
352 lines.append("</comment>")
355 return istring + sep.join(lines).rstrip('\n')
357 def from_xml(self, xml_string, verbose=True):
359 Note: If alt-id is not given, translates any <uuid> fields to
361 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
362 >>> commA.uuid = "0123"
363 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
364 >>> xml = commA.xml(shortname="com-1")
365 >>> commB = Comment()
366 >>> commB.from_xml(xml)
367 >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
368 >>> for attr in attrs: # doctest: +ELLIPSIS
369 ... if getattr(commB, attr) != getattr(commA, attr):
370 ... estr = "Mismatch on %s: '%s' should be '%s'"
371 ... args = (attr, getattr(commB, attr), getattr(commA, attr))
372 ... print estr % args
373 Mismatch on uuid: '...' should be '0123'
374 Mismatch on alt_id: '0123' should be 'None'
375 >>> print commB.alt_id
380 if type(xml_string) == types.UnicodeType:
381 xml_string = xml_string.strip().encode("unicode_escape")
382 comment = ElementTree.XML(xml_string)
383 if comment.tag != "comment":
384 raise InvalidXML(comment, "root element must be <comment>")
385 tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
388 for child in comment.getchildren():
389 if child.tag == "short-name":
391 elif child.tag in tags:
392 if child.text == None or len(child.text) == 0:
393 text = settings_object.EMPTY
395 text = xml.sax.saxutils.unescape(child.text)
396 text = unicode(text).decode("unicode_escape").strip()
397 if child.tag == "uuid":
399 continue # don't set the bug's uuid tag.
400 if child.tag == "body":
402 continue # don't set the bug's body yet.
404 attr_name = child.tag.replace('-','_')
405 setattr(self, attr_name, text)
406 elif verbose == True:
407 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
408 % (child.tag, comment.tag)
409 if self.alt_id == None and uuid not in [None, self.uuid]:
412 if self.content_type.startswith("text/"):
413 self.body = body+"\n" # restore trailing newline
415 self.body = base64.decodestring(body)
417 def string(self, indent=0, shortname=None):
419 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
420 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
421 >>> print comm.string(indent=2, shortname="com-1")
422 --------- Comment ---------
425 Date: Thu, 01 Jan 1970 00:00:00 +0000
431 if shortname == None:
432 shortname = self.uuid
434 lines.append("--------- Comment ---------")
435 lines.append("Name: %s" % shortname)
436 lines.append("From: %s" % (self._setting_attr_string("author")))
437 lines.append("Date: %s" % self.date)
439 if self.content_type.startswith("text/"):
440 lines.extend((self.body or "").splitlines())
442 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
446 return istring + sep.join(lines).rstrip('\n')
448 def string_thread(self, string_method_name="string", name_map={},
449 indent=0, flatten=True,
450 auto_name_map=False, bug_shortname=None):
452 Return a string displaying a thread of comments.
453 bug_shortname is only used if auto_name_map == True.
455 string_method_name (defaults to "string") is the name of the
456 Comment method used to generate the output string for each
457 Comment in the thread. The method must take the arguments
458 indent and shortname.
460 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
461 which will sort the tree by comment.time. Avoid by calling
463 for shortname,comment in comm.comment_shortnames(bug_shortname):
464 name_map[comment.uuid] = shortname
465 comm.sort(key=lambda c : c.author) # your sort
466 comm.string_thread(name_map=name_map)
468 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
469 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
470 >>> b = a.new_reply("Critique original comment")
472 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
473 >>> c = b.new_reply("Begin flamewar :p")
475 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
476 >>> d = a.new_reply("Useful examples")
478 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
479 >>> a.sort(key=lambda comm : comm.time)
480 >>> print a.string_thread(flatten=True)
481 --------- Comment ---------
484 Date: Thu, 20 Nov 2008 01:00:00 +0000
487 --------- Comment ---------
490 Date: Thu, 20 Nov 2008 02:00:00 +0000
492 Critique original comment
493 --------- Comment ---------
496 Date: Thu, 20 Nov 2008 03:00:00 +0000
499 --------- Comment ---------
502 Date: Thu, 20 Nov 2008 04:00:00 +0000
505 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
506 --------- Comment ---------
509 Date: Thu, 20 Nov 2008 01:00:00 +0000
512 --------- Comment ---------
515 Date: Thu, 20 Nov 2008 02:00:00 +0000
517 Critique original comment
518 --------- Comment ---------
521 Date: Thu, 20 Nov 2008 03:00:00 +0000
524 --------- Comment ---------
527 Date: Thu, 20 Nov 2008 04:00:00 +0000
531 if auto_name_map == True:
533 for shortname,comment in self.comment_shortnames(bug_shortname):
534 name_map[comment.uuid] = shortname
536 for depth,comment in self.thread(flatten=flatten):
538 if comment.uuid in name_map:
539 sname = name_map[comment.uuid]
542 string_fn = getattr(comment, string_method_name)
543 stringlist.append(string_fn(indent=ind, shortname=sname))
544 return '\n'.join(stringlist)
546 def xml_thread(self, name_map={}, indent=0,
547 auto_name_map=False, bug_shortname=None):
548 return self.string_thread(string_method_name="xml", name_map=name_map,
549 indent=indent, auto_name_map=auto_name_map,
550 bug_shortname=bug_shortname)
552 # methods for saving/loading/acessing settings and properties.
554 def get_path(self, *args):
555 dir = os.path.join(self.bug.get_path("comments"), self.uuid)
558 assert args[0] in ["values", "body"], str(args)
559 return os.path.join(dir, *args)
561 def set_sync_with_disk(self, value):
562 self.sync_with_disk = value
564 def load_settings(self):
565 if self.sync_with_disk == False:
566 raise DiskAccessRequired("load settings")
567 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
568 self._setup_saved_settings()
570 def save_settings(self):
571 if self.sync_with_disk == False:
572 raise DiskAccessRequired("save settings")
573 self.vcs.mkdir(self.get_path())
574 path = self.get_path("values")
575 mapfile.map_save(self.vcs, path, self._get_saved_settings())
579 Save any loaded contents to disk.
581 However, if self.sync_with_disk = True, then any changes are
582 automatically written to disk as soon as they happen, so
583 calling this method will just waste time (unless something
584 else has been messing with your on-disk files).
586 sync_with_disk = self.sync_with_disk
587 if sync_with_disk == False:
588 self.set_sync_with_disk(True)
589 assert self.body != None, "Can't save blank comment"
591 self._set_comment_body(new=self.body, force=True)
592 if sync_with_disk == False:
593 self.set_sync_with_disk(False)
596 if self.sync_with_disk == False and self.uuid != INVALID_UUID:
597 raise DiskAccessRequired("remove")
598 for comment in self.traverse():
599 path = comment.get_path()
600 self.vcs.recursive_remove(path)
602 def add_reply(self, reply, allow_time_inversion=False):
603 if self.uuid != INVALID_UUID:
604 reply.in_reply_to = self.uuid
607 def new_reply(self, body=None, content_type=None):
609 >>> comm = Comment(bug=None, body="Some insightful remarks")
610 >>> repA = comm.new_reply("Critique original comment")
611 >>> repB = repA.new_reply("Begin flamewar :p")
612 >>> repB.in_reply_to == repA.uuid
615 reply = Comment(self.bug, body=body)
616 if content_type != None: # set before saving body to decide binary format
617 reply.content_type = content_type
619 reply.set_sync_with_disk(self.bug.sync_with_disk)
620 if reply.sync_with_disk == True:
622 self.add_reply(reply)
625 def comment_shortnames(self, bug_shortname=None):
627 Iterate through (id, comment) pairs, in time order.
628 (This is a user-friendly id, not the comment uuid).
630 SIDE-EFFECT : will sort the comment tree by comment.time
632 >>> a = Comment(bug=None, uuid="a")
633 >>> b = a.new_reply()
635 >>> c = b.new_reply()
637 >>> d = a.new_reply()
639 >>> for id,name in a.comment_shortnames("bug-1"):
640 ... print id, name.uuid
646 if bug_shortname == None:
648 self.sort(key=lambda comm : comm.time)
649 for num,comment in enumerate(self.traverse()):
650 yield ("%s:%d" % (bug_shortname, num+1), comment)
652 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
654 Use a comment shortname to look up a comment.
655 >>> a = Comment(bug=None, uuid="a")
656 >>> b = a.new_reply()
658 >>> c = b.new_reply()
660 >>> d = a.new_reply()
662 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
663 >>> id(comm) == id(c)
666 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
667 if comment_shortname == cur_name:
669 raise InvalidShortname(comment_shortname,
670 list(self.comment_shortnames(*args, **kwargs)))
672 def comment_from_uuid(self, uuid):
674 Use a comment shortname to look up a comment.
675 >>> a = Comment(bug=None, uuid="a")
676 >>> b = a.new_reply()
678 >>> c = b.new_reply()
680 >>> d = a.new_reply()
682 >>> comm = a.comment_from_uuid("d")
683 >>> id(comm) == id(d)
686 for comment in self.traverse():
687 if comment.uuid == uuid:
691 def cmp_attr(comment_1, comment_2, attr, invert=False):
693 Compare a general attribute between two comments using the conventional
694 comparison rule for that attribute type. If invert == True, sort
695 *against* that convention.
697 >>> commentA = Comment()
698 >>> commentB = Comment()
699 >>> commentA.author = "John Doe"
700 >>> commentB.author = "Jane Doe"
701 >>> cmp_attr(commentA, commentB, attr) > 0
703 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
705 >>> commentB.author = "John Doe"
706 >>> cmp_attr(commentA, commentB, attr) == 0
709 if not hasattr(comment_2, attr) :
711 val_1 = getattr(comment_1, attr)
712 val_2 = getattr(comment_2, attr)
713 if val_1 == None: val_1 = None
714 if val_2 == None: val_2 = None
717 return -cmp(val_1, val_2)
719 return cmp(val_1, val_2)
721 # alphabetical rankings (a < z)
722 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
723 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
724 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
725 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
726 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
727 # chronological rankings (newer < older)
728 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
730 DEFAULT_CMP_FULL_CMP_LIST = \
731 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
734 class CommentCompoundComparator (object):
735 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
736 self.cmp_list = cmp_list
737 def __call__(self, comment_1, comment_2):
738 for comparison in self.cmp_list :
739 val = comparison(comment_1, comment_2)
744 cmp_full = CommentCompoundComparator()
746 suite = doctest.DocTestSuite()