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 loadComments(bug, load_full=False):
70 Set load_full=True when you want to load the comment completely
71 from disk *now*, rather than waiting and lazy loading as required.
73 if bug.sync_with_disk == False:
74 raise DiskAccessRequired("load comments")
75 path = bug.get_path("comments")
76 if not os.path.exists(path):
77 return Comment(bug, uuid=INVALID_UUID)
79 for uuid in os.listdir(path):
80 if uuid.startswith('.'):
82 comm = Comment(bug, uuid, from_disk=True)
83 comm.set_sync_with_disk(bug.sync_with_disk)
86 dummy = comm.body # force the body to load
88 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
89 bug.add_comments(comments)
90 return bug.comment_root
92 def saveComments(bug):
93 if bug.sync_with_disk == False:
94 raise DiskAccessRequired("save comments")
95 for comment in bug.comment_root.traverse():
99 class Comment(Tree, settings_object.SavedSettingsObject):
104 >>> c.uuid = "some-UUID"
105 >>> print c.content_type
109 settings_properties = []
110 required_saved_properties = []
111 _prop_save_settings = settings_object.prop_save_settings
112 _prop_load_settings = settings_object.prop_load_settings
113 def _versioned_property(settings_properties=settings_properties,
114 required_saved_properties=required_saved_properties,
116 if "settings_properties" not in kwargs:
117 kwargs["settings_properties"] = settings_properties
118 if "required_saved_properties" not in kwargs:
119 kwargs["required_saved_properties"]=required_saved_properties
120 return settings_object.versioned_property(**kwargs)
122 @_versioned_property(name="Alt-id",
123 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.")
124 def alt_id(): return {}
126 @_versioned_property(name="Author",
127 doc="The author of the comment")
128 def author(): return {}
130 @_versioned_property(name="In-reply-to",
131 doc="UUID for parent comment or bug")
132 def in_reply_to(): return {}
134 @_versioned_property(name="Content-type",
135 doc="Mime type for comment body",
136 default="text/plain",
138 def content_type(): return {}
140 @_versioned_property(name="Date",
141 doc="An RFC 2822 timestamp for comment creation")
142 def date(): return {}
145 if self.date == None:
147 return utility.str_to_time(self.date)
148 def _set_time(self, value):
149 self.date = utility.time_to_str(value)
150 time = property(fget=_get_time,
152 doc="An integer version of .date")
154 def _get_comment_body(self):
155 if self.vcs != None and self.sync_with_disk == True:
157 binary = not self.content_type.startswith("text/")
158 return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
159 def _set_comment_body(self, old=None, new=None, force=False):
160 if (self.vcs != None and self.sync_with_disk == True) or force==True:
161 assert new != None, "Can't save empty comment"
162 binary = not self.content_type.startswith("text/")
163 self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
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 {}
173 if hasattr(self.bug, "vcs"):
177 @cached_property(generator=_get_vcs)
178 @local_property("vcs")
179 @doc_property(doc="A revision control system instance.")
182 def _extra_strings_check_fn(value):
183 return utility.iterable_full_of_strings(value, \
184 alternative=settings_object.EMPTY)
185 def _extra_strings_change_hook(self, old, new):
186 self.extra_strings.sort() # to make merging easier
187 self._prop_save_settings(old, new)
188 @_versioned_property(name="extra_strings",
189 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
191 check_fn=_extra_strings_check_fn,
192 change_hook=_extra_strings_change_hook,
194 def extra_strings(): return {}
196 def __init__(self, bug=None, uuid=None, from_disk=False,
197 in_reply_to=None, body=None):
199 Set from_disk=True to load an old comment.
200 Set from_disk=False to create a new comment.
202 The uuid option is required when from_disk==True.
204 The in_reply_to and body options are only used if
205 from_disk==False (the default). When from_disk==True, they are
206 loaded from the bug database.
208 in_reply_to should be the uuid string of the parent comment.
211 settings_object.SavedSettingsObject.__init__(self)
214 if from_disk == True:
215 self.sync_with_disk = True
217 self.sync_with_disk = False
219 self.uuid = uuid_gen()
220 self.time = int(time.time()) # only save to second precision
222 self.author = self.vcs.get_user_id()
223 self.in_reply_to = in_reply_to
226 def __cmp__(self, other):
227 return cmp_full(self, other)
231 >>> comm = Comment(bug=None, body="Some insightful remarks")
232 >>> comm.uuid = "com-1"
233 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
234 >>> comm.author = "Jane Doe <jdoe@example.com>"
236 --------- Comment ---------
238 From: Jane Doe <jdoe@example.com>
239 Date: Thu, 20 Nov 2008 15:55:11 +0000
241 Some insightful remarks
245 def traverse(self, *args, **kwargs):
246 """Avoid working with the possible dummy root comment"""
247 for comment in Tree.traverse(self, *args, **kwargs):
248 if comment.uuid == INVALID_UUID:
252 # serializing methods
254 def _setting_attr_string(self, setting):
255 value = getattr(self, setting)
258 if type(value) not in types.StringTypes:
262 def xml(self, indent=0, shortname=None):
264 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
265 >>> comm.uuid = "0123"
266 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
267 >>> print comm.xml(indent=2, shortname="com-1")
270 <short-name>com-1</short-name>
272 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
273 <content-type>text/plain</content-type>
279 if shortname == None:
280 shortname = self.uuid
281 if self.content_type.startswith('text/'):
282 body = (self.body or '').rstrip('\n')
284 maintype,subtype = self.content_type.split('/',1)
285 msg = email.mime.base.MIMEBase(maintype, subtype)
286 msg.set_payload(self.body or '')
287 email.encoders.encode_base64(msg)
288 body = base64.encodestring(self.body or '')
289 info = [('uuid', self.uuid),
290 ('alt-id', self.alt_id),
291 ('short-name', shortname),
292 ('in-reply-to', self.in_reply_to),
293 ('author', self._setting_attr_string('author')),
295 ('content-type', self.content_type),
297 lines = ['<comment>']
300 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
301 for estr in self.extra_strings:
302 lines.append(' <extra-string>%s</extra-string>' % estr)
303 lines.append('</comment>')
306 return istring + sep.join(lines).rstrip('\n')
308 def from_xml(self, xml_string, verbose=True):
310 Note: If alt-id is not given, translates any <uuid> fields to
312 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
313 >>> commA.uuid = "0123"
314 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
315 >>> commA.author = u'Fran\xe7ois'
316 >>> commA.extra_strings += ['TAG: very helpful']
317 >>> xml = commA.xml(shortname="com-1")
318 >>> commB = Comment()
319 >>> commB.from_xml(xml, verbose=True)
320 >>> commB.explicit_attrs
321 ['author', 'date', 'content_type', 'body', 'alt_id']
322 >>> commB.xml(shortname="com-1") == xml
324 >>> commB.uuid = commB.alt_id
325 >>> commB.alt_id = None
326 >>> commB.xml(shortname="com-1") == xml
329 if type(xml_string) == types.UnicodeType:
330 xml_string = xml_string.strip().encode('unicode_escape')
331 if hasattr(xml_string, 'getchildren'): # already an ElementTree Element
334 comment = ElementTree.XML(xml_string)
335 if comment.tag != 'comment':
336 raise utility.InvalidXML( \
337 'comment', comment, 'root element must be <comment>')
338 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
339 'body','extra-string']
340 self.explicit_attrs = []
344 for child in comment.getchildren():
345 if child.tag == 'short-name':
347 elif child.tag in tags:
348 if child.text == None or len(child.text) == 0:
349 text = settings_object.EMPTY
351 text = xml.sax.saxutils.unescape(child.text)
352 text = text.decode('unicode_escape').strip()
353 if child.tag == 'uuid':
355 continue # don't set the comment's uuid tag.
356 elif child.tag == 'body':
358 self.explicit_attrs.append(child.tag)
359 continue # don't set the comment's body yet.
360 elif child.tag == 'extra-string':
362 continue # don't set the comment's extra_string yet.
363 attr_name = child.tag.replace('-','_')
364 self.explicit_attrs.append(attr_name)
365 setattr(self, attr_name, text)
366 elif verbose == True:
367 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
368 % (child.tag, comment.tag)
369 if self.alt_id == None:
370 self.explicit_attrs.append('alt_id')
373 if self.content_type.startswith('text/'):
374 self.body = body+'\n' # restore trailing newline
376 self.body = base64.decodestring(body)
377 self.extra_strings = estrs
379 def merge(self, other, allow_changes=True):
381 Merge info from other into this comment. Overrides any
382 attributes in self that are listed in other.explicit_attrs.
383 >>> commA = Comment(bug=None, body='Some insightful remarks')
384 >>> commA.uuid = '0123'
385 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
386 >>> commA.author = 'Frank'
387 >>> commA.extra_strings += ['TAG: very helpful']
388 >>> commA.extra_strings += ['TAG: favorite']
389 >>> commB = Comment(bug=None, body='More insightful remarks')
390 >>> commB.uuid = '3210'
391 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
392 >>> commB.author = 'John'
393 >>> commB.explicit_attrs = ['author', 'body']
394 >>> commB.extra_strings += ['TAG: very helpful']
395 >>> commB.extra_strings += ['TAG: useful']
396 >>> commA.merge(commB, allow_changes=False)
397 Traceback (most recent call last):
399 ValueError: Merge would change author "Frank"->"John" for comment 0123
400 >>> commA.merge(commB)
401 >>> print commA.xml()
404 <short-name>0123</short-name>
405 <author>John</author>
406 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
407 <content-type>text/plain</content-type>
408 <body>More insightful remarks</body>
409 <extra-string>TAG: favorite</extra-string>
410 <extra-string>TAG: useful</extra-string>
411 <extra-string>TAG: very helpful</extra-string>
414 for attr in other.explicit_attrs:
415 old = getattr(self, attr)
416 new = getattr(other, attr)
418 if allow_changes == True:
419 setattr(self, attr, new)
422 'Merge would change %s "%s"->"%s" for comment %s' \
423 % (attr, old, new, self.uuid)
424 if allow_changes == False and len(other.extra_strings) > 0:
426 'Merge would change extra_strings for comment %s' % self.uuid
427 for estr in other.extra_strings:
428 if not estr in self.extra_strings:
429 self.extra_strings.append(estr)
431 def string(self, indent=0, shortname=None):
433 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
434 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
435 >>> print comm.string(indent=2, shortname="com-1")
436 --------- Comment ---------
439 Date: Thu, 01 Jan 1970 00:00:00 +0000
445 if shortname == None:
446 shortname = self.uuid
448 lines.append("--------- Comment ---------")
449 lines.append("Name: %s" % shortname)
450 lines.append("From: %s" % (self._setting_attr_string("author")))
451 lines.append("Date: %s" % self.date)
453 if self.content_type.startswith("text/"):
454 lines.extend((self.body or "").splitlines())
456 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
460 return istring + sep.join(lines).rstrip('\n')
462 def string_thread(self, string_method_name="string", name_map={},
463 indent=0, flatten=True,
464 auto_name_map=False, bug_shortname=None):
466 Return a string displaying a thread of comments.
467 bug_shortname is only used if auto_name_map == True.
469 string_method_name (defaults to "string") is the name of the
470 Comment method used to generate the output string for each
471 Comment in the thread. The method must take the arguments
472 indent and shortname.
474 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
475 which will sort the tree by comment.time. Avoid by calling
477 for shortname,comment in comm.comment_shortnames(bug_shortname):
478 name_map[comment.uuid] = shortname
479 comm.sort(key=lambda c : c.author) # your sort
480 comm.string_thread(name_map=name_map)
482 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
483 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
484 >>> b = a.new_reply("Critique original comment")
486 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
487 >>> c = b.new_reply("Begin flamewar :p")
489 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
490 >>> d = a.new_reply("Useful examples")
492 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
493 >>> a.sort(key=lambda comm : comm.time)
494 >>> print a.string_thread(flatten=True)
495 --------- Comment ---------
498 Date: Thu, 20 Nov 2008 01:00:00 +0000
501 --------- Comment ---------
504 Date: Thu, 20 Nov 2008 02:00:00 +0000
506 Critique original comment
507 --------- Comment ---------
510 Date: Thu, 20 Nov 2008 03:00:00 +0000
513 --------- Comment ---------
516 Date: Thu, 20 Nov 2008 04:00:00 +0000
519 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
520 --------- Comment ---------
523 Date: Thu, 20 Nov 2008 01:00:00 +0000
526 --------- Comment ---------
529 Date: Thu, 20 Nov 2008 02:00:00 +0000
531 Critique original comment
532 --------- Comment ---------
535 Date: Thu, 20 Nov 2008 03:00:00 +0000
538 --------- Comment ---------
541 Date: Thu, 20 Nov 2008 04:00:00 +0000
545 if auto_name_map == True:
547 for shortname,comment in self.comment_shortnames(bug_shortname):
548 name_map[comment.uuid] = shortname
550 for depth,comment in self.thread(flatten=flatten):
552 if comment.uuid in name_map:
553 sname = name_map[comment.uuid]
556 string_fn = getattr(comment, string_method_name)
557 stringlist.append(string_fn(indent=ind, shortname=sname))
558 return '\n'.join(stringlist)
560 def xml_thread(self, name_map={}, indent=0,
561 auto_name_map=False, bug_shortname=None):
562 return self.string_thread(string_method_name="xml", name_map=name_map,
563 indent=indent, auto_name_map=auto_name_map,
564 bug_shortname=bug_shortname)
566 # methods for saving/loading/acessing settings and properties.
568 def get_path(self, *args):
569 dir = os.path.join(self.bug.get_path("comments"), self.uuid)
572 assert args[0] in ["values", "body"], str(args)
573 return os.path.join(dir, *args)
575 def set_sync_with_disk(self, value):
576 self.sync_with_disk = value
578 def load_settings(self):
579 if self.sync_with_disk == False:
580 raise DiskAccessRequired("load settings")
581 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
582 self._setup_saved_settings()
584 def save_settings(self):
585 if self.sync_with_disk == False:
586 raise DiskAccessRequired("save settings")
587 self.vcs.mkdir(self.get_path())
588 path = self.get_path("values")
589 mapfile.map_save(self.vcs, path, self._get_saved_settings())
593 Save any loaded contents to disk.
595 However, if self.sync_with_disk = True, then any changes are
596 automatically written to disk as soon as they happen, so
597 calling this method will just waste time (unless something
598 else has been messing with your on-disk files).
600 sync_with_disk = self.sync_with_disk
601 if sync_with_disk == False:
602 self.set_sync_with_disk(True)
603 assert self.body != None, "Can't save blank comment"
605 self._set_comment_body(new=self.body, force=True)
606 if sync_with_disk == False:
607 self.set_sync_with_disk(False)
610 if self.sync_with_disk == False and self.uuid != INVALID_UUID:
611 raise DiskAccessRequired("remove")
612 for comment in self.traverse():
613 path = comment.get_path()
614 self.vcs.recursive_remove(path)
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, content_type=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 if content_type != None: # set before saving body to decide binary format
631 reply.content_type = content_type
633 reply.set_sync_with_disk(self.bug.sync_with_disk)
634 if reply.sync_with_disk == True:
636 self.add_reply(reply)
639 def comment_shortnames(self, bug_shortname=None):
641 Iterate through (id, comment) pairs, in time order.
642 (This is a user-friendly id, not the comment uuid).
644 SIDE-EFFECT : will sort the comment tree by comment.time
646 >>> a = Comment(bug=None, uuid="a")
647 >>> b = a.new_reply()
649 >>> c = b.new_reply()
651 >>> d = a.new_reply()
653 >>> for id,name in a.comment_shortnames("bug-1"):
654 ... print id, name.uuid
659 >>> for id,name in a.comment_shortnames():
660 ... print id, name.uuid
666 if bug_shortname == None:
668 self.sort(key=lambda comm : comm.time)
669 for num,comment in enumerate(self.traverse()):
670 yield ("%s:%d" % (bug_shortname, num+1), comment)
672 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
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_shortname("bug-1:3", bug_shortname="bug-1")
683 >>> id(comm) == id(c)
686 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
687 if comment_shortname == cur_name:
689 raise InvalidShortname(comment_shortname,
690 list(self.comment_shortnames(*args, **kwargs)))
692 def comment_from_uuid(self, uuid, match_alt_id=True):
694 Use a comment shortname to look up a comment.
695 >>> a = Comment(bug=None, uuid="a")
696 >>> b = a.new_reply()
698 >>> c = b.new_reply()
700 >>> d = a.new_reply()
702 >>> d.alt_id = "d-alt"
703 >>> comm = a.comment_from_uuid("d")
704 >>> id(comm) == id(d)
706 >>> comm = a.comment_from_uuid("d-alt")
707 >>> id(comm) == id(d)
709 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
710 Traceback (most recent call last):
714 for comment in self.traverse():
715 if comment.uuid == uuid:
717 if match_alt_id == True and uuid != None \
718 and comment.alt_id == uuid:
722 def cmp_attr(comment_1, comment_2, attr, invert=False):
724 Compare a general attribute between two comments using the conventional
725 comparison rule for that attribute type. If invert == True, sort
726 *against* that convention.
728 >>> commentA = Comment()
729 >>> commentB = Comment()
730 >>> commentA.author = "John Doe"
731 >>> commentB.author = "Jane Doe"
732 >>> cmp_attr(commentA, commentB, attr) > 0
734 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
736 >>> commentB.author = "John Doe"
737 >>> cmp_attr(commentA, commentB, attr) == 0
740 if not hasattr(comment_2, attr) :
742 val_1 = getattr(comment_1, attr)
743 val_2 = getattr(comment_2, attr)
744 if val_1 == None: val_1 = None
745 if val_2 == None: val_2 = None
748 return -cmp(val_1, val_2)
750 return cmp(val_1, val_2)
752 # alphabetical rankings (a < z)
753 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
754 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
755 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
756 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
757 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
758 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
759 # chronological rankings (newer < older)
760 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
763 DEFAULT_CMP_FULL_CMP_LIST = \
764 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
765 cmp_uuid, cmp_extra_strings)
767 class CommentCompoundComparator (object):
768 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
769 self.cmp_list = cmp_list
770 def __call__(self, comment_1, comment_2):
771 for comparison in self.cmp_list :
772 val = comparison(comment_1, comment_2)
777 cmp_full = CommentCompoundComparator()
779 suite = doctest.DocTestSuite()