Added Bug.merge() and Comment.merge().
[be.git] / libbe / comment.py
1 # Bugs Everywhere, a distributed bugtracker
2 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
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
20 """
21 Define the Comment class for representing bug comments.
22 """
23
24 import base64
25 import os
26 import os.path
27 import sys
28 import time
29 import types
30 try: # import core module, Python >= 2.5
31     from xml.etree import ElementTree
32 except ImportError: # look for non-core module
33     from elementtree import ElementTree
34 import xml.sax.saxutils
35 import doctest
36
37 from beuuid import uuid_gen
38 from properties import Property, doc_property, local_property, \
39     defaulting_property, checked_property, cached_property, \
40     primed_property, change_hook_property, settings_property
41 import settings_object
42 import mapfile
43 from tree import Tree
44 import utility
45
46
47 class InvalidShortname(KeyError):
48     def __init__(self, shortname, shortnames):
49         msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
50         KeyError.__init__(self, msg)
51         self.shortname = shortname
52         self.shortnames = shortnames
53
54 class MissingReference(ValueError):
55     def __init__(self, comment):
56         msg = "Missing reference to %s" % (comment.in_reply_to)
57         ValueError.__init__(self, msg)
58         self.reference = comment.in_reply_to
59         self.comment = comment
60
61 class DiskAccessRequired (Exception):
62     def __init__(self, goal):
63         msg = "Cannot %s without accessing the disk" % goal
64         Exception.__init__(self, msg)
65
66 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
67
68 def list_to_root(comments, bug, root=None,
69                  ignore_missing_references=False):
70     """
71     Convert a raw list of comments to single root comment.  We use a
72     dummy root comment by default, because there can be several
73     comment threads rooted on the same parent bug.  To simplify
74     comment interaction, we condense these threads into a single
75     thread with a Comment dummy root.  Can also be used to append
76     a list of subcomments to a non-dummy root comment, so long as
77     all the new comments are descendants of the root comment.
78     
79     No Comment method should use the dummy comment.
80     """
81     root_comments = []
82     uuid_map = {}
83     for comment in comments:
84         assert comment.uuid != None
85         uuid_map[comment.uuid] = comment
86     for comment in comments:
87         if comment.alt_id != None and comment.alt_id not in uuid_map:
88             uuid_map[comment.alt_id] = comment
89     if root == None:
90         root = Comment(bug, uuid=INVALID_UUID)
91     else:
92         uuid_map[root.uuid] = root
93     for comm in comments:
94         if comm.in_reply_to == INVALID_UUID:
95             comm.in_reply_to = None
96         rep = comm.in_reply_to
97         if rep == None or rep == bug.uuid:
98             root_comments.append(comm)
99         else:
100             parentUUID = comm.in_reply_to
101             try:
102                 parent = uuid_map[parentUUID]
103                 parent.add_reply(comm)
104             except KeyError, e:
105                 if ignore_missing_references == True:
106                     print >> sys.stderr, \
107                         "Ignoring missing reference to %s" % parentUUID
108                     comm.in_reply_to = None
109                     root_comments.append(comm)
110                 else:
111                     raise MissingReference(comm)
112     root.extend(root_comments)
113     return root
114
115 def loadComments(bug, load_full=False):
116     """
117     Set load_full=True when you want to load the comment completely
118     from disk *now*, rather than waiting and lazy loading as required.
119     """
120     if bug.sync_with_disk == False:
121         raise DiskAccessRequired("load comments")
122     path = bug.get_path("comments")
123     if not os.path.exists(path):
124         return Comment(bug, uuid=INVALID_UUID)
125     comments = []
126     for uuid in os.listdir(path):
127         if uuid.startswith('.'):
128             continue
129         comm = Comment(bug, uuid, from_disk=True)
130         comm.set_sync_with_disk(bug.sync_with_disk)
131         if load_full == True:
132             comm.load_settings()
133             dummy = comm.body # force the body to load
134         comments.append(comm)
135     return list_to_root(comments, bug)
136
137 def saveComments(bug):
138     if bug.sync_with_disk == False:
139         raise DiskAccessRequired("save comments")
140     for comment in bug.comment_root.traverse():
141         comment.save()
142
143
144 class Comment(Tree, settings_object.SavedSettingsObject):
145     """
146     >>> c = Comment()
147     >>> c.uuid != None
148     True
149     >>> c.uuid = "some-UUID"
150     >>> print c.content_type
151     text/plain
152     """
153
154     settings_properties = []
155     required_saved_properties = []
156     _prop_save_settings = settings_object.prop_save_settings
157     _prop_load_settings = settings_object.prop_load_settings
158     def _versioned_property(settings_properties=settings_properties,
159                             required_saved_properties=required_saved_properties,
160                             **kwargs):
161         if "settings_properties" not in kwargs:
162             kwargs["settings_properties"] = settings_properties
163         if "required_saved_properties" not in kwargs:
164             kwargs["required_saved_properties"]=required_saved_properties
165         return settings_object.versioned_property(**kwargs)
166
167     @_versioned_property(name="Alt-id",
168                          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.")
169     def alt_id(): return {}
170
171     @_versioned_property(name="Author",
172                          doc="The author of the comment")
173     def author(): return {}
174
175     @_versioned_property(name="In-reply-to",
176                          doc="UUID for parent comment or bug")
177     def in_reply_to(): return {}
178
179     @_versioned_property(name="Content-type",
180                          doc="Mime type for comment body",
181                          default="text/plain",
182                          require_save=True)
183     def content_type(): return {}
184
185     @_versioned_property(name="Date",
186                          doc="An RFC 2822 timestamp for comment creation")
187     def date(): return {}
188
189     def _get_time(self):
190         if self.date == None:
191             return None
192         return utility.str_to_time(self.date)
193     def _set_time(self, value):
194         self.date = utility.time_to_str(value)
195     time = property(fget=_get_time,
196                     fset=_set_time,
197                     doc="An integer version of .date")
198
199     def _get_comment_body(self):
200         if self.vcs != None and self.sync_with_disk == True:
201             import vcs
202             binary = not self.content_type.startswith("text/")
203             return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
204     def _set_comment_body(self, old=None, new=None, force=False):
205         if (self.vcs != None and self.sync_with_disk == True) or force==True:
206             assert new != None, "Can't save empty comment"
207             binary = not self.content_type.startswith("text/")
208             self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
209
210     @Property
211     @change_hook_property(hook=_set_comment_body)
212     @cached_property(generator=_get_comment_body)
213     @local_property("body")
214     @doc_property(doc="The meat of the comment")
215     def body(): return {}
216
217     def _get_vcs(self):
218         if hasattr(self.bug, "vcs"):
219             return self.bug.vcs
220
221     @Property
222     @cached_property(generator=_get_vcs)
223     @local_property("vcs")
224     @doc_property(doc="A revision control system instance.")
225     def vcs(): return {}
226
227     def _extra_strings_check_fn(value):
228         return utility.iterable_full_of_strings(value, \
229                          alternative=settings_object.EMPTY)
230     def _extra_strings_change_hook(self, old, new):
231         self.extra_strings.sort() # to make merging easier
232         self._prop_save_settings(old, new)
233     @_versioned_property(name="extra_strings",
234                          doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
235                          default=[],
236                          check_fn=_extra_strings_check_fn,
237                          change_hook=_extra_strings_change_hook,
238                          mutable=True)
239     def extra_strings(): return {}
240
241     def __init__(self, bug=None, uuid=None, from_disk=False,
242                  in_reply_to=None, body=None):
243         """
244         Set from_disk=True to load an old comment.
245         Set from_disk=False to create a new comment.
246
247         The uuid option is required when from_disk==True.
248         
249         The in_reply_to and body options are only used if
250         from_disk==False (the default).  When from_disk==True, they are
251         loaded from the bug database.
252         
253         in_reply_to should be the uuid string of the parent comment.
254         """
255         Tree.__init__(self)
256         settings_object.SavedSettingsObject.__init__(self)
257         self.bug = bug
258         self.uuid = uuid 
259         if from_disk == True: 
260             self.sync_with_disk = True
261         else:
262             self.sync_with_disk = False
263             if uuid == None:
264                 self.uuid = uuid_gen()
265             self.time = int(time.time()) # only save to second precision
266             if self.vcs != None:
267                 self.author = self.vcs.get_user_id()
268             self.in_reply_to = in_reply_to
269             self.body = body
270
271     def __cmp__(self, other):
272         return cmp_full(self, other)
273
274     def __str__(self):
275         """
276         >>> comm = Comment(bug=None, body="Some insightful remarks")
277         >>> comm.uuid = "com-1"
278         >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
279         >>> comm.author = "Jane Doe <jdoe@example.com>"
280         >>> print comm
281         --------- Comment ---------
282         Name: com-1
283         From: Jane Doe <jdoe@example.com>
284         Date: Thu, 20 Nov 2008 15:55:11 +0000
285         <BLANKLINE>
286         Some insightful remarks
287         """
288         return self.string()
289
290     def traverse(self, *args, **kwargs):
291         """Avoid working with the possible dummy root comment"""
292         for comment in Tree.traverse(self, *args, **kwargs):
293             if comment.uuid == INVALID_UUID:
294                 continue
295             yield comment
296
297     # serializing methods
298
299     def _setting_attr_string(self, setting):
300         value = getattr(self, setting)
301         if value == None:
302             return ""
303         if type(value) not in types.StringTypes:
304             return str(value)
305         return value
306
307     def xml(self, indent=0, shortname=None):
308         """
309         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
310         >>> comm.uuid = "0123"
311         >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
312         >>> print comm.xml(indent=2, shortname="com-1")
313           <comment>
314             <uuid>0123</uuid>
315             <short-name>com-1</short-name>
316             <author></author>
317             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
318             <content-type>text/plain</content-type>
319             <body>Some
320         insightful
321         remarks</body>
322           </comment>
323         """
324         if shortname == None:
325             shortname = self.uuid
326         if self.content_type.startswith('text/'):
327             body = (self.body or '').rstrip('\n')
328         else:
329             maintype,subtype = self.content_type.split('/',1)
330             msg = email.mime.base.MIMEBase(maintype, subtype)
331             msg.set_payload(self.body or '')
332             email.encoders.encode_base64(msg)
333             body = base64.encodestring(self.body or '')
334         info = [('uuid', self.uuid),
335                 ('alt-id', self.alt_id),
336                 ('short-name', shortname),
337                 ('in-reply-to', self.in_reply_to),
338                 ('author', self._setting_attr_string('author')),
339                 ('date', self.date),
340                 ('content-type', self.content_type),
341                 ('body', body)]
342         lines = ['<comment>']
343         for (k,v) in info:
344             if v != None:
345                 lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
346         for estr in self.extra_strings:
347             lines.append('  <extra-string>%s</extra-string>' % estr)
348         lines.append('</comment>')
349         istring = ' '*indent
350         sep = '\n' + istring
351         return istring + sep.join(lines).rstrip('\n')
352
353     def from_xml(self, xml_string, verbose=True):
354         """
355         Note: If alt-id is not given, translates any <uuid> fields to
356         <alt-id> fields.
357         >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
358         >>> commA.uuid = "0123"
359         >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
360         >>> commA.author = u'Fran\xe7ois'
361         >>> commA.extra_strings += ['TAG: very helpful']
362         >>> xml = commA.xml(shortname="com-1")
363         >>> commB = Comment()
364         >>> commB.from_xml(xml, verbose=True)
365         >>> commB.explicit_attrs
366         ['author', 'date', 'content_type', 'body', 'alt_id']
367         >>> commB.xml(shortname="com-1") == xml
368         False
369         >>> commB.uuid = commB.alt_id
370         >>> commB.alt_id = None
371         >>> commB.xml(shortname="com-1") == xml
372         True
373         """
374         if type(xml_string) == types.UnicodeType:
375             xml_string = xml_string.strip().encode('unicode_escape')
376         comment = ElementTree.XML(xml_string)
377         if comment.tag != 'comment':
378             raise utility.InvalidXML( \
379                 'comment', comment, 'root element must be <comment>')
380         tags=['uuid','alt-id','in-reply-to','author','date','content-type',
381               'body','extra-string']
382         self.explicit_attrs = []
383         uuid = None
384         body = None
385         estrs = []
386         for child in comment.getchildren():
387             if child.tag == 'short-name':
388                 pass
389             elif child.tag in tags:
390                 if child.text == None or len(child.text) == 0:
391                     text = settings_object.EMPTY
392                 else:
393                     text = xml.sax.saxutils.unescape(child.text)
394                     text = text.decode('unicode_escape').strip()
395                 if child.tag == 'uuid':
396                     uuid = text
397                     continue # don't set the comment's uuid tag.
398                 elif child.tag == 'body':
399                     body = text
400                     self.explicit_attrs.append(child.tag)
401                     continue # don't set the comment's body yet.
402                 elif child.tag == 'extra-string':
403                     estrs.append(text)
404                     continue # don't set the comment's extra_string yet.
405                 attr_name = child.tag.replace('-','_')
406                 self.explicit_attrs.append(attr_name)
407                 setattr(self, attr_name, text)
408             elif verbose == True:
409                 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
410                     % (child.tag, comment.tag)
411         if self.alt_id == None:
412             self.explicit_attrs.append('alt_id')
413             self.alt_id = uuid
414         if body != None:
415             if self.content_type.startswith('text/'):
416                 self.body = body+'\n' # restore trailing newline
417             else:
418                 self.body = base64.decodestring(body)
419         self.extra_strings = estrs
420
421     def merge(self, other, allow_changes=True):
422         """
423         Merge info from other into this comment.  Overrides any
424         attributes in self that are listed in other.explicit_attrs.
425         >>> commA = Comment(bug=None, body='Some insightful remarks')
426         >>> commA.uuid = '0123'
427         >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
428         >>> commA.author = 'Frank'
429         >>> commA.extra_strings += ['TAG: very helpful']
430         >>> commA.extra_strings += ['TAG: favorite']
431         >>> commB = Comment(bug=None, body='More insightful remarks')
432         >>> commB.uuid = '3210'
433         >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
434         >>> commB.author = 'John'
435         >>> commB.explicit_attrs = ['author', 'body']
436         >>> commB.extra_strings += ['TAG: very helpful']
437         >>> commB.extra_strings += ['TAG: useful']
438         >>> commA.merge(commB, allow_changes=False)
439         Traceback (most recent call last):
440           ...
441         ValueError: Merge would change author "Frank"->"John" for comment 0123
442         >>> commA.merge(commB)
443         >>> print commA.xml()
444         <comment>
445           <uuid>0123</uuid>
446           <short-name>0123</short-name>
447           <author>John</author>
448           <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
449           <content-type>text/plain</content-type>
450           <body>More insightful remarks</body>
451           <extra-string>TAG: favorite</extra-string>
452           <extra-string>TAG: useful</extra-string>
453           <extra-string>TAG: very helpful</extra-string>
454         </comment>
455         """
456         for attr in other.explicit_attrs:
457             old = getattr(self, attr)
458             new = getattr(other, attr)
459             if old != new:
460                 if allow_changes == True:
461                     setattr(self, attr, new)
462                 else:
463                     raise ValueError, \
464                         'Merge would change %s "%s"->"%s" for comment %s' \
465                         % (attr, old, new, self.uuid)
466         if allow_changes == False and len(other.extra_strings) > 0:
467             raise ValueError, \
468                 'Merge would change extra_strings for comment %s' % self.uuid
469         for estr in other.extra_strings:
470             if not estr in self.extra_strings:
471                 self.extra_strings.append(estr)
472
473     def string(self, indent=0, shortname=None):
474         """
475         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
476         >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
477         >>> print comm.string(indent=2, shortname="com-1")
478           --------- Comment ---------
479           Name: com-1
480           From: 
481           Date: Thu, 01 Jan 1970 00:00:00 +0000
482         <BLANKLINE>
483           Some
484           insightful
485           remarks
486         """
487         if shortname == None:
488             shortname = self.uuid
489         lines = []
490         lines.append("--------- Comment ---------")
491         lines.append("Name: %s" % shortname)
492         lines.append("From: %s" % (self._setting_attr_string("author")))
493         lines.append("Date: %s" % self.date)
494         lines.append("")
495         if self.content_type.startswith("text/"):
496             lines.extend((self.body or "").splitlines())
497         else:
498             lines.append("Content type %s not printable.  Try XML output instead" % self.content_type)
499         
500         istring = ' '*indent
501         sep = '\n' + istring
502         return istring + sep.join(lines).rstrip('\n')
503
504     def string_thread(self, string_method_name="string", name_map={},
505                       indent=0, flatten=True,
506                       auto_name_map=False, bug_shortname=None):
507         """
508         Return a string displaying a thread of comments.
509         bug_shortname is only used if auto_name_map == True.
510         
511         string_method_name (defaults to "string") is the name of the
512         Comment method used to generate the output string for each
513         Comment in the thread.  The method must take the arguments
514         indent and shortname.
515         
516         SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
517         which will sort the tree by comment.time.  Avoid by calling
518           name_map = {}
519           for shortname,comment in comm.comment_shortnames(bug_shortname):
520               name_map[comment.uuid] = shortname
521           comm.sort(key=lambda c : c.author) # your sort
522           comm.string_thread(name_map=name_map)
523
524         >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
525         >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
526         >>> b = a.new_reply("Critique original comment")
527         >>> b.uuid = "b"
528         >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
529         >>> c = b.new_reply("Begin flamewar :p")
530         >>> c.uuid = "c"
531         >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
532         >>> d = a.new_reply("Useful examples")
533         >>> d.uuid = "d"
534         >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
535         >>> a.sort(key=lambda comm : comm.time)
536         >>> print a.string_thread(flatten=True)
537         --------- Comment ---------
538         Name: a
539         From: 
540         Date: Thu, 20 Nov 2008 01:00:00 +0000
541         <BLANKLINE>
542         Insightful remarks
543           --------- Comment ---------
544           Name: b
545           From: 
546           Date: Thu, 20 Nov 2008 02:00:00 +0000
547         <BLANKLINE>
548           Critique original comment
549           --------- Comment ---------
550           Name: c
551           From: 
552           Date: Thu, 20 Nov 2008 03:00:00 +0000
553         <BLANKLINE>
554           Begin flamewar :p
555         --------- Comment ---------
556         Name: d
557         From: 
558         Date: Thu, 20 Nov 2008 04:00:00 +0000
559         <BLANKLINE>
560         Useful examples
561         >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
562         --------- Comment ---------
563         Name: bug-1:1
564         From: 
565         Date: Thu, 20 Nov 2008 01:00:00 +0000
566         <BLANKLINE>
567         Insightful remarks
568           --------- Comment ---------
569           Name: bug-1:2
570           From: 
571           Date: Thu, 20 Nov 2008 02:00:00 +0000
572         <BLANKLINE>
573           Critique original comment
574           --------- Comment ---------
575           Name: bug-1:3
576           From: 
577           Date: Thu, 20 Nov 2008 03:00:00 +0000
578         <BLANKLINE>
579           Begin flamewar :p
580         --------- Comment ---------
581         Name: bug-1:4
582         From: 
583         Date: Thu, 20 Nov 2008 04:00:00 +0000
584         <BLANKLINE>
585         Useful examples
586         """
587         if auto_name_map == True:
588             name_map = {}
589             for shortname,comment in self.comment_shortnames(bug_shortname):
590                 name_map[comment.uuid] = shortname
591         stringlist = []
592         for depth,comment in self.thread(flatten=flatten):
593             ind = 2*depth+indent
594             if comment.uuid in name_map:
595                 sname = name_map[comment.uuid]
596             else:
597                 sname = None
598             string_fn = getattr(comment, string_method_name)
599             stringlist.append(string_fn(indent=ind, shortname=sname))
600         return '\n'.join(stringlist)
601
602     def xml_thread(self, name_map={}, indent=0,
603                    auto_name_map=False, bug_shortname=None):
604         return self.string_thread(string_method_name="xml", name_map=name_map,
605                                   indent=indent, auto_name_map=auto_name_map,
606                                   bug_shortname=bug_shortname)
607
608     # methods for saving/loading/acessing settings and properties.
609
610     def get_path(self, *args):
611         dir = os.path.join(self.bug.get_path("comments"), self.uuid)
612         if len(args) == 0:
613             return dir
614         assert args[0] in ["values", "body"], str(args)
615         return os.path.join(dir, *args)
616
617     def set_sync_with_disk(self, value):
618         self.sync_with_disk = value
619
620     def load_settings(self):
621         if self.sync_with_disk == False:
622             raise DiskAccessRequired("load settings")
623         self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
624         self._setup_saved_settings()
625
626     def save_settings(self):
627         if self.sync_with_disk == False:
628             raise DiskAccessRequired("save settings")
629         self.vcs.mkdir(self.get_path())
630         path = self.get_path("values")
631         mapfile.map_save(self.vcs, path, self._get_saved_settings())
632
633     def save(self):
634         """
635         Save any loaded contents to disk.
636         
637         However, if self.sync_with_disk = True, then any changes are
638         automatically written to disk as soon as they happen, so
639         calling this method will just waste time (unless something
640         else has been messing with your on-disk files).
641         """
642         sync_with_disk = self.sync_with_disk
643         if sync_with_disk == False:
644             self.set_sync_with_disk(True)
645         assert self.body != None, "Can't save blank comment"
646         self.save_settings()
647         self._set_comment_body(new=self.body, force=True)
648         if sync_with_disk == False:
649             self.set_sync_with_disk(False)
650
651     def remove(self):
652         if self.sync_with_disk == False and self.uuid != INVALID_UUID:
653             raise DiskAccessRequired("remove")
654         for comment in self.traverse():
655             path = comment.get_path()
656             self.vcs.recursive_remove(path)
657
658     def add_reply(self, reply, allow_time_inversion=False):
659         if self.uuid != INVALID_UUID:
660             reply.in_reply_to = self.uuid
661         self.append(reply)
662
663     def new_reply(self, body=None, content_type=None):
664         """
665         >>> comm = Comment(bug=None, body="Some insightful remarks")
666         >>> repA = comm.new_reply("Critique original comment")
667         >>> repB = repA.new_reply("Begin flamewar :p")
668         >>> repB.in_reply_to == repA.uuid
669         True
670         """
671         reply = Comment(self.bug, body=body)
672         if content_type != None: # set before saving body to decide binary format
673             reply.content_type = content_type
674         if self.bug != None:
675             reply.set_sync_with_disk(self.bug.sync_with_disk)
676         if reply.sync_with_disk == True:
677             reply.save()
678         self.add_reply(reply)
679         return reply
680
681     def comment_shortnames(self, bug_shortname=None):
682         """
683         Iterate through (id, comment) pairs, in time order.
684         (This is a user-friendly id, not the comment uuid).
685
686         SIDE-EFFECT : will sort the comment tree by comment.time
687
688         >>> a = Comment(bug=None, uuid="a")
689         >>> b = a.new_reply()
690         >>> b.uuid = "b"
691         >>> c = b.new_reply()
692         >>> c.uuid = "c"
693         >>> d = a.new_reply()
694         >>> d.uuid = "d"
695         >>> for id,name in a.comment_shortnames("bug-1"):
696         ...     print id, name.uuid
697         bug-1:1 a
698         bug-1:2 b
699         bug-1:3 c
700         bug-1:4 d
701         >>> for id,name in a.comment_shortnames():
702         ...     print id, name.uuid
703         :1 a
704         :2 b
705         :3 c
706         :4 d
707         """
708         if bug_shortname == None:
709             bug_shortname = ""
710         self.sort(key=lambda comm : comm.time)
711         for num,comment in enumerate(self.traverse()):
712             yield ("%s:%d" % (bug_shortname, num+1), comment)
713
714     def comment_from_shortname(self, comment_shortname, *args, **kwargs):
715         """
716         Use a comment shortname to look up a comment.
717         >>> a = Comment(bug=None, uuid="a")
718         >>> b = a.new_reply()
719         >>> b.uuid = "b"
720         >>> c = b.new_reply()
721         >>> c.uuid = "c"
722         >>> d = a.new_reply()
723         >>> d.uuid = "d"
724         >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
725         >>> id(comm) == id(c)
726         True
727         """
728         for cur_name, comment in self.comment_shortnames(*args, **kwargs):
729             if comment_shortname == cur_name:
730                 return comment
731         raise InvalidShortname(comment_shortname,
732                                list(self.comment_shortnames(*args, **kwargs)))
733
734     def comment_from_uuid(self, uuid, match_alt_id=True):
735         """
736         Use a comment shortname to look up a comment.
737         >>> a = Comment(bug=None, uuid="a")
738         >>> b = a.new_reply()
739         >>> b.uuid = "b"
740         >>> c = b.new_reply()
741         >>> c.uuid = "c"
742         >>> d = a.new_reply()
743         >>> d.uuid = "d"
744         >>> d.alt_id = "d-alt"
745         >>> comm = a.comment_from_uuid("d")
746         >>> id(comm) == id(d)
747         True
748         >>> comm = a.comment_from_uuid("d-alt")
749         >>> id(comm) == id(d)
750         True
751         >>> comm = a.comment_from_uuid(None, match_alt_id=False)
752         Traceback (most recent call last):
753           ...
754         KeyError: None
755         """
756         for comment in self.traverse():
757             if comment.uuid == uuid:
758                 return comment
759             if match_alt_id == True and uuid != None \
760                     and comment.alt_id == uuid:
761                 return comment
762         raise KeyError(uuid)
763
764 def cmp_attr(comment_1, comment_2, attr, invert=False):
765     """
766     Compare a general attribute between two comments using the conventional
767     comparison rule for that attribute type.  If invert == True, sort
768     *against* that convention.
769     >>> attr="author"
770     >>> commentA = Comment()
771     >>> commentB = Comment()
772     >>> commentA.author = "John Doe"
773     >>> commentB.author = "Jane Doe"
774     >>> cmp_attr(commentA, commentB, attr) > 0
775     True
776     >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
777     True
778     >>> commentB.author = "John Doe"
779     >>> cmp_attr(commentA, commentB, attr) == 0
780     True
781     """
782     if not hasattr(comment_2, attr) :
783         return 1
784     val_1 = getattr(comment_1, attr)
785     val_2 = getattr(comment_2, attr)
786     if val_1 == None: val_1 = None
787     if val_2 == None: val_2 = None
788     
789     if invert == True :
790         return -cmp(val_1, val_2)
791     else :
792         return cmp(val_1, val_2)
793
794 # alphabetical rankings (a < z)
795 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
796 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
797 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
798 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
799 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
800 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
801 # chronological rankings (newer < older)
802 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
803
804
805 DEFAULT_CMP_FULL_CMP_LIST = \
806     (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
807      cmp_uuid, cmp_extra_strings)
808
809 class CommentCompoundComparator (object):
810     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
811         self.cmp_list = cmp_list
812     def __call__(self, comment_1, comment_2):
813         for comparison in self.cmp_list :
814             val = comparison(comment_1, comment_2)
815             if val != 0 :
816                 return val
817         return 0
818         
819 cmp_full = CommentCompoundComparator()
820
821 suite = doctest.DocTestSuite()