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 if load_full == True:
128 dummy = comm.body # force the body to load
129 comments.append(comm)
130 return list_to_root(comments, bug)
132 def saveComments(bug):
133 path = bug.get_path("comments")
135 for comment in bug.comment_root.traverse():
139 class Comment(Tree, settings_object.SavedSettingsObject):
144 >>> c.uuid = "some-UUID"
145 >>> print c.content_type
149 settings_properties = []
150 required_saved_properties = []
151 _prop_save_settings = settings_object.prop_save_settings
152 _prop_load_settings = settings_object.prop_load_settings
153 def _versioned_property(settings_properties=settings_properties,
154 required_saved_properties=required_saved_properties,
156 if "settings_properties" not in kwargs:
157 kwargs["settings_properties"] = settings_properties
158 if "required_saved_properties" not in kwargs:
159 kwargs["required_saved_properties"]=required_saved_properties
160 return settings_object.versioned_property(**kwargs)
162 @_versioned_property(name="Alt-id",
163 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.")
164 def alt_id(): return {}
166 @_versioned_property(name="From",
167 doc="The author of the comment")
168 def From(): return {}
170 @_versioned_property(name="In-reply-to",
171 doc="UUID for parent comment or bug")
172 def in_reply_to(): return {}
174 @_versioned_property(name="Content-type",
175 doc="Mime type for comment body",
176 default="text/plain",
178 def content_type(): return {}
180 @_versioned_property(name="Date",
181 doc="An RFC 2822 timestamp for comment creation")
182 def time_string(): return {}
185 if self.time_string == None:
187 return utility.str_to_time(self.time_string)
188 def _set_time(self, value):
189 self.time_string = utility.time_to_str(value)
190 time = property(fget=_get_time,
192 doc="An integer version of .time_string")
194 def _get_comment_body(self):
195 if self.rcs != None and self.sync_with_disk == True:
197 binary = not self.content_type.startswith("text/")
198 return self.rcs.get_file_contents(self.get_path("body"), binary=binary)
199 def _set_comment_body(self, old=None, new=None, force=False):
200 if (self.rcs != None and self.sync_with_disk == True) or force==True:
201 assert new != None, "Can't save empty comment"
202 binary = not self.content_type.startswith("text/")
203 self.rcs.set_file_contents(self.get_path("body"), new, binary=binary)
206 @change_hook_property(hook=_set_comment_body)
207 @cached_property(generator=_get_comment_body)
208 @local_property("body")
209 @doc_property(doc="The meat of the comment")
210 def body(): return {}
213 if hasattr(self.bug, "rcs"):
217 @cached_property(generator=_get_rcs)
218 @local_property("rcs")
219 @doc_property(doc="A revision control system instance.")
222 def __init__(self, bug=None, uuid=None, from_disk=False,
223 in_reply_to=None, body=None):
225 Set from_disk=True to load an old comment.
226 Set from_disk=False to create a new comment.
228 The uuid option is required when from_disk==True.
230 The in_reply_to and body options are only used if
231 from_disk==False (the default). When from_disk==True, they are
232 loaded from the bug database.
234 in_reply_to should be the uuid string of the parent comment.
237 settings_object.SavedSettingsObject.__init__(self)
240 if from_disk == True:
241 self.sync_with_disk = True
243 self.sync_with_disk = False
245 self.uuid = uuid_gen()
246 self.time = int(time.time()) # only save to second precision
248 self.From = self.rcs.get_user_id()
249 self.in_reply_to = in_reply_to
252 def traverse(self, *args, **kwargs):
253 """Avoid working with the possible dummy root comment"""
254 for comment in Tree.traverse(self, *args, **kwargs):
255 if comment.uuid == INVALID_UUID:
259 def _setting_attr_string(self, setting):
260 value = getattr(self, setting)
265 def xml(self, indent=0, shortname=None):
267 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
268 >>> comm.uuid = "0123"
269 >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
270 >>> print comm.xml(indent=2, shortname="com-1")
273 <short-name>com-1</short-name>
275 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
276 <content-type>text/plain</content-type>
282 if shortname == None:
283 shortname = self.uuid
284 if self.content_type.startswith("text/"):
285 body = (self.body or "").rstrip('\n')
287 maintype,subtype = self.content_type.split('/',1)
288 msg = email.mime.base.MIMEBase(maintype, subtype)
289 msg.set_payload(self.body or "")
290 email.encoders.encode_base64(msg)
291 body = base64.encodestring(self.body or "")
292 info = [("uuid", self.uuid),
293 ("alt-id", self.alt_id),
294 ("short-name", shortname),
295 ("in-reply-to", self.in_reply_to),
296 ("from", self._setting_attr_string("From")),
297 ("date", self.time_string),
298 ("content-type", self.content_type),
300 lines = ["<comment>"]
303 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
304 lines.append("</comment>")
307 return istring + sep.join(lines).rstrip('\n')
309 def from_xml(self, xml_string, verbose=True):
311 Note: If alt-id is not given, translates any <uuid> fields to
313 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
314 >>> commA.uuid = "0123"
315 >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
316 >>> xml = commA.xml(shortname="com-1")
317 >>> commB = Comment()
318 >>> commB.from_xml(xml)
319 >>> attrs=['uuid','alt_id','in_reply_to','From','time_string','content_type','body']
320 >>> for attr in attrs: # doctest: +ELLIPSIS
321 ... if getattr(commB, attr) != getattr(commA, attr):
322 ... estr = "Mismatch on %s: '%s' should be '%s'"
323 ... args = (attr, getattr(commB, attr), getattr(commA, attr))
324 ... print estr % args
325 Mismatch on uuid: '...' should be '0123'
326 Mismatch on alt_id: '0123' should be 'None'
327 >>> print commB.alt_id
332 if type(xml_string) == types.UnicodeType:
333 xml_string = xml_string.strip().encode("unicode_escape")
334 comment = ElementTree.XML(xml_string)
335 if comment.tag != "comment":
336 raise InvalidXML(comment, "root element must be <comment>")
337 tags=['uuid','alt-id','in-reply-to','from','date','content-type','body']
340 for child in comment.getchildren():
341 if child.tag == "short-name":
343 elif child.tag in tags:
344 if child.text == None or len(child.text) == 0:
345 text = settings_object.EMPTY
347 text = xml.sax.saxutils.unescape(child.text)
348 text = unicode(text).decode("unicode_escape").strip()
349 if child.tag == "uuid":
351 continue # don't set the bug's uuid tag.
352 if child.tag == "body":
354 continue # don't set the bug's body yet.
355 elif child.tag == 'from':
357 elif child.tag == 'date':
358 attr_name = 'time_string'
360 attr_name = child.tag.replace('-','_')
361 setattr(self, attr_name, text)
362 elif verbose == True:
363 print >> sys.stderr, "Ignoring unknown tag %s in %s" \
364 % (child.tag, comment.tag)
365 if self.alt_id == None and uuid not in [None, self.uuid]:
368 if self.content_type.startswith("text/"):
369 self.body = body+"\n" # restore trailing newline
371 self.body = base64.decodestring(body)
373 def string(self, indent=0, shortname=None):
375 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
376 >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
377 >>> print comm.string(indent=2, shortname="com-1")
378 --------- Comment ---------
381 Date: Thu, 01 Jan 1970 00:00:00 +0000
387 if shortname == None:
388 shortname = self.uuid
390 lines.append("--------- Comment ---------")
391 lines.append("Name: %s" % shortname)
392 lines.append("From: %s" % (self._setting_attr_string("From")))
393 lines.append("Date: %s" % self.time_string)
395 if self.content_type.startswith("text/"):
396 lines.extend((self.body or "").splitlines())
398 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
402 return istring + sep.join(lines).rstrip('\n')
406 >>> comm = Comment(bug=None, body="Some insightful remarks")
407 >>> comm.uuid = "com-1"
408 >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
409 >>> comm.From = "Jane Doe <jdoe@example.com>"
411 --------- Comment ---------
413 From: Jane Doe <jdoe@example.com>
414 Date: Thu, 20 Nov 2008 15:55:11 +0000
416 Some insightful remarks
420 def get_path(self, name=None):
421 my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
424 assert name in ["values", "body"]
425 return os.path.join(my_dir, name)
427 def load_settings(self):
428 self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
429 self._setup_saved_settings()
431 def save_settings(self):
432 parent_dir = os.path.dirname(self.get_path())
433 self.rcs.mkdir(parent_dir)
434 self.rcs.mkdir(self.get_path())
435 path = self.get_path("values")
436 mapfile.map_save(self.rcs, path, self._get_saved_settings())
439 assert self.body != None, "Can't save blank comment"
441 self._set_comment_body(new=self.body, force=True)
444 for comment in self.traverse():
445 path = comment.get_path()
446 self.rcs.recursive_remove(path)
448 def add_reply(self, reply, allow_time_inversion=False):
449 if self.uuid != INVALID_UUID:
450 reply.in_reply_to = self.uuid
452 #raise Exception, "adding reply \n%s\n%s" % (self, reply)
454 def new_reply(self, body=None):
456 >>> comm = Comment(bug=None, body="Some insightful remarks")
457 >>> repA = comm.new_reply("Critique original comment")
458 >>> repB = repA.new_reply("Begin flamewar :p")
459 >>> repB.in_reply_to == repA.uuid
462 reply = Comment(self.bug, body=body)
463 self.add_reply(reply)
466 def string_thread(self, string_method_name="string", name_map={},
467 indent=0, flatten=True,
468 auto_name_map=False, bug_shortname=None):
470 Return a string displaying a thread of comments.
471 bug_shortname is only used if auto_name_map == True.
473 string_method_name (defaults to "string") is the name of the
474 Comment method used to generate the output string for each
475 Comment in the thread. The method must take the arguments
476 indent and shortname.
478 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
479 which will sort the tree by comment.time. Avoid by calling
481 for shortname,comment in comm.comment_shortnames(bug_shortname):
482 name_map[comment.uuid] = shortname
483 comm.sort(key=lambda c : c.From) # your sort
484 comm.string_thread(name_map=name_map)
486 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
487 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
488 >>> b = a.new_reply("Critique original comment")
490 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
491 >>> c = b.new_reply("Begin flamewar :p")
493 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
494 >>> d = a.new_reply("Useful examples")
496 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
497 >>> a.sort(key=lambda comm : comm.time)
498 >>> print a.string_thread(flatten=True)
499 --------- Comment ---------
502 Date: Thu, 20 Nov 2008 01:00:00 +0000
505 --------- Comment ---------
508 Date: Thu, 20 Nov 2008 02:00:00 +0000
510 Critique original comment
511 --------- Comment ---------
514 Date: Thu, 20 Nov 2008 03:00:00 +0000
517 --------- Comment ---------
520 Date: Thu, 20 Nov 2008 04:00:00 +0000
523 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
524 --------- Comment ---------
527 Date: Thu, 20 Nov 2008 01:00:00 +0000
530 --------- Comment ---------
533 Date: Thu, 20 Nov 2008 02:00:00 +0000
535 Critique original comment
536 --------- Comment ---------
539 Date: Thu, 20 Nov 2008 03:00:00 +0000
542 --------- Comment ---------
545 Date: Thu, 20 Nov 2008 04:00:00 +0000
549 if auto_name_map == True:
551 for shortname,comment in self.comment_shortnames(bug_shortname):
552 name_map[comment.uuid] = shortname
554 for depth,comment in self.thread(flatten=flatten):
556 if comment.uuid in name_map:
557 sname = name_map[comment.uuid]
560 string_fn = getattr(comment, string_method_name)
561 stringlist.append(string_fn(indent=ind, shortname=sname))
562 return '\n'.join(stringlist)
564 def xml_thread(self, name_map={}, indent=0,
565 auto_name_map=False, bug_shortname=None):
566 return self.string_thread(string_method_name="xml", name_map=name_map,
567 indent=indent, auto_name_map=auto_name_map,
568 bug_shortname=bug_shortname)
570 def comment_shortnames(self, bug_shortname=None):
572 Iterate through (id, comment) pairs, in time order.
573 (This is a user-friendly id, not the comment uuid).
575 SIDE-EFFECT : will sort the comment tree by comment.time
577 >>> a = Comment(bug=None, uuid="a")
578 >>> b = a.new_reply()
580 >>> c = b.new_reply()
582 >>> d = a.new_reply()
584 >>> for id,name in a.comment_shortnames("bug-1"):
585 ... print id, name.uuid
591 if bug_shortname == None:
593 self.sort(key=lambda comm : comm.time)
594 for num,comment in enumerate(self.traverse()):
595 yield ("%s:%d" % (bug_shortname, num+1), comment)
597 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
599 Use a comment shortname to look up a comment.
600 >>> a = Comment(bug=None, uuid="a")
601 >>> b = a.new_reply()
603 >>> c = b.new_reply()
605 >>> d = a.new_reply()
607 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
608 >>> id(comm) == id(c)
611 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
612 if comment_shortname == cur_name:
614 raise InvalidShortname(comment_shortname,
615 list(self.comment_shortnames(*args, **kwargs)))
617 def comment_from_uuid(self, uuid):
619 Use a comment shortname to look up a comment.
620 >>> a = Comment(bug=None, uuid="a")
621 >>> b = a.new_reply()
623 >>> c = b.new_reply()
625 >>> d = a.new_reply()
627 >>> comm = a.comment_from_uuid("d")
628 >>> id(comm) == id(d)
631 for comment in self.traverse():
632 if comment.uuid == uuid:
636 suite = doctest.DocTestSuite()