Fixed the merge
[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         comm.set_sync_with_disk(bug.sync_with_disk)
127         if load_full == True:
128             comm.load_settings()
129             dummy = comm.body # force the body to load
130         comments.append(comm)
131     return list_to_root(comments, bug)
132
133 def saveComments(bug):
134     for comment in bug.comment_root.traverse():
135         comment.save()
136
137
138 class Comment(Tree, settings_object.SavedSettingsObject):
139     """
140     >>> c = Comment()
141     >>> c.uuid != None
142     True
143     >>> c.uuid = "some-UUID"
144     >>> print c.content_type
145     text/plain
146     """
147
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,
154                             **kwargs):
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)
160
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 {}
164
165     @_versioned_property(name="From",
166                          doc="The author of the comment")
167     def From(): return {}
168
169     @_versioned_property(name="In-reply-to",
170                          doc="UUID for parent comment or bug")
171     def in_reply_to(): return {}
172
173     @_versioned_property(name="Content-type",
174                          doc="Mime type for comment body",
175                          default="text/plain",
176                          require_save=True)
177     def content_type(): return {}
178
179     @_versioned_property(name="Date",
180                          doc="An RFC 2822 timestamp for comment creation")
181     def time_string(): return {}
182
183     def _get_time(self):
184         if self.time_string == None:
185             return 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,
190                     fset=_set_time,
191                     doc="An integer version of .time_string")
192
193     def _get_comment_body(self):
194         if self.rcs != None and self.sync_with_disk == True:
195             import rcs
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)
203
204     @Property
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 {}
210
211     def _get_rcs(self):
212         if hasattr(self.bug, "rcs"):
213             return self.bug.rcs
214
215     @Property
216     @cached_property(generator=_get_rcs)
217     @local_property("rcs")
218     @doc_property(doc="A revision control system instance.")
219     def rcs(): return {}
220
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.",
229                          default=[],
230                          check_fn=_extra_strings_check_fn,
231                          change_hook=_extra_strings_change_hook,
232                          mutable=True)
233     def extra_strings(): return {}
234
235     def __init__(self, bug=None, uuid=None, from_disk=False,
236                  in_reply_to=None, body=None):
237         """
238         Set from_disk=True to load an old comment.
239         Set from_disk=False to create a new comment.
240
241         The uuid option is required when from_disk==True.
242         
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.
246         
247         in_reply_to should be the uuid string of the parent comment.
248         """
249         Tree.__init__(self)
250         settings_object.SavedSettingsObject.__init__(self)
251         self.bug = bug
252         self.uuid = uuid 
253         if from_disk == True: 
254             self.sync_with_disk = True
255         else:
256             self.sync_with_disk = False
257             if uuid == None:
258                 self.uuid = uuid_gen()
259             self.time = int(time.time()) # only save to second precision
260             if self.rcs != None:
261                 self.From = self.rcs.get_user_id()
262             self.in_reply_to = in_reply_to
263             self.body = body
264
265     def set_sync_with_disk(self, value):
266         self.sync_with_disk = True
267
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:
272                 continue
273             yield comment
274
275     def _setting_attr_string(self, setting):
276         value = getattr(self, setting)
277         if value == None:
278             return ""
279         return str(value)
280
281     def xml(self, indent=0, shortname=None):
282         """
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")
287           <comment>
288             <uuid>0123</uuid>
289             <short-name>com-1</short-name>
290             <from></from>
291             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
292             <content-type>text/plain</content-type>
293             <body>Some
294         insightful
295         remarks</body>
296           </comment>
297         """
298         if shortname == None:
299             shortname = self.uuid
300         if self.content_type.startswith("text/"):
301             body = (self.body or "").rstrip('\n')
302         else:
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),
315                 ("body", body)]
316         lines = ["<comment>"]
317         for (k,v) in info:
318             if v != None:
319                 lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
320         lines.append("</comment>")
321         istring = ' '*indent
322         sep = '\n' + istring
323         return istring + sep.join(lines).rstrip('\n')
324
325     def from_xml(self, xml_string, verbose=True):
326         """
327         Note: If alt-id is not given, translates any <uuid> fields to
328         <alt-id> fields.
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
344         0123
345         >>> commA.From
346         >>> commB.From
347         """
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']
354         uuid = None
355         body = None
356         for child in comment.getchildren():
357             if child.tag == "short-name":
358                 pass
359             elif child.tag in tags:
360                 if child.text == None or len(child.text) == 0:
361                     text = settings_object.EMPTY
362                 else:
363                     text = xml.sax.saxutils.unescape(child.text)
364                     text = unicode(text).decode("unicode_escape").strip()
365                 if child.tag == "uuid":
366                     uuid = text
367                     continue # don't set the bug's uuid tag.
368                 if child.tag == "body":
369                     body = text
370                     continue # don't set the bug's body yet.
371                 elif child.tag == 'from':
372                     attr_name = "From"
373                 elif child.tag == 'date':
374                     attr_name = 'time_string'
375                 else:
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]:
382             self.alt_id = uuid
383         if body != None:
384             if self.content_type.startswith("text/"):
385                 self.body = body+"\n" # restore trailing newline
386             else:
387                 self.body = base64.decodestring(body)
388
389     def string(self, indent=0, shortname=None):
390         """
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 ---------
395           Name: com-1
396           From: 
397           Date: Thu, 01 Jan 1970 00:00:00 +0000
398         <BLANKLINE>
399           Some
400           insightful
401           remarks
402         """
403         if shortname == None:
404             shortname = self.uuid
405         lines = []
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)
410         lines.append("")
411         if self.content_type.startswith("text/"):
412             lines.extend((self.body or "").splitlines())
413         else:
414             lines.append("Content type %s not printable.  Try XML output instead" % self.content_type)
415         
416         istring = ' '*indent
417         sep = '\n' + istring
418         return istring + sep.join(lines).rstrip('\n')
419
420     def __str__(self):
421         """
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>"
426         >>> print comm
427         --------- Comment ---------
428         Name: com-1
429         From: Jane Doe <jdoe@example.com>
430         Date: Thu, 20 Nov 2008 15:55:11 +0000
431         <BLANKLINE>
432         Some insightful remarks
433         """
434         return self.string()
435
436     def get_path(self, name=None):
437         my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
438         if name is None:
439             return my_dir
440         assert name in ["values", "body"]
441         return os.path.join(my_dir, name)
442
443     def load_settings(self):
444         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
445         self._setup_saved_settings()
446
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())
451
452     def save(self):
453         """
454         Save any loaded contents to disk.
455         
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).
460         """
461         assert self.body != None, "Can't save blank comment"
462         self.save_settings()
463         self._set_comment_body(new=self.body, force=True)
464
465     def remove(self):
466         for comment in self.traverse():
467             path = comment.get_path()
468             self.rcs.recursive_remove(path)
469
470     def add_reply(self, reply, allow_time_inversion=False):
471         if self.uuid != INVALID_UUID:
472             reply.in_reply_to = self.uuid
473         self.append(reply)
474         #raise Exception, "adding reply \n%s\n%s" % (self, reply)
475
476     def new_reply(self, body=None):
477         """
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
482         True
483         """
484         reply = Comment(self.bug, body=body)
485         if self.bug != None:
486             reply.set_sync_with_disk(self.bug.sync_with_disk)
487         if reply.sync_with_disk == True:
488             reply.save()
489         self.add_reply(reply)
490         return reply
491
492     def string_thread(self, string_method_name="string", name_map={},
493                       indent=0, flatten=True,
494                       auto_name_map=False, bug_shortname=None):
495         """
496         Return a string displaying a thread of comments.
497         bug_shortname is only used if auto_name_map == True.
498         
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.
503         
504         SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
505         which will sort the tree by comment.time.  Avoid by calling
506           name_map = {}
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)
511
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")
515         >>> b.uuid = "b"
516         >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
517         >>> c = b.new_reply("Begin flamewar :p")
518         >>> c.uuid = "c"
519         >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
520         >>> d = a.new_reply("Useful examples")
521         >>> d.uuid = "d"
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 ---------
526         Name: a
527         From: 
528         Date: Thu, 20 Nov 2008 01:00:00 +0000
529         <BLANKLINE>
530         Insightful remarks
531           --------- Comment ---------
532           Name: b
533           From: 
534           Date: Thu, 20 Nov 2008 02:00:00 +0000
535         <BLANKLINE>
536           Critique original comment
537           --------- Comment ---------
538           Name: c
539           From: 
540           Date: Thu, 20 Nov 2008 03:00:00 +0000
541         <BLANKLINE>
542           Begin flamewar :p
543         --------- Comment ---------
544         Name: d
545         From: 
546         Date: Thu, 20 Nov 2008 04:00:00 +0000
547         <BLANKLINE>
548         Useful examples
549         >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
550         --------- Comment ---------
551         Name: bug-1:1
552         From: 
553         Date: Thu, 20 Nov 2008 01:00:00 +0000
554         <BLANKLINE>
555         Insightful remarks
556           --------- Comment ---------
557           Name: bug-1:2
558           From: 
559           Date: Thu, 20 Nov 2008 02:00:00 +0000
560         <BLANKLINE>
561           Critique original comment
562           --------- Comment ---------
563           Name: bug-1:3
564           From: 
565           Date: Thu, 20 Nov 2008 03:00:00 +0000
566         <BLANKLINE>
567           Begin flamewar :p
568         --------- Comment ---------
569         Name: bug-1:4
570         From: 
571         Date: Thu, 20 Nov 2008 04:00:00 +0000
572         <BLANKLINE>
573         Useful examples
574         """
575         if auto_name_map == True:
576             name_map = {}
577             for shortname,comment in self.comment_shortnames(bug_shortname):
578                 name_map[comment.uuid] = shortname
579         stringlist = []
580         for depth,comment in self.thread(flatten=flatten):
581             ind = 2*depth+indent
582             if comment.uuid in name_map:
583                 sname = name_map[comment.uuid]
584             else:
585                 sname = None
586             string_fn = getattr(comment, string_method_name)
587             stringlist.append(string_fn(indent=ind, shortname=sname))
588         return '\n'.join(stringlist)
589
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)
595
596     def comment_shortnames(self, bug_shortname=None):
597         """
598         Iterate through (id, comment) pairs, in time order.
599         (This is a user-friendly id, not the comment uuid).
600
601         SIDE-EFFECT : will sort the comment tree by comment.time
602
603         >>> a = Comment(bug=None, uuid="a")
604         >>> b = a.new_reply()
605         >>> b.uuid = "b"
606         >>> c = b.new_reply()
607         >>> c.uuid = "c"
608         >>> d = a.new_reply()
609         >>> d.uuid = "d"
610         >>> for id,name in a.comment_shortnames("bug-1"):
611         ...     print id, name.uuid
612         bug-1:1 a
613         bug-1:2 b
614         bug-1:3 c
615         bug-1:4 d
616         """
617         if bug_shortname == None:
618             bug_shortname = ""
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)
622
623     def comment_from_shortname(self, comment_shortname, *args, **kwargs):
624         """
625         Use a comment shortname to look up a comment.
626         >>> a = Comment(bug=None, uuid="a")
627         >>> b = a.new_reply()
628         >>> b.uuid = "b"
629         >>> c = b.new_reply()
630         >>> c.uuid = "c"
631         >>> d = a.new_reply()
632         >>> d.uuid = "d"
633         >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
634         >>> id(comm) == id(c)
635         True
636         """
637         for cur_name, comment in self.comment_shortnames(*args, **kwargs):
638             if comment_shortname == cur_name:
639                 return comment
640         raise InvalidShortname(comment_shortname,
641                                list(self.comment_shortnames(*args, **kwargs)))
642
643     def comment_from_uuid(self, uuid):
644         """
645         Use a comment shortname to look up a comment.
646         >>> a = Comment(bug=None, uuid="a")
647         >>> b = a.new_reply()
648         >>> b.uuid = "b"
649         >>> c = b.new_reply()
650         >>> c.uuid = "c"
651         >>> d = a.new_reply()
652         >>> d.uuid = "d"
653         >>> comm = a.comment_from_uuid("d")
654         >>> id(comm) == id(d)
655         True
656         """
657         for comment in self.traverse():
658             if comment.uuid == uuid:
659                 return comment
660         raise KeyError(uuid)
661
662 suite = doctest.DocTestSuite()