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