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