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