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.
25 try: # import core module, Python >= 2.5
26 from xml.etree import ElementTree
27 except ImportError: # look for non-core module
28 from elementtree import ElementTree
29 import xml.sax.saxutils
32 from beuuid import uuid_gen
33 from properties import Property, doc_property, local_property, \
34 defaulting_property, checked_property, cached_property, \
35 primed_property, change_hook_property, settings_property
36 import settings_object
42 class InvalidShortname(KeyError):
43 def __init__(self, shortname, shortnames):
44 msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
45 KeyError.__init__(self, msg)
46 self.shortname = shortname
47 self.shortnames = shortnames
49 class InvalidXML(ValueError):
50 def __init__(self, element, comment):
51 msg = "Invalid comment xml: %s\n %s\n" \
52 % (comment, ElementTree.tostring(element))
53 ValueError.__init__(self, msg)
54 self.element = element
55 self.comment = comment
57 class MissingReference(ValueError):
58 def __init__(self, comment):
59 msg = "Missing reference to %s" % (comment.in_reply_to)
60 ValueError.__init__(self, msg)
61 self.reference = comment.in_reply_to
62 self.comment = comment
64 class DiskAccessRequired (Exception):
65 def __init__(self, goal):
66 msg = "Cannot %s without accessing the disk" % goal
67 Exception.__init__(self, msg)
69 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
71 def list_to_root(comments, bug, root=None,
72 ignore_missing_references=False):
74 Convert a raw list of comments to single root comment. We use a
75 dummy root comment by default, because there can be several
76 comment threads rooted on the same parent bug. To simplify
77 comment interaction, we condense these threads into a single
78 thread with a Comment dummy root. Can also be used to append
79 a list of subcomments to a non-dummy root comment, so long as
80 all the new comments are descendants of the root comment.
82 No Comment method should use the dummy comment.
86 for comment in comments:
87 assert comment.uuid != None
88 uuid_map[comment.uuid] = comment
89 for comment in comments:
90 if comment.alt_id != None and comment.alt_id not in uuid_map:
91 uuid_map[comment.alt_id] = comment
93 root = Comment(bug, uuid=INVALID_UUID)
95 uuid_map[root.uuid] = root
97 if comm.in_reply_to == INVALID_UUID:
98 comm.in_reply_to = None
99 rep = comm.in_reply_to
100 if rep == None or rep == bug.uuid:
101 root_comments.append(comm)
103 parentUUID = comm.in_reply_to
105 parent = uuid_map[parentUUID]
106 parent.add_reply(comm)
108 if ignore_missing_references == True:
109 print >> sys.stderr, \
110 "Ignoring missing reference to %s" % parentUUID
111 comm.in_reply_to = None
112 root_comments.append(comm)
114 raise MissingReference(comm)
115 root.extend(root_comments)
118 def loadComments(bug, load_full=False):
120 Set load_full=True when you want to load the comment completely
121 from disk *now*, rather than waiting and lazy loading as required.
123 if bug.sync_with_disk == False:
124 raise DiskAccessRequired("load comments")
125 path = bug.get_path("comments")
126 if not os.path.isdir(path):
127 return Comment(bug, uuid=INVALID_UUID)
129 for uuid in os.listdir(path):
130 if uuid.startswith('.'):
132 comm = Comment(bug, uuid, from_disk=True)
133 comm.set_sync_with_disk(bug.sync_with_disk)
134 if load_full == True:
136 dummy = comm.body # force the body to load
137 comments.append(comm)
138 return list_to_root(comments, bug)
140 def saveComments(bug):
141 if bug.sync_with_disk == False:
142 raise DiskAccessRequired("save comments")
143 for comment in bug.comment_root.traverse():
147 class Comment(Tree, settings_object.SavedSettingsObject):
152 >>> c.uuid = "some-UUID"
153 >>> print c.content_type
157 settings_properties = []
158 required_saved_properties = []
159 _prop_save_settings = settings_object.prop_save_settings
160 _prop_load_settings = settings_object.prop_load_settings
161 def _versioned_property(settings_properties=settings_properties,
162 required_saved_properties=required_saved_properties,
164 if "settings_properties" not in kwargs:
165 kwargs["settings_properties"] = settings_properties
166 if "required_saved_properties" not in kwargs:
167 kwargs["required_saved_properties"]=required_saved_properties
168 return settings_object.versioned_property(**kwargs)
170 @_versioned_property(name="Alt-id",
171 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.")
172 def alt_id(): return {}
174 @_versioned_property(name="Author",
175 doc="The author of the comment")
176 def author(): return {}
178 @_versioned_property(name="In-reply-to",
179 doc="UUID for parent comment or bug")
180 def in_reply_to(): return {}
182 @_versioned_property(name="Content-type",
183 doc="Mime type for comment body",
184 default="text/plain",
186 def content_type(): return {}
188 @_versioned_property(name="Date",
189 doc="An RFC 2822 timestamp for comment creation")
190 def date(): return {}
193 if self.date == None:
195 return utility.str_to_time(self.date)
196 def _set_time(self, value):
197 self.date = utility.time_to_str(value)
198 time = property(fget=_get_time,
200 doc="An integer version of .date")
202 def _get_comment_body(self):
203 if self.rcs != None and self.sync_with_disk == True:
205 binary = not self.content_type.startswith("text/")
206 return self.rcs.get_file_contents(self.get_path("body"), binary=binary)
207 def _set_comment_body(self, old=None, new=None, force=False):
208 if (self.rcs != None and self.sync_with_disk == True) or force==True:
209 assert new != None, "Can't save empty comment"
210 binary = not self.content_type.startswith("text/")
211 self.rcs.set_file_contents(self.get_path("body"), new, binary=binary)
214 @change_hook_property(hook=_set_comment_body)
215 @cached_property(generator=_get_comment_body)
216 @local_property("body")
217 @doc_property(doc="The meat of the comment")
218 def body(): return {}
221 if hasattr(self.bug, "rcs"):
225 @cached_property(generator=_get_rcs)
226 @local_property("rcs")
227 @doc_property(doc="A revision control system instance.")
230 def _extra_strings_check_fn(value):
231 return utility.iterable_full_of_strings(value, \
232 alternative=settings_object.EMPTY)
233 def _extra_strings_change_hook(self, old, new):
234 self.extra_strings.sort() # to make merging easier
235 self._prop_save_settings(old, new)
236 @_versioned_property(name="extra_strings",
237 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
239 check_fn=_extra_strings_check_fn,
240 change_hook=_extra_strings_change_hook,
242 def extra_strings(): return {}
244 def __init__(self, bug=None, uuid=None, from_disk=False,
245 in_reply_to=None, body=None):
247 Set from_disk=True to load an old comment.
248 Set from_disk=False to create a new comment.
250 The uuid option is required when from_disk==True.
252 The in_reply_to and body options are only used if
253 from_disk==False (the default). When from_disk==True, they are
254 loaded from the bug database.
256 in_reply_to should be the uuid string of the parent comment.
259 settings_object.SavedSettingsObject.__init__(self)
262 if from_disk == True:
263 self.sync_with_disk = True
265 self.sync_with_disk = False
267 self.uuid = uuid_gen()
268 self.time = int(time.time()) # only save to second precision
270 self.author = self.rcs.get_user_id()
271 self.in_reply_to = in_reply_to
274 def __cmp__(self, other):
275 return cmp_full(self, other)
279 >>> comm = Comment(bug=None, body="Some insightful remarks")
280 >>> comm.uuid = "com-1"
281 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
282 >>> comm.author = "Jane Doe <jdoe@example.com>"
284 --------- Comment ---------
286 From: Jane Doe <jdoe@example.com>
287 Date: Thu, 20 Nov 2008 15:55:11 +0000
289 Some insightful remarks
293 def traverse(self, *args, **kwargs):
294 """Avoid working with the possible dummy root comment"""
295 for comment in Tree.traverse(self, *args, **kwargs):
296 if comment.uuid == INVALID_UUID:
300 # serializing methods
302 def _setting_attr_string(self, setting):
303 value = getattr(self, setting)
308 def xml(self, indent=0, shortname=None):
310 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
311 >>> comm.uuid = "0123"
312 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
313 >>> print comm.xml(indent=2, shortname="com-1")
316 <short-name>com-1</short-name>
318 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
319 <content-type>text/plain</content-type>
325 if shortname == None:
326 shortname = self.uuid
327 if self.content_type.startswith("text/"):
328 body = (self.body or "").rstrip('\n')
330 maintype,subtype = self.content_type.split('/',1)
331 msg = email.mime.base.MIMEBase(maintype, subtype)
332 msg.set_payload(self.body or "")
333 email.encoders.encode_base64(msg)
334 body = base64.encodestring(self.body or "")
335 info = [("uuid", self.uuid),
336 ("alt-id", self.alt_id),
337 ("short-name", shortname),
338 ("in-reply-to", self.in_reply_to),
339 ("author", self._setting_attr_string("author")),
341 ("content-type", self.content_type),
343 lines = ["<comment>"]
346 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
347 lines.append("</comment>")
350 return istring + sep.join(lines).rstrip('\n')
352 def from_xml(self, xml_string, verbose=True):
354 Note: If alt-id is not given, translates any <uuid> fields to
356 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
357 >>> commA.uuid = "0123"
358 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
359 >>> xml = commA.xml(shortname="com-1")
360 >>> commB = Comment()
361 >>> commB.from_xml(xml)
362 >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
363 >>> for attr in attrs: # doctest: +ELLIPSIS
364 ... if getattr(commB, attr) != getattr(commA, attr):
365 ... estr = "Mismatch on %s: '%s' should be '%s'"
366 ... args = (attr, getattr(commB, attr), getattr(commA, attr))
367 ... print estr % args
368 Mismatch on uuid: '...' should be '0123'
369 Mismatch on alt_id: '0123' should be 'None'
370 >>> print commB.alt_id
375 if type(xml_string) == types.UnicodeType:
376 xml_string = xml_string.strip().encode("unicode_escape")
377 comment = ElementTree.XML(xml_string)
378 if comment.tag != "comment":
379 raise InvalidXML(comment, "root element must be <comment>")
380 tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
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 = unicode(text).decode("unicode_escape").strip()
392 if child.tag == "uuid":
394 continue # don't set the bug's uuid tag.
395 if child.tag == "body":
397 continue # don't set the bug's body yet.
399 attr_name = child.tag.replace('-','_')
400 setattr(self, attr_name, text)
401 elif verbose == True:
402 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
403 % (child.tag, comment.tag)
404 if self.alt_id == None and uuid not in [None, self.uuid]:
407 if self.content_type.startswith("text/"):
408 self.body = body+"\n" # restore trailing newline
410 self.body = base64.decodestring(body)
412 def string(self, indent=0, shortname=None):
414 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
415 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
416 >>> print comm.string(indent=2, shortname="com-1")
417 --------- Comment ---------
420 Date: Thu, 01 Jan 1970 00:00:00 +0000
426 if shortname == None:
427 shortname = self.uuid
429 lines.append("--------- Comment ---------")
430 lines.append("Name: %s" % shortname)
431 lines.append("From: %s" % (self._setting_attr_string("author")))
432 lines.append("Date: %s" % self.date)
434 if self.content_type.startswith("text/"):
435 lines.extend((self.body or "").splitlines())
437 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
441 return istring + sep.join(lines).rstrip('\n')
443 def string_thread(self, string_method_name="string", name_map={},
444 indent=0, flatten=True,
445 auto_name_map=False, bug_shortname=None):
447 Return a string displaying a thread of comments.
448 bug_shortname is only used if auto_name_map == True.
450 string_method_name (defaults to "string") is the name of the
451 Comment method used to generate the output string for each
452 Comment in the thread. The method must take the arguments
453 indent and shortname.
455 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
456 which will sort the tree by comment.time. Avoid by calling
458 for shortname,comment in comm.comment_shortnames(bug_shortname):
459 name_map[comment.uuid] = shortname
460 comm.sort(key=lambda c : c.author) # your sort
461 comm.string_thread(name_map=name_map)
463 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
464 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
465 >>> b = a.new_reply("Critique original comment")
467 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
468 >>> c = b.new_reply("Begin flamewar :p")
470 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
471 >>> d = a.new_reply("Useful examples")
473 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
474 >>> a.sort(key=lambda comm : comm.time)
475 >>> print a.string_thread(flatten=True)
476 --------- Comment ---------
479 Date: Thu, 20 Nov 2008 01:00:00 +0000
482 --------- Comment ---------
485 Date: Thu, 20 Nov 2008 02:00:00 +0000
487 Critique original comment
488 --------- Comment ---------
491 Date: Thu, 20 Nov 2008 03:00:00 +0000
494 --------- Comment ---------
497 Date: Thu, 20 Nov 2008 04:00:00 +0000
500 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
501 --------- Comment ---------
504 Date: Thu, 20 Nov 2008 01:00:00 +0000
507 --------- Comment ---------
510 Date: Thu, 20 Nov 2008 02:00:00 +0000
512 Critique original comment
513 --------- Comment ---------
516 Date: Thu, 20 Nov 2008 03:00:00 +0000
519 --------- Comment ---------
522 Date: Thu, 20 Nov 2008 04:00:00 +0000
526 if auto_name_map == True:
528 for shortname,comment in self.comment_shortnames(bug_shortname):
529 name_map[comment.uuid] = shortname
531 for depth,comment in self.thread(flatten=flatten):
533 if comment.uuid in name_map:
534 sname = name_map[comment.uuid]
537 string_fn = getattr(comment, string_method_name)
538 stringlist.append(string_fn(indent=ind, shortname=sname))
539 return '\n'.join(stringlist)
541 def xml_thread(self, name_map={}, indent=0,
542 auto_name_map=False, bug_shortname=None):
543 return self.string_thread(string_method_name="xml", name_map=name_map,
544 indent=indent, auto_name_map=auto_name_map,
545 bug_shortname=bug_shortname)
547 # methods for saving/loading/acessing settings and properties.
549 def get_path(self, name=None):
550 my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
553 assert name in ["values", "body"]
554 return os.path.join(my_dir, name)
556 def set_sync_with_disk(self, value):
557 self.sync_with_disk = value
559 def load_settings(self):
560 if self.sync_with_disk == False:
561 raise DiskAccessRequired("load settings")
562 self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
563 # hack to deal with old BE comments:
564 if "From" in self.settings:
565 self.settings["Author"] = self.settings.pop("From")
566 self._setup_saved_settings()
568 def save_settings(self):
569 if self.sync_with_disk == False:
570 raise DiskAccessRequired("save settings")
571 self.rcs.mkdir(self.get_path())
572 path = self.get_path("values")
573 mapfile.map_save(self.rcs, path, self._get_saved_settings())
577 Save any loaded contents to disk.
579 However, if self.sync_with_disk = True, then any changes are
580 automatically written to disk as soon as they happen, so
581 calling this method will just waste time (unless something
582 else has been messing with your on-disk files).
584 sync_with_disk = self.sync_with_disk
585 if sync_with_disk == False:
586 self.set_sync_with_disk(True)
587 assert self.body != None, "Can't save blank comment"
589 self._set_comment_body(new=self.body, force=True)
590 if sync_with_disk == False:
591 self.set_sync_with_disk(False)
594 if self.sync_with_disk == False and self.uuid != INVALID_UUID:
595 raise DiskAccessRequired("remove")
596 for comment in self.traverse():
597 path = comment.get_path()
598 self.rcs.recursive_remove(path)
600 def add_reply(self, reply, allow_time_inversion=False):
601 if self.uuid != INVALID_UUID:
602 reply.in_reply_to = self.uuid
605 def new_reply(self, body=None):
607 >>> comm = Comment(bug=None, body="Some insightful remarks")
608 >>> repA = comm.new_reply("Critique original comment")
609 >>> repB = repA.new_reply("Begin flamewar :p")
610 >>> repB.in_reply_to == repA.uuid
613 reply = Comment(self.bug, body=body)
615 reply.set_sync_with_disk(self.bug.sync_with_disk)
616 if reply.sync_with_disk == True:
618 self.add_reply(reply)
621 def comment_shortnames(self, bug_shortname=None):
623 Iterate through (id, comment) pairs, in time order.
624 (This is a user-friendly id, not the comment uuid).
626 SIDE-EFFECT : will sort the comment tree by comment.time
628 >>> a = Comment(bug=None, uuid="a")
629 >>> b = a.new_reply()
631 >>> c = b.new_reply()
633 >>> d = a.new_reply()
635 >>> for id,name in a.comment_shortnames("bug-1"):
636 ... print id, name.uuid
642 if bug_shortname == None:
644 self.sort(key=lambda comm : comm.time)
645 for num,comment in enumerate(self.traverse()):
646 yield ("%s:%d" % (bug_shortname, num+1), comment)
648 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
650 Use a comment shortname to look up a comment.
651 >>> a = Comment(bug=None, uuid="a")
652 >>> b = a.new_reply()
654 >>> c = b.new_reply()
656 >>> d = a.new_reply()
658 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
659 >>> id(comm) == id(c)
662 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
663 if comment_shortname == cur_name:
665 raise InvalidShortname(comment_shortname,
666 list(self.comment_shortnames(*args, **kwargs)))
668 def comment_from_uuid(self, uuid):
670 Use a comment shortname to look up a comment.
671 >>> a = Comment(bug=None, uuid="a")
672 >>> b = a.new_reply()
674 >>> c = b.new_reply()
676 >>> d = a.new_reply()
678 >>> comm = a.comment_from_uuid("d")
679 >>> id(comm) == id(d)
682 for comment in self.traverse():
683 if comment.uuid == uuid:
687 def cmp_attr(comment_1, comment_2, attr, invert=False):
689 Compare a general attribute between two comments using the conventional
690 comparison rule for that attribute type. If invert == True, sort
691 *against* that convention.
693 >>> commentA = Comment()
694 >>> commentB = Comment()
695 >>> commentA.author = "John Doe"
696 >>> commentB.author = "Jane Doe"
697 >>> cmp_attr(commentA, commentB, attr) > 0
699 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
701 >>> commentB.author = "John Doe"
702 >>> cmp_attr(commentA, commentB, attr) == 0
705 if not hasattr(comment_2, attr) :
707 val_1 = getattr(comment_1, attr)
708 val_2 = getattr(comment_2, attr)
709 if val_1 == None: val_1 = None
710 if val_2 == None: val_2 = None
713 return -cmp(val_1, val_2)
715 return cmp(val_1, val_2)
717 # alphabetical rankings (a < z)
718 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
719 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
720 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
721 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
722 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
723 # chronological rankings (newer < older)
724 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
726 DEFAULT_CMP_FULL_CMP_LIST = \
727 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
730 class CommentCompoundComparator (object):
731 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
732 self.cmp_list = cmp_list
733 def __call__(self, comment_1, comment_2):
734 for comparison in self.cmp_list :
735 val = comparison(comment_1, comment_2)
740 cmp_full = CommentCompoundComparator()
742 suite = doctest.DocTestSuite()