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