Cleaned up some outdated libbe.settings_object.EMPTY cruft.
[be.git] / libbe / comment.py
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>
5 #
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.
10 #
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.
15 #
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.
19 import base64
20 import os
21 import os.path
22 import sys
23 import time
24 import types
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
30 import doctest
31
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
37 import mapfile
38 from tree import Tree
39 import utility
40
41
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
48
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
56
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
63
64 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
65
66 def list_to_root(comments, bug, root=None,
67                  ignore_missing_references=False):
68     """
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.
76     
77     No Comment method should use the dummy comment.
78     """
79     root_comments = []
80     uuid_map = {}
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
87     if root == None:
88         root = Comment(bug, uuid=INVALID_UUID)
89     else:
90         uuid_map[root.uuid] = root
91     for comm in comments:
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)
97         else:
98             parentUUID = comm.in_reply_to
99             try:
100                 parent = uuid_map[parentUUID]
101                 parent.add_reply(comm)
102             except KeyError, e:
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)
108                 else:
109                     raise MissingReference(comm)
110     root.extend(root_comments)
111     return root
112
113 def loadComments(bug, load_full=False):
114     """
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.
117     """
118     path = bug.get_path("comments")
119     if not os.path.isdir(path):
120         return Comment(bug, uuid=INVALID_UUID)
121     comments = []
122     for uuid in os.listdir(path):
123         if uuid.startswith('.'):
124             continue
125         comm = Comment(bug, uuid, from_disk=True)
126         if load_full == True:
127             comm.load_settings()
128             dummy = comm.body # force the body to load
129         comments.append(comm)
130     return list_to_root(comments, bug)
131
132 def saveComments(bug):
133     path = bug.get_path("comments")
134     bug.rcs.mkdir(path)
135     for comment in bug.comment_root.traverse():
136         comment.save()
137
138
139 class Comment(Tree, settings_object.SavedSettingsObject):
140     """
141     >>> c = Comment()
142     >>> c.uuid != None
143     True
144     >>> c.uuid = "some-UUID"
145     >>> print c.content_type
146     text/plain
147     """
148
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,
155                             **kwargs):
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)
161
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 {}
165
166     @_versioned_property(name="From",
167                          doc="The author of the comment")
168     def From(): return {}
169
170     @_versioned_property(name="In-reply-to",
171                          doc="UUID for parent comment or bug")
172     def in_reply_to(): return {}
173
174     @_versioned_property(name="Content-type",
175                          doc="Mime type for comment body",
176                          default="text/plain",
177                          require_save=True)
178     def content_type(): return {}
179
180     @_versioned_property(name="Date",
181                          doc="An RFC 2822 timestamp for comment creation")
182     def time_string(): return {}
183
184     def _get_time(self):
185         if self.time_string == None:
186             return 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,
191                     fset=_set_time,
192                     doc="An integer version of .time_string")
193
194     def _get_comment_body(self):
195         if self.rcs != None and self.sync_with_disk == True:
196             import rcs
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)
204
205     @Property
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 {}
211
212     def _get_rcs(self):
213         if hasattr(self.bug, "rcs"):
214             return self.bug.rcs
215
216     @Property
217     @cached_property(generator=_get_rcs)
218     @local_property("rcs")
219     @doc_property(doc="A revision control system instance.")
220     def rcs(): return {}
221
222     def __init__(self, bug=None, uuid=None, from_disk=False,
223                  in_reply_to=None, body=None):
224         """
225         Set from_disk=True to load an old comment.
226         Set from_disk=False to create a new comment.
227
228         The uuid option is required when from_disk==True.
229         
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.
233         
234         in_reply_to should be the uuid string of the parent comment.
235         """
236         Tree.__init__(self)
237         settings_object.SavedSettingsObject.__init__(self)
238         self.bug = bug
239         self.uuid = uuid 
240         if from_disk == True: 
241             self.sync_with_disk = True
242         else:
243             self.sync_with_disk = False
244             if uuid == None:
245                 self.uuid = uuid_gen()
246             self.time = int(time.time()) # only save to second precision
247             if self.rcs != None:
248                 self.From = self.rcs.get_user_id()
249             self.in_reply_to = in_reply_to
250             self.body = body
251
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:
256                 continue
257             yield comment
258
259     def _setting_attr_string(self, setting):
260         value = getattr(self, setting)
261         if value == None:
262             return ""
263         return str(value)
264
265     def xml(self, indent=0, shortname=None):
266         """
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")
271           <comment>
272             <uuid>0123</uuid>
273             <short-name>com-1</short-name>
274             <from></from>
275             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
276             <content-type>text/plain</content-type>
277             <body>Some
278         insightful
279         remarks</body>
280           </comment>
281         """
282         if shortname == None:
283             shortname = self.uuid
284         if self.content_type.startswith("text/"):
285             body = (self.body or "").rstrip('\n')
286         else:
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),
299                 ("body", body)]
300         lines = ["<comment>"]
301         for (k,v) in info:
302             if v != None:
303                 lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
304         lines.append("</comment>")
305         istring = ' '*indent
306         sep = '\n' + istring
307         return istring + sep.join(lines).rstrip('\n')
308
309     def from_xml(self, xml_string, verbose=True):
310         """
311         Note: If alt-id is not given, translates any <uuid> fields to
312         <alt-id> fields.
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
328         0123
329         >>> commA.From
330         >>> commB.From
331         """
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']
338         uuid = None
339         body = None
340         for child in comment.getchildren():
341             if child.tag == "short-name":
342                 pass
343             elif child.tag in tags:
344                 if child.text == None or len(child.text) == 0:
345                     text = settings_object.EMPTY
346                 else:
347                     text = xml.sax.saxutils.unescape(child.text)
348                     text = unicode(text).decode("unicode_escape").strip()
349                 if child.tag == "uuid":
350                     uuid = text
351                     continue # don't set the bug's uuid tag.
352                 if child.tag == "body":
353                     body = text
354                     continue # don't set the bug's body yet.
355                 elif child.tag == 'from':
356                     attr_name = "From"
357                 elif child.tag == 'date':
358                     attr_name = 'time_string'
359                 else:
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]:
366             self.alt_id = uuid
367         if body != None:
368             if self.content_type.startswith("text/"):
369                 self.body = body+"\n" # restore trailing newline
370             else:
371                 self.body = base64.decodestring(body)
372
373     def string(self, indent=0, shortname=None):
374         """
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 ---------
379           Name: com-1
380           From: 
381           Date: Thu, 01 Jan 1970 00:00:00 +0000
382         <BLANKLINE>
383           Some
384           insightful
385           remarks
386         """
387         if shortname == None:
388             shortname = self.uuid
389         lines = []
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)
394         lines.append("")
395         if self.content_type.startswith("text/"):
396             lines.extend((self.body or "").splitlines())
397         else:
398             lines.append("Content type %s not printable.  Try XML output instead" % self.content_type)
399         
400         istring = ' '*indent
401         sep = '\n' + istring
402         return istring + sep.join(lines).rstrip('\n')
403
404     def __str__(self):
405         """
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>"
410         >>> print comm
411         --------- Comment ---------
412         Name: com-1
413         From: Jane Doe <jdoe@example.com>
414         Date: Thu, 20 Nov 2008 15:55:11 +0000
415         <BLANKLINE>
416         Some insightful remarks
417         """
418         return self.string()
419
420     def get_path(self, name=None):
421         my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
422         if name is None:
423             return my_dir
424         assert name in ["values", "body"]
425         return os.path.join(my_dir, name)
426
427     def load_settings(self):
428         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
429         self._setup_saved_settings()
430
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())
437
438     def save(self):
439         assert self.body != None, "Can't save blank comment"
440         self.save_settings()
441         self._set_comment_body(new=self.body, force=True)
442
443     def remove(self):
444         for comment in self.traverse():
445             path = comment.get_path()
446             self.rcs.recursive_remove(path)
447
448     def add_reply(self, reply, allow_time_inversion=False):
449         if self.uuid != INVALID_UUID:
450             reply.in_reply_to = self.uuid
451         self.append(reply)
452         #raise Exception, "adding reply \n%s\n%s" % (self, reply)
453
454     def new_reply(self, body=None):
455         """
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
460         True
461         """
462         reply = Comment(self.bug, body=body)
463         self.add_reply(reply)
464         return reply
465
466     def string_thread(self, string_method_name="string", name_map={},
467                       indent=0, flatten=True,
468                       auto_name_map=False, bug_shortname=None):
469         """
470         Return a string displaying a thread of comments.
471         bug_shortname is only used if auto_name_map == True.
472         
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.
477         
478         SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
479         which will sort the tree by comment.time.  Avoid by calling
480           name_map = {}
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)
485
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")
489         >>> b.uuid = "b"
490         >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
491         >>> c = b.new_reply("Begin flamewar :p")
492         >>> c.uuid = "c"
493         >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
494         >>> d = a.new_reply("Useful examples")
495         >>> d.uuid = "d"
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 ---------
500         Name: a
501         From: 
502         Date: Thu, 20 Nov 2008 01:00:00 +0000
503         <BLANKLINE>
504         Insightful remarks
505           --------- Comment ---------
506           Name: b
507           From: 
508           Date: Thu, 20 Nov 2008 02:00:00 +0000
509         <BLANKLINE>
510           Critique original comment
511           --------- Comment ---------
512           Name: c
513           From: 
514           Date: Thu, 20 Nov 2008 03:00:00 +0000
515         <BLANKLINE>
516           Begin flamewar :p
517         --------- Comment ---------
518         Name: d
519         From: 
520         Date: Thu, 20 Nov 2008 04:00:00 +0000
521         <BLANKLINE>
522         Useful examples
523         >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
524         --------- Comment ---------
525         Name: bug-1:1
526         From: 
527         Date: Thu, 20 Nov 2008 01:00:00 +0000
528         <BLANKLINE>
529         Insightful remarks
530           --------- Comment ---------
531           Name: bug-1:2
532           From: 
533           Date: Thu, 20 Nov 2008 02:00:00 +0000
534         <BLANKLINE>
535           Critique original comment
536           --------- Comment ---------
537           Name: bug-1:3
538           From: 
539           Date: Thu, 20 Nov 2008 03:00:00 +0000
540         <BLANKLINE>
541           Begin flamewar :p
542         --------- Comment ---------
543         Name: bug-1:4
544         From: 
545         Date: Thu, 20 Nov 2008 04:00:00 +0000
546         <BLANKLINE>
547         Useful examples
548         """
549         if auto_name_map == True:
550             name_map = {}
551             for shortname,comment in self.comment_shortnames(bug_shortname):
552                 name_map[comment.uuid] = shortname
553         stringlist = []
554         for depth,comment in self.thread(flatten=flatten):
555             ind = 2*depth+indent
556             if comment.uuid in name_map:
557                 sname = name_map[comment.uuid]
558             else:
559                 sname = None
560             string_fn = getattr(comment, string_method_name)
561             stringlist.append(string_fn(indent=ind, shortname=sname))
562         return '\n'.join(stringlist)
563
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)
569
570     def comment_shortnames(self, bug_shortname=None):
571         """
572         Iterate through (id, comment) pairs, in time order.
573         (This is a user-friendly id, not the comment uuid).
574
575         SIDE-EFFECT : will sort the comment tree by comment.time
576
577         >>> a = Comment(bug=None, uuid="a")
578         >>> b = a.new_reply()
579         >>> b.uuid = "b"
580         >>> c = b.new_reply()
581         >>> c.uuid = "c"
582         >>> d = a.new_reply()
583         >>> d.uuid = "d"
584         >>> for id,name in a.comment_shortnames("bug-1"):
585         ...     print id, name.uuid
586         bug-1:1 a
587         bug-1:2 b
588         bug-1:3 c
589         bug-1:4 d
590         """
591         if bug_shortname == None:
592             bug_shortname = ""
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)
596
597     def comment_from_shortname(self, comment_shortname, *args, **kwargs):
598         """
599         Use a comment shortname to look up a comment.
600         >>> a = Comment(bug=None, uuid="a")
601         >>> b = a.new_reply()
602         >>> b.uuid = "b"
603         >>> c = b.new_reply()
604         >>> c.uuid = "c"
605         >>> d = a.new_reply()
606         >>> d.uuid = "d"
607         >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
608         >>> id(comm) == id(c)
609         True
610         """
611         for cur_name, comment in self.comment_shortnames(*args, **kwargs):
612             if comment_shortname == cur_name:
613                 return comment
614         raise InvalidShortname(comment_shortname,
615                                list(self.comment_shortnames(*args, **kwargs)))
616
617     def comment_from_uuid(self, uuid):
618         """
619         Use a comment shortname to look up a comment.
620         >>> a = Comment(bug=None, uuid="a")
621         >>> b = a.new_reply()
622         >>> b.uuid = "b"
623         >>> c = b.new_reply()
624         >>> c.uuid = "c"
625         >>> d = a.new_reply()
626         >>> d.uuid = "d"
627         >>> comm = a.comment_from_uuid("d")
628         >>> id(comm) == id(d)
629         True
630         """
631         for comment in self.traverse():
632             if comment.uuid == uuid:
633                 return comment
634         raise KeyError(uuid)
635
636 suite = doctest.DocTestSuite()