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