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