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 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
66 def list_to_root(comments, bug, root=None,
67 ignore_missing_references=False):
69 Convert a raw list of comments to single root comment. We use a
70 dummy root comment by default, because there can be several
71 comment threads rooted on the same parent bug. To simplify
72 comment interaction, we condense these threads into a single
73 thread with a Comment dummy root. Can also be used to append
74 a list of subcomments to a non-dummy root comment, so long as
75 all the new comments are descendants of the root comment.
77 No Comment method should use the dummy comment.
81 for comment in comments:
82 assert comment.uuid != None
83 uuid_map[comment.uuid] = comment
84 for comment in comments:
85 if comment.alt_id != None and comment.alt_id not in uuid_map:
86 uuid_map[comment.alt_id] = comment
88 root = Comment(bug, uuid=INVALID_UUID)
90 uuid_map[root.uuid] = root
92 if comm.in_reply_to == INVALID_UUID:
93 comm.in_reply_to = None
94 rep = comm.in_reply_to
95 if rep == None or rep == bug.uuid:
96 root_comments.append(comm)
98 parentUUID = comm.in_reply_to
100 parent = uuid_map[parentUUID]
101 parent.add_reply(comm)
103 if ignore_missing_references == True:
104 print >> sys.stderr, \
105 "Ignoring missing reference to %s" % parentUUID
106 comm.in_reply_to = None
107 root_comments.append(comm)
109 raise MissingReference(comm)
110 root.extend(root_comments)
113 def loadComments(bug, load_full=False):
115 Set load_full=True when you want to load the comment completely
116 from disk *now*, rather than waiting and lazy loading as required.
118 path = bug.get_path("comments")
119 if not os.path.isdir(path):
120 return Comment(bug, uuid=INVALID_UUID)
122 for uuid in os.listdir(path):
123 if uuid.startswith('.'):
125 comm = Comment(bug, uuid, from_disk=True)
126 comm.set_sync_with_disk(bug.sync_with_disk)
127 if load_full == True:
129 dummy = comm.body # force the body to load
130 comments.append(comm)
131 return list_to_root(comments, bug)
133 def saveComments(bug):
134 for comment in bug.comment_root.traverse():
138 class Comment(Tree, settings_object.SavedSettingsObject):
143 >>> c.uuid = "some-UUID"
144 >>> print c.content_type
148 settings_properties = []
149 required_saved_properties = []
150 _prop_save_settings = settings_object.prop_save_settings
151 _prop_load_settings = settings_object.prop_load_settings
152 def _versioned_property(settings_properties=settings_properties,
153 required_saved_properties=required_saved_properties,
155 if "settings_properties" not in kwargs:
156 kwargs["settings_properties"] = settings_properties
157 if "required_saved_properties" not in kwargs:
158 kwargs["required_saved_properties"]=required_saved_properties
159 return settings_object.versioned_property(**kwargs)
161 @_versioned_property(name="Alt-id",
162 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.")
163 def alt_id(): return {}
165 @_versioned_property(name="From",
166 doc="The author of the comment")
167 def From(): return {}
169 @_versioned_property(name="In-reply-to",
170 doc="UUID for parent comment or bug")
171 def in_reply_to(): return {}
173 @_versioned_property(name="Content-type",
174 doc="Mime type for comment body",
175 default="text/plain",
177 def content_type(): return {}
179 @_versioned_property(name="Date",
180 doc="An RFC 2822 timestamp for comment creation")
181 def time_string(): return {}
184 if self.time_string == None:
186 return utility.str_to_time(self.time_string)
187 def _set_time(self, value):
188 self.time_string = utility.time_to_str(value)
189 time = property(fget=_get_time,
191 doc="An integer version of .time_string")
193 def _get_comment_body(self):
194 if self.rcs != None and self.sync_with_disk == True:
196 binary = not self.content_type.startswith("text/")
197 return self.rcs.get_file_contents(self.get_path("body"), binary=binary)
198 def _set_comment_body(self, old=None, new=None, force=False):
199 if (self.rcs != None and self.sync_with_disk == True) or force==True:
200 assert new != None, "Can't save empty comment"
201 binary = not self.content_type.startswith("text/")
202 self.rcs.set_file_contents(self.get_path("body"), new, binary=binary)
205 @change_hook_property(hook=_set_comment_body)
206 @cached_property(generator=_get_comment_body)
207 @local_property("body")
208 @doc_property(doc="The meat of the comment")
209 def body(): return {}
212 if hasattr(self.bug, "rcs"):
216 @cached_property(generator=_get_rcs)
217 @local_property("rcs")
218 @doc_property(doc="A revision control system instance.")
221 def _extra_strings_check_fn(value):
222 return utility.iterable_full_of_strings(value, \
223 alternative=settings_object.EMPTY)
224 def _extra_strings_change_hook(self, old, new):
225 self.extra_strings.sort() # to make merging easier
226 self._prop_save_settings(old, new)
227 @_versioned_property(name="extra_strings",
228 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
230 check_fn=_extra_strings_check_fn,
231 change_hook=_extra_strings_change_hook,
233 def extra_strings(): return {}
235 def __init__(self, bug=None, uuid=None, from_disk=False,
236 in_reply_to=None, body=None):
238 Set from_disk=True to load an old comment.
239 Set from_disk=False to create a new comment.
241 The uuid option is required when from_disk==True.
243 The in_reply_to and body options are only used if
244 from_disk==False (the default). When from_disk==True, they are
245 loaded from the bug database.
247 in_reply_to should be the uuid string of the parent comment.
250 settings_object.SavedSettingsObject.__init__(self)
253 if from_disk == True:
254 self.sync_with_disk = True
256 self.sync_with_disk = False
258 self.uuid = uuid_gen()
259 self.time = int(time.time()) # only save to second precision
261 self.From = self.rcs.get_user_id()
262 self.in_reply_to = in_reply_to
265 def set_sync_with_disk(self, value):
266 self.sync_with_disk = True
268 def traverse(self, *args, **kwargs):
269 """Avoid working with the possible dummy root comment"""
270 for comment in Tree.traverse(self, *args, **kwargs):
271 if comment.uuid == INVALID_UUID:
275 def _setting_attr_string(self, setting):
276 value = getattr(self, setting)
281 def xml(self, indent=0, shortname=None):
283 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
284 >>> comm.uuid = "0123"
285 >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
286 >>> print comm.xml(indent=2, shortname="com-1")
289 <short-name>com-1</short-name>
291 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
292 <content-type>text/plain</content-type>
298 if shortname == None:
299 shortname = self.uuid
300 if self.content_type.startswith("text/"):
301 body = (self.body or "").rstrip('\n')
303 maintype,subtype = self.content_type.split('/',1)
304 msg = email.mime.base.MIMEBase(maintype, subtype)
305 msg.set_payload(self.body or "")
306 email.encoders.encode_base64(msg)
307 body = base64.encodestring(self.body or "")
308 info = [("uuid", self.uuid),
309 ("alt-id", self.alt_id),
310 ("short-name", shortname),
311 ("in-reply-to", self.in_reply_to),
312 ("from", self._setting_attr_string("From")),
313 ("date", self.time_string),
314 ("content-type", self.content_type),
316 lines = ["<comment>"]
319 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
320 lines.append("</comment>")
323 return istring + sep.join(lines).rstrip('\n')
325 def from_xml(self, xml_string, verbose=True):
327 Note: If alt-id is not given, translates any <uuid> fields to
329 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
330 >>> commA.uuid = "0123"
331 >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
332 >>> xml = commA.xml(shortname="com-1")
333 >>> commB = Comment()
334 >>> commB.from_xml(xml)
335 >>> attrs=['uuid','alt_id','in_reply_to','From','time_string','content_type','body']
336 >>> for attr in attrs: # doctest: +ELLIPSIS
337 ... if getattr(commB, attr) != getattr(commA, attr):
338 ... estr = "Mismatch on %s: '%s' should be '%s'"
339 ... args = (attr, getattr(commB, attr), getattr(commA, attr))
340 ... print estr % args
341 Mismatch on uuid: '...' should be '0123'
342 Mismatch on alt_id: '0123' should be 'None'
343 >>> print commB.alt_id
348 if type(xml_string) == types.UnicodeType:
349 xml_string = xml_string.strip().encode("unicode_escape")
350 comment = ElementTree.XML(xml_string)
351 if comment.tag != "comment":
352 raise InvalidXML(comment, "root element must be <comment>")
353 tags=['uuid','alt-id','in-reply-to','from','date','content-type','body']
356 for child in comment.getchildren():
357 if child.tag == "short-name":
359 elif child.tag in tags:
360 if child.text == None or len(child.text) == 0:
361 text = settings_object.EMPTY
363 text = xml.sax.saxutils.unescape(child.text)
364 text = unicode(text).decode("unicode_escape").strip()
365 if child.tag == "uuid":
367 continue # don't set the bug's uuid tag.
368 if child.tag == "body":
370 continue # don't set the bug's body yet.
371 elif child.tag == 'from':
373 elif child.tag == 'date':
374 attr_name = 'time_string'
376 attr_name = child.tag.replace('-','_')
377 setattr(self, attr_name, text)
378 elif verbose == True:
379 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
380 % (child.tag, comment.tag)
381 if self.alt_id == None and uuid not in [None, self.uuid]:
384 if self.content_type.startswith("text/"):
385 self.body = body+"\n" # restore trailing newline
387 self.body = base64.decodestring(body)
389 def string(self, indent=0, shortname=None):
391 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
392 >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
393 >>> print comm.string(indent=2, shortname="com-1")
394 --------- Comment ---------
397 Date: Thu, 01 Jan 1970 00:00:00 +0000
403 if shortname == None:
404 shortname = self.uuid
406 lines.append("--------- Comment ---------")
407 lines.append("Name: %s" % shortname)
408 lines.append("From: %s" % (self._setting_attr_string("From")))
409 lines.append("Date: %s" % self.time_string)
411 if self.content_type.startswith("text/"):
412 lines.extend((self.body or "").splitlines())
414 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
418 return istring + sep.join(lines).rstrip('\n')
422 >>> comm = Comment(bug=None, body="Some insightful remarks")
423 >>> comm.uuid = "com-1"
424 >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
425 >>> comm.From = "Jane Doe <jdoe@example.com>"
427 --------- Comment ---------
429 From: Jane Doe <jdoe@example.com>
430 Date: Thu, 20 Nov 2008 15:55:11 +0000
432 Some insightful remarks
436 def get_path(self, name=None):
437 my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
440 assert name in ["values", "body"]
441 return os.path.join(my_dir, name)
443 def load_settings(self):
444 self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
445 self._setup_saved_settings()
447 def save_settings(self):
448 self.rcs.mkdir(self.get_path())
449 path = self.get_path("values")
450 mapfile.map_save(self.rcs, path, self._get_saved_settings())
454 Save any loaded contents to disk.
456 However, if self.sync_with_disk = True, then any changes are
457 automatically written to disk as soon as they happen, so
458 calling this method will just waste time (unless something
459 else has been messing with your on-disk files).
461 assert self.body != None, "Can't save blank comment"
463 self._set_comment_body(new=self.body, force=True)
466 for comment in self.traverse():
467 path = comment.get_path()
468 self.rcs.recursive_remove(path)
470 def add_reply(self, reply, allow_time_inversion=False):
471 if self.uuid != INVALID_UUID:
472 reply.in_reply_to = self.uuid
474 #raise Exception, "adding reply \n%s\n%s" % (self, reply)
476 def new_reply(self, body=None):
478 >>> comm = Comment(bug=None, body="Some insightful remarks")
479 >>> repA = comm.new_reply("Critique original comment")
480 >>> repB = repA.new_reply("Begin flamewar :p")
481 >>> repB.in_reply_to == repA.uuid
484 reply = Comment(self.bug, body=body)
486 reply.set_sync_with_disk(self.bug.sync_with_disk)
487 if reply.sync_with_disk == True:
489 self.add_reply(reply)
492 def string_thread(self, string_method_name="string", name_map={},
493 indent=0, flatten=True,
494 auto_name_map=False, bug_shortname=None):
496 Return a string displaying a thread of comments.
497 bug_shortname is only used if auto_name_map == True.
499 string_method_name (defaults to "string") is the name of the
500 Comment method used to generate the output string for each
501 Comment in the thread. The method must take the arguments
502 indent and shortname.
504 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
505 which will sort the tree by comment.time. Avoid by calling
507 for shortname,comment in comm.comment_shortnames(bug_shortname):
508 name_map[comment.uuid] = shortname
509 comm.sort(key=lambda c : c.From) # your sort
510 comm.string_thread(name_map=name_map)
512 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
513 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
514 >>> b = a.new_reply("Critique original comment")
516 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
517 >>> c = b.new_reply("Begin flamewar :p")
519 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
520 >>> d = a.new_reply("Useful examples")
522 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
523 >>> a.sort(key=lambda comm : comm.time)
524 >>> print a.string_thread(flatten=True)
525 --------- Comment ---------
528 Date: Thu, 20 Nov 2008 01:00:00 +0000
531 --------- Comment ---------
534 Date: Thu, 20 Nov 2008 02:00:00 +0000
536 Critique original comment
537 --------- Comment ---------
540 Date: Thu, 20 Nov 2008 03:00:00 +0000
543 --------- Comment ---------
546 Date: Thu, 20 Nov 2008 04:00:00 +0000
549 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
550 --------- Comment ---------
553 Date: Thu, 20 Nov 2008 01:00:00 +0000
556 --------- Comment ---------
559 Date: Thu, 20 Nov 2008 02:00:00 +0000
561 Critique original comment
562 --------- Comment ---------
565 Date: Thu, 20 Nov 2008 03:00:00 +0000
568 --------- Comment ---------
571 Date: Thu, 20 Nov 2008 04:00:00 +0000
575 if auto_name_map == True:
577 for shortname,comment in self.comment_shortnames(bug_shortname):
578 name_map[comment.uuid] = shortname
580 for depth,comment in self.thread(flatten=flatten):
582 if comment.uuid in name_map:
583 sname = name_map[comment.uuid]
586 string_fn = getattr(comment, string_method_name)
587 stringlist.append(string_fn(indent=ind, shortname=sname))
588 return '\n'.join(stringlist)
590 def xml_thread(self, name_map={}, indent=0,
591 auto_name_map=False, bug_shortname=None):
592 return self.string_thread(string_method_name="xml", name_map=name_map,
593 indent=indent, auto_name_map=auto_name_map,
594 bug_shortname=bug_shortname)
596 def comment_shortnames(self, bug_shortname=None):
598 Iterate through (id, comment) pairs, in time order.
599 (This is a user-friendly id, not the comment uuid).
601 SIDE-EFFECT : will sort the comment tree by comment.time
603 >>> a = Comment(bug=None, uuid="a")
604 >>> b = a.new_reply()
606 >>> c = b.new_reply()
608 >>> d = a.new_reply()
610 >>> for id,name in a.comment_shortnames("bug-1"):
611 ... print id, name.uuid
617 if bug_shortname == None:
619 self.sort(key=lambda comm : comm.time)
620 for num,comment in enumerate(self.traverse()):
621 yield ("%s:%d" % (bug_shortname, num+1), comment)
623 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
625 Use a comment shortname to look up a comment.
626 >>> a = Comment(bug=None, uuid="a")
627 >>> b = a.new_reply()
629 >>> c = b.new_reply()
631 >>> d = a.new_reply()
633 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
634 >>> id(comm) == id(c)
637 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
638 if comment_shortname == cur_name:
640 raise InvalidShortname(comment_shortname,
641 list(self.comment_shortnames(*args, **kwargs)))
643 def comment_from_uuid(self, uuid):
645 Use a comment shortname to look up a comment.
646 >>> a = Comment(bug=None, uuid="a")
647 >>> b = a.new_reply()
649 >>> c = b.new_reply()
651 >>> d = a.new_reply()
653 >>> comm = a.comment_from_uuid("d")
654 >>> id(comm) == id(d)
657 for comment in self.traverse():
658 if comment.uuid == uuid:
662 suite = doctest.DocTestSuite()