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