a669e4e5012e180f229ca324426dbee53d64994b
[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, verbose=True):
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, verbose=True)
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             elif verbose == True:
401                 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
402                     % (child.tag, comment.tag)
403         if uuid != self.uuid and self.alt_id == None:
404             self.explicit_attrs.append('alt_id')
405             self.alt_id = uuid
406         if body != None:
407             if self.content_type.startswith('text/'):
408                 self.body = body+'\n' # restore trailing newline
409             else:
410                 self.body = base64.decodestring(body)
411         self.extra_strings = estrs
412
413     def merge(self, other, accept_changes=True,
414               accept_extra_strings=True, change_exception=False):
415         """
416         Merge info from other into this comment.  Overrides any
417         attributes in self that are listed in other.explicit_attrs.
418
419         >>> commA = Comment(bug=None, body='Some insightful remarks')
420         >>> commA.uuid = '0123'
421         >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
422         >>> commA.author = 'Frank'
423         >>> commA.extra_strings += ['TAG: very helpful']
424         >>> commA.extra_strings += ['TAG: favorite']
425         >>> commB = Comment(bug=None, body='More insightful remarks')
426         >>> commB.uuid = '3210'
427         >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
428         >>> commB.author = 'John'
429         >>> commB.explicit_attrs = ['author', 'body']
430         >>> commB.extra_strings += ['TAG: very helpful']
431         >>> commB.extra_strings += ['TAG: useful']
432         >>> commA.merge(commB, accept_changes=False,
433         ...             accept_extra_strings=False, change_exception=False)
434         >>> commA.merge(commB, accept_changes=False,
435         ...             accept_extra_strings=False, change_exception=True)
436         Traceback (most recent call last):
437           ...
438         ValueError: Merge would change author "Frank"->"John" for comment 0123
439         >>> commA.merge(commB, accept_changes=True,
440         ...             accept_extra_strings=False, change_exception=True)
441         Traceback (most recent call last):
442           ...
443         ValueError: Merge would add extra string "TAG: useful" to comment 0123
444         >>> print commA.author
445         John
446         >>> print commA.extra_strings
447         ['TAG: favorite', 'TAG: very helpful']
448         >>> commA.merge(commB, accept_changes=True,
449         ...             accept_extra_strings=True, change_exception=True)
450         >>> print commA.extra_strings
451         ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
452         >>> print commA.xml()
453         <comment>
454           <uuid>0123</uuid>
455           <short-name>//012</short-name>
456           <author>John</author>
457           <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
458           <content-type>text/plain</content-type>
459           <body>More insightful remarks</body>
460           <extra-string>TAG: favorite</extra-string>
461           <extra-string>TAG: useful</extra-string>
462           <extra-string>TAG: very helpful</extra-string>
463         </comment>
464         """
465         if hasattr(other, 'explicit_attrs'):
466             for attr in other.explicit_attrs:
467                 old = getattr(self, attr)
468                 new = getattr(other, attr)
469                 if old != new:
470                     if accept_changes:
471                         setattr(self, attr, new)
472                     elif change_exception:
473                         raise ValueError(
474                             ('Merge would change {} "{}"->"{}" for comment {}'
475                              ).format(attr, old, new, self.uuid))
476         if self.alt_id == self.uuid:
477             self.alt_id = None
478         for estr in other.extra_strings:
479             if not estr in self.extra_strings:
480                 if accept_extra_strings == True:
481                     self.extra_strings.append(estr)
482                 elif change_exception == True:
483                     raise ValueError, \
484                         'Merge would add extra string "%s" to comment %s' \
485                         % (estr, self.uuid)
486
487     def string(self, indent=0):
488         """
489         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
490         >>> comm.uuid = 'abcdef'
491         >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
492         >>> print comm.string(indent=2)
493           --------- Comment ---------
494           Name: //abc
495           From: 
496           Date: Thu, 01 Jan 1970 00:00:00 +0000
497         <BLANKLINE>
498           Some
499           insightful
500           remarks
501         """
502         lines = []
503         lines.append("--------- Comment ---------")
504         lines.append("Name: %s" % self.id.user())
505         lines.append("From: %s" % (self._setting_attr_string("author")))
506         lines.append("Date: %s" % self.date)
507         lines.append("")
508         if self.content_type.startswith("text/"):
509             body = (self.body or "")
510             if self.bug != None and self.bug.bugdir != None:
511                 body = libbe.util.id.long_to_short_text(
512                     {self.bug.bugdir.uuid: self.bug.bugdir}, body)
513             lines.extend(body.splitlines())
514         else:
515             lines.append("Content type %s not printable.  Try XML output instead" % self.content_type)
516
517         istring = ' '*indent
518         sep = '\n' + istring
519         return istring + sep.join(lines).rstrip('\n')
520
521     def string_thread(self, string_method_name="string",
522                       indent=0, flatten=True):
523         """
524         Return a string displaying a thread of comments.
525         bug_shortname is only used if auto_name_map == True.
526
527         string_method_name (defaults to "string") is the name of the
528         Comment method used to generate the output string for each
529         Comment in the thread.  The method must take the arguments
530         indent and shortname.
531
532         >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
533         >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
534         >>> b = a.new_reply("Critique original comment")
535         >>> b.uuid = "b"
536         >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
537         >>> c = b.new_reply("Begin flamewar :p")
538         >>> c.uuid = "c"
539         >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
540         >>> d = a.new_reply("Useful examples")
541         >>> d.uuid = "d"
542         >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
543         >>> a.sort(key=lambda comm : comm.time)
544         >>> print a.string_thread(flatten=True)
545         --------- Comment ---------
546         Name: //a
547         From: 
548         Date: Thu, 20 Nov 2008 01:00:00 +0000
549         <BLANKLINE>
550         Insightful remarks
551           --------- Comment ---------
552           Name: //b
553           From: 
554           Date: Thu, 20 Nov 2008 02:00:00 +0000
555         <BLANKLINE>
556           Critique original comment
557           --------- Comment ---------
558           Name: //c
559           From: 
560           Date: Thu, 20 Nov 2008 03:00:00 +0000
561         <BLANKLINE>
562           Begin flamewar :p
563         --------- Comment ---------
564         Name: //d
565         From: 
566         Date: Thu, 20 Nov 2008 04:00:00 +0000
567         <BLANKLINE>
568         Useful examples
569         >>> print a.string_thread()
570         --------- Comment ---------
571         Name: //a
572         From: 
573         Date: Thu, 20 Nov 2008 01:00:00 +0000
574         <BLANKLINE>
575         Insightful remarks
576           --------- Comment ---------
577           Name: //b
578           From: 
579           Date: Thu, 20 Nov 2008 02:00:00 +0000
580         <BLANKLINE>
581           Critique original comment
582           --------- Comment ---------
583           Name: //c
584           From: 
585           Date: Thu, 20 Nov 2008 03:00:00 +0000
586         <BLANKLINE>
587           Begin flamewar :p
588         --------- Comment ---------
589         Name: //d
590         From: 
591         Date: Thu, 20 Nov 2008 04:00:00 +0000
592         <BLANKLINE>
593         Useful examples
594         """
595         stringlist = []
596         for depth,comment in self.thread(flatten=flatten):
597             ind = 2*depth+indent
598             string_fn = getattr(comment, string_method_name)
599             stringlist.append(string_fn(indent=ind))
600         return '\n'.join(stringlist)
601
602     def xml_thread(self, indent=0):
603         return self.string_thread(string_method_name="xml", indent=indent)
604
605     # methods for saving/loading/acessing settings and properties.
606
607     def load_settings(self, settings_mapfile=None):
608         if self.uuid == INVALID_UUID:
609             return
610         if settings_mapfile == None:
611             settings_mapfile = self.storage.get(
612                 self.id.storage('values'), '{}\n')
613         try:
614             settings = mapfile.parse(settings_mapfile)
615         except mapfile.InvalidMapfileContents, e:
616             raise Exception('Invalid settings file for comment %s\n'
617                             '(BE version missmatch?)' % self.id.user())
618         self._setup_saved_settings(settings)
619
620     def save_settings(self):
621         if self.uuid == INVALID_UUID:
622             return
623         mf = mapfile.generate(self._get_saved_settings())
624         self.storage.set(self.id.storage("values"), mf)
625
626     def save(self):
627         """
628         Save any loaded contents to storage.
629
630         However, if ``self.storage.is_writeable() == True``, then any
631         changes are automatically written to storage as soon as they
632         happen, so calling this method will just waste time (unless
633         something else has been messing with your stored files).
634         """
635         if self.uuid == INVALID_UUID:
636             return
637         assert self.storage != None, "Can't save without storage"
638         assert self.body != None, "Can't save blank comment"
639         if self.bug != None:
640             parent = self.bug.id.storage()
641         else:
642             parent = None
643         self.storage.add(self.id.storage(), parent=parent, directory=True)
644         self.storage.add(self.id.storage('values'), parent=self.id.storage(),
645                          directory=False)
646         self.storage.add(self.id.storage('body'), parent=self.id.storage(),
647                          directory=False)
648         self.save_settings()
649         self._set_comment_body(new=self.body, force=True)
650
651     def remove(self):
652         for comment in self:
653             comment.remove()
654         if self.uuid != INVALID_UUID:
655             self.storage.recursive_remove(self.id.storage())
656
657     def add_reply(self, reply, allow_time_inversion=False):
658         if self.uuid != INVALID_UUID:
659             reply.in_reply_to = self.uuid
660         self.append(reply)
661
662     def new_reply(self, body=None, content_type=None):
663         """
664         >>> comm = Comment(bug=None, body="Some insightful remarks")
665         >>> repA = comm.new_reply("Critique original comment")
666         >>> repB = repA.new_reply("Begin flamewar :p")
667         >>> repB.in_reply_to == repA.uuid
668         True
669         """
670         reply = Comment(self.bug, body=body, content_type=content_type)
671         self.add_reply(reply)
672         return reply
673
674     def comment_from_uuid(self, uuid, match_alt_id=True):
675         """Use a uuid to look up a comment.
676
677         >>> a = Comment(bug=None, uuid="a")
678         >>> b = a.new_reply()
679         >>> b.uuid = "b"
680         >>> c = b.new_reply()
681         >>> c.uuid = "c"
682         >>> d = a.new_reply()
683         >>> d.uuid = "d"
684         >>> d.alt_id = "d-alt"
685         >>> comm = a.comment_from_uuid("d")
686         >>> id(comm) == id(d)
687         True
688         >>> comm = a.comment_from_uuid("d-alt")
689         >>> id(comm) == id(d)
690         True
691         >>> comm = a.comment_from_uuid(None, match_alt_id=False)
692         Traceback (most recent call last):
693           ...
694         KeyError: None
695         """
696         for comment in self.traverse():
697             if comment.uuid == uuid:
698                 return comment
699             if match_alt_id == True and uuid != None \
700                     and comment.alt_id == uuid:
701                 return comment
702         raise KeyError(uuid)
703
704     # methods for id generation
705
706     def sibling_uuids(self):
707         if self.bug != None:
708             return self.bug.uuids()
709         return []
710
711
712 def cmp_attr(comment_1, comment_2, attr, invert=False):
713     """
714     Compare a general attribute between two comments using the conventional
715     comparison rule for that attribute type.  If invert == True, sort
716     *against* that convention.
717
718     >>> attr="author"
719     >>> commentA = Comment()
720     >>> commentB = Comment()
721     >>> commentA.author = "John Doe"
722     >>> commentB.author = "Jane Doe"
723     >>> cmp_attr(commentA, commentB, attr) > 0
724     True
725     >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
726     True
727     >>> commentB.author = "John Doe"
728     >>> cmp_attr(commentA, commentB, attr) == 0
729     True
730     """
731     if not hasattr(comment_2, attr) :
732         return 1
733     val_1 = getattr(comment_1, attr)
734     val_2 = getattr(comment_2, attr)
735     if val_1 == None: val_1 = None
736     if val_2 == None: val_2 = None
737
738     if invert == True :
739         return -cmp(val_1, val_2)
740     else :
741         return cmp(val_1, val_2)
742
743 # alphabetical rankings (a < z)
744 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
745 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
746 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
747 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
748 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
749 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
750 # chronological rankings (newer < older)
751 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
752
753
754 DEFAULT_CMP_FULL_CMP_LIST = \
755     (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
756      cmp_uuid, cmp_extra_strings)
757
758 class CommentCompoundComparator (object):
759     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
760         self.cmp_list = cmp_list
761     def __call__(self, comment_1, comment_2):
762         for comparison in self.cmp_list :
763             val = comparison(comment_1, comment_2)
764             if val != 0 :
765                 return val
766         return 0
767
768 cmp_full = CommentCompoundComparator()
769
770 if libbe.TESTING == True:
771     suite = doctest.DocTestSuite()