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