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