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