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