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