1 # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
2 # Thomas Habets <thomas@habets.pp.se>
3 # W. Trevor King <wking@drexel.edu>
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.
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.
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.
20 Define the Comment class for representing bug comments.
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
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
45 if libbe.TESTING == True:
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
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
63 class DiskAccessRequired (Exception):
64 def __init__(self, goal):
65 msg = "Cannot %s without accessing the disk" % goal
66 Exception.__init__(self, msg)
68 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
70 def load_comments(bug, load_full=False):
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.
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'])
82 comm = Comment(bug, uuid, from_storage=True)
85 dummy = comm.body # force the body to load
87 bug.comment_root = Comment(bug, uuid=INVALID_UUID)
88 bug.add_comments(comments)
89 return bug.comment_root
91 def save_comments(bug):
92 for comment in bug.comment_root.traverse():
96 class Comment(Tree, settings_object.SavedSettingsObject):
101 >>> c.uuid = "some-UUID"
102 >>> print c.content_type
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,
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)
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 {}
123 def _get_user_id(self):
125 return self.bug._get_user_id()
128 @_versioned_property(name="Author",
129 doc="The author of the comment",
130 generator=_get_user_id)
131 def author(): return {}
133 @_versioned_property(name="In-reply-to",
134 doc="UUID for parent comment or bug")
135 def in_reply_to(): return {}
137 @_versioned_property(name="Content-type",
138 doc="Mime type for comment body",
139 default="text/plain",
141 def content_type(): return {}
143 @_versioned_property(name="Date",
144 doc="An RFC 2822 timestamp for comment creation")
145 def date(): return {}
148 if self.date == 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,
155 doc="An integer version of .date")
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) \
166 assert new != None, "Can't save empty comment"
167 self.storage.set(self.id("body"), new)
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 {}
176 def _get_storage(self):
177 if hasattr(self.bug, "storage"):
178 return self.bug.storage
181 @cached_property(generator=_get_storage)
182 @local_property("storage")
183 @doc_property(doc="A revision control system instance.")
184 def storage(): return {}
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.",
195 check_fn=_extra_strings_check_fn,
196 change_hook=_extra_strings_change_hook,
198 def extra_strings(): return {}
200 def __init__(self, bug=None, uuid=None, from_storage=False,
201 in_reply_to=None, body=None):
203 Set from_storage=True to load an old comment.
204 Set from_storage=False to create a new comment.
206 The uuid option is required when from_storage==True.
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.
212 in_reply_to should be the uuid string of the parent comment.
215 settings_object.SavedSettingsObject.__init__(self)
218 if from_storage == False:
220 self.uuid = libbe.util.id.uuid_gen()
222 self._setup_saved_settings()
223 if self.storage != None and self.storage.is_writeable():
224 self.storage.writeable = False
227 set_writeable = False
228 self.time = int(time.time()) # only save to second precision
229 self.in_reply_to = in_reply_to
231 if set_writeable == True:
232 self.storage.writeable = True
235 def __cmp__(self, other):
236 return cmp_full(self, other)
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>"
245 --------- Comment ---------
247 From: Jane Doe <jdoe@example.com>
248 Date: Thu, 20 Nov 2008 15:55:11 +0000
250 Some insightful remarks
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:
261 # serializing methods
263 def _setting_attr_string(self, setting):
264 value = getattr(self, setting)
267 if type(value) not in types.StringTypes:
271 def xml(self, indent=0, shortname=None):
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")
279 <short-name>com-1</short-name>
281 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
282 <content-type>text/plain</content-type>
288 if shortname == None:
289 shortname = self.uuid
290 if self.content_type.startswith('text/'):
291 body = (self.body or '').rstrip('\n')
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')),
304 ('content-type', self.content_type),
306 lines = ['<comment>']
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>')
315 return istring + sep.join(lines).rstrip('\n')
317 def from_xml(self, xml_string, verbose=True):
319 Note: If alt-id is not given, translates any <uuid> fields to
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
333 >>> commB.uuid = commB.alt_id
334 >>> commB.alt_id = None
335 >>> commB.xml(shortname="com-1") == xml
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
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 = []
353 for child in comment.getchildren():
354 if child.tag == 'short-name':
356 elif child.tag in tags:
357 if child.text == None or len(child.text) == 0:
358 text = settings_object.EMPTY
360 text = xml.sax.saxutils.unescape(child.text)
361 text = text.decode('unicode_escape').strip()
362 if child.tag == 'uuid':
364 continue # don't set the comment's uuid tag.
365 elif child.tag == 'body':
367 self.explicit_attrs.append(child.tag)
368 continue # don't set the comment's body yet.
369 elif child.tag == 'extra-string':
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')
382 if self.content_type.startswith('text/'):
383 self.body = body+'\n' # restore trailing newline
385 self.body = base64.decodestring(body)
386 self.extra_strings = estrs
388 def merge(self, other, accept_changes=True,
389 accept_extra_strings=True, change_exception=False):
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):
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):
417 ValueError: Merge would add extra string "TAG: useful" to comment 0123
418 >>> print commA.author
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()
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>
439 for attr in other.explicit_attrs:
440 old = getattr(self, attr)
441 new = getattr(other, attr)
443 if accept_changes == True:
444 setattr(self, attr, new)
445 elif change_exception == True:
447 'Merge would change %s "%s"->"%s" for comment %s' \
448 % (attr, old, new, self.uuid)
449 if self.alt_id == self.uuid:
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:
457 'Merge would add extra string "%s" to comment %s' \
460 def string(self, indent=0, shortname=None):
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 ---------
468 Date: Thu, 01 Jan 1970 00:00:00 +0000
474 if shortname == None:
475 shortname = self.uuid
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)
482 if self.content_type.startswith("text/"):
483 lines.extend((self.body or "").splitlines())
485 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
489 return istring + sep.join(lines).rstrip('\n')
491 def string_thread(self, string_method_name="string", name_map={},
492 indent=0, flatten=True,
493 auto_name_map=False, bug_shortname=None):
495 Return a string displaying a thread of comments.
496 bug_shortname is only used if auto_name_map == True.
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.
503 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
504 which will sort the tree by comment.time. Avoid by calling
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)
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")
515 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
516 >>> c = b.new_reply("Begin flamewar :p")
518 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
519 >>> d = a.new_reply("Useful examples")
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 ---------
527 Date: Thu, 20 Nov 2008 01:00:00 +0000
530 --------- Comment ---------
533 Date: Thu, 20 Nov 2008 02:00:00 +0000
535 Critique original comment
536 --------- Comment ---------
539 Date: Thu, 20 Nov 2008 03:00:00 +0000
542 --------- Comment ---------
545 Date: Thu, 20 Nov 2008 04:00:00 +0000
548 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
549 --------- Comment ---------
552 Date: Thu, 20 Nov 2008 01:00:00 +0000
555 --------- Comment ---------
558 Date: Thu, 20 Nov 2008 02:00:00 +0000
560 Critique original comment
561 --------- Comment ---------
564 Date: Thu, 20 Nov 2008 03:00:00 +0000
567 --------- Comment ---------
570 Date: Thu, 20 Nov 2008 04:00:00 +0000
574 if auto_name_map == True:
576 for shortname,comment in self.comment_shortnames(bug_shortname):
577 name_map[comment.uuid] = shortname
579 for depth,comment in self.thread(flatten=flatten):
581 if comment.uuid in name_map:
582 sname = name_map[comment.uuid]
585 string_fn = getattr(comment, string_method_name)
586 stringlist.append(string_fn(indent=ind, shortname=sname))
587 return '\n'.join(stringlist)
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)
595 # methods for saving/loading/acessing settings and properties.
598 assert len(args) <= 1, str(args)
600 assert args[0] in ["values", "body"], str(args)
601 return libbe.util.id.comment_id(self, *args)
603 def load_settings(self, settings_mapfile=None):
604 if settings_mapfile == None:
606 self.storage.get(self.id("values"), default="\n")
607 self.settings = mapfile.parse(settings_mapfile)
608 self._setup_saved_settings()
610 def save_settings(self):
611 mf = mapfile.generate(self._get_saved_settings())
612 self.storage.set(self.id("values"), mf)
616 Save any loaded contents to storage.
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).
623 if self.uuid == INVALID_UUID:
625 assert self.storage != None, "Can't save without storage"
626 assert self.body != None, "Can't save blank comment"
628 parent = self.bug.id()
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())
635 self._set_comment_body(new=self.body, force=True)
640 if self.uuid != INVALID_UUID:
641 self.storage.recursive_remove(self.id())
643 def add_reply(self, reply, allow_time_inversion=False):
644 if self.uuid != INVALID_UUID:
645 reply.in_reply_to = self.uuid
648 def new_reply(self, body=None, content_type=None):
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
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():
661 self.add_reply(reply)
664 def comment_shortnames(self, bug_shortname=None):
666 Iterate through (id, comment) pairs, in time order.
667 (This is a user-friendly id, not the comment uuid).
669 SIDE-EFFECT : will sort the comment tree by comment.time
671 >>> a = Comment(bug=None, uuid="a")
672 >>> b = a.new_reply()
674 >>> c = b.new_reply()
676 >>> d = a.new_reply()
678 >>> for id,name in a.comment_shortnames("bug-1"):
679 ... print id, name.uuid
684 >>> for id,name in a.comment_shortnames():
685 ... print id, name.uuid
691 if bug_shortname == None:
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)
697 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
699 Use a comment shortname to look up a comment.
700 >>> a = Comment(bug=None, uuid="a")
701 >>> b = a.new_reply()
703 >>> c = b.new_reply()
705 >>> d = a.new_reply()
707 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
708 >>> id(comm) == id(c)
711 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
712 if comment_shortname == cur_name:
714 raise InvalidShortname(comment_shortname,
715 list(self.comment_shortnames(*args, **kwargs)))
717 def comment_from_uuid(self, uuid, match_alt_id=True):
719 Use a comment shortname to look up a comment.
720 >>> a = Comment(bug=None, uuid="a")
721 >>> b = a.new_reply()
723 >>> c = b.new_reply()
725 >>> d = a.new_reply()
727 >>> d.alt_id = "d-alt"
728 >>> comm = a.comment_from_uuid("d")
729 >>> id(comm) == id(d)
731 >>> comm = a.comment_from_uuid("d-alt")
732 >>> id(comm) == id(d)
734 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
735 Traceback (most recent call last):
739 for comment in self.traverse():
740 if comment.uuid == uuid:
742 if match_alt_id == True and uuid != None \
743 and comment.alt_id == uuid:
747 def cmp_attr(comment_1, comment_2, attr, invert=False):
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.
753 >>> commentA = Comment()
754 >>> commentB = Comment()
755 >>> commentA.author = "John Doe"
756 >>> commentB.author = "Jane Doe"
757 >>> cmp_attr(commentA, commentB, attr) > 0
759 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
761 >>> commentB.author = "John Doe"
762 >>> cmp_attr(commentA, commentB, attr) == 0
765 if not hasattr(comment_2, attr) :
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
773 return -cmp(val_1, val_2)
775 return cmp(val_1, val_2)
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)
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)
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)
802 cmp_full = CommentCompoundComparator()
804 if libbe.TESTING == True:
805 suite = doctest.DocTestSuite()