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