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