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>
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.
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.
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.
21 Define the Comment class for representing bug comments.
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
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
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
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
61 class DiskAccessRequired (Exception):
62 def __init__(self, goal):
63 msg = "Cannot %s without accessing the disk" % goal
64 Exception.__init__(self, msg)
66 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
68 def list_to_root(comments, bug, root=None,
69 ignore_missing_references=False):
71 Convert a raw list of comments to single root comment. We use a
72 dummy root comment by default, because there can be several
73 comment threads rooted on the same parent bug. To simplify
74 comment interaction, we condense these threads into a single
75 thread with a Comment dummy root. Can also be used to append
76 a list of subcomments to a non-dummy root comment, so long as
77 all the new comments are descendants of the root comment.
79 No Comment method should use the dummy comment.
83 for comment in comments:
84 assert comment.uuid != None
85 uuid_map[comment.uuid] = comment
86 for comment in comments:
87 if comment.alt_id != None and comment.alt_id not in uuid_map:
88 uuid_map[comment.alt_id] = comment
90 root = Comment(bug, uuid=INVALID_UUID)
92 uuid_map[root.uuid] = root
94 if comm.in_reply_to == INVALID_UUID:
95 comm.in_reply_to = None
96 rep = comm.in_reply_to
97 if rep == None or rep == bug.uuid:
98 root_comments.append(comm)
100 parentUUID = comm.in_reply_to
102 parent = uuid_map[parentUUID]
103 parent.add_reply(comm)
105 if ignore_missing_references == True:
106 print >> sys.stderr, \
107 "Ignoring missing reference to %s" % parentUUID
108 comm.in_reply_to = None
109 root_comments.append(comm)
111 raise MissingReference(comm)
112 root.extend(root_comments)
115 def loadComments(bug, load_full=False):
117 Set load_full=True when you want to load the comment completely
118 from disk *now*, rather than waiting and lazy loading as required.
120 if bug.sync_with_disk == False:
121 raise DiskAccessRequired("load comments")
122 path = bug.get_path("comments")
123 if not os.path.exists(path):
124 return Comment(bug, uuid=INVALID_UUID)
126 for uuid in os.listdir(path):
127 if uuid.startswith('.'):
129 comm = Comment(bug, uuid, from_disk=True)
130 comm.set_sync_with_disk(bug.sync_with_disk)
131 if load_full == True:
133 dummy = comm.body # force the body to load
134 comments.append(comm)
135 return list_to_root(comments, bug)
137 def saveComments(bug):
138 if bug.sync_with_disk == False:
139 raise DiskAccessRequired("save comments")
140 for comment in bug.comment_root.traverse():
144 class Comment(Tree, settings_object.SavedSettingsObject):
149 >>> c.uuid = "some-UUID"
150 >>> print c.content_type
154 settings_properties = []
155 required_saved_properties = []
156 _prop_save_settings = settings_object.prop_save_settings
157 _prop_load_settings = settings_object.prop_load_settings
158 def _versioned_property(settings_properties=settings_properties,
159 required_saved_properties=required_saved_properties,
161 if "settings_properties" not in kwargs:
162 kwargs["settings_properties"] = settings_properties
163 if "required_saved_properties" not in kwargs:
164 kwargs["required_saved_properties"]=required_saved_properties
165 return settings_object.versioned_property(**kwargs)
167 @_versioned_property(name="Alt-id",
168 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.")
169 def alt_id(): return {}
171 @_versioned_property(name="Author",
172 doc="The author of the comment")
173 def author(): return {}
175 @_versioned_property(name="In-reply-to",
176 doc="UUID for parent comment or bug")
177 def in_reply_to(): return {}
179 @_versioned_property(name="Content-type",
180 doc="Mime type for comment body",
181 default="text/plain",
183 def content_type(): return {}
185 @_versioned_property(name="Date",
186 doc="An RFC 2822 timestamp for comment creation")
187 def date(): return {}
190 if self.date == None:
192 return utility.str_to_time(self.date)
193 def _set_time(self, value):
194 self.date = utility.time_to_str(value)
195 time = property(fget=_get_time,
197 doc="An integer version of .date")
199 def _get_comment_body(self):
200 if self.vcs != None and self.sync_with_disk == True:
202 binary = not self.content_type.startswith("text/")
203 return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
204 def _set_comment_body(self, old=None, new=None, force=False):
205 if (self.vcs != None and self.sync_with_disk == True) or force==True:
206 assert new != None, "Can't save empty comment"
207 binary = not self.content_type.startswith("text/")
208 self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
211 @change_hook_property(hook=_set_comment_body)
212 @cached_property(generator=_get_comment_body)
213 @local_property("body")
214 @doc_property(doc="The meat of the comment")
215 def body(): return {}
218 if hasattr(self.bug, "vcs"):
222 @cached_property(generator=_get_vcs)
223 @local_property("vcs")
224 @doc_property(doc="A revision control system instance.")
227 def _extra_strings_check_fn(value):
228 return utility.iterable_full_of_strings(value, \
229 alternative=settings_object.EMPTY)
230 def _extra_strings_change_hook(self, old, new):
231 self.extra_strings.sort() # to make merging easier
232 self._prop_save_settings(old, new)
233 @_versioned_property(name="extra_strings",
234 doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
236 check_fn=_extra_strings_check_fn,
237 change_hook=_extra_strings_change_hook,
239 def extra_strings(): return {}
241 def __init__(self, bug=None, uuid=None, from_disk=False,
242 in_reply_to=None, body=None):
244 Set from_disk=True to load an old comment.
245 Set from_disk=False to create a new comment.
247 The uuid option is required when from_disk==True.
249 The in_reply_to and body options are only used if
250 from_disk==False (the default). When from_disk==True, they are
251 loaded from the bug database.
253 in_reply_to should be the uuid string of the parent comment.
256 settings_object.SavedSettingsObject.__init__(self)
259 if from_disk == True:
260 self.sync_with_disk = True
262 self.sync_with_disk = False
264 self.uuid = uuid_gen()
265 self.time = int(time.time()) # only save to second precision
267 self.author = self.vcs.get_user_id()
268 self.in_reply_to = in_reply_to
271 def __cmp__(self, other):
272 return cmp_full(self, other)
276 >>> comm = Comment(bug=None, body="Some insightful remarks")
277 >>> comm.uuid = "com-1"
278 >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
279 >>> comm.author = "Jane Doe <jdoe@example.com>"
281 --------- Comment ---------
283 From: Jane Doe <jdoe@example.com>
284 Date: Thu, 20 Nov 2008 15:55:11 +0000
286 Some insightful remarks
290 def traverse(self, *args, **kwargs):
291 """Avoid working with the possible dummy root comment"""
292 for comment in Tree.traverse(self, *args, **kwargs):
293 if comment.uuid == INVALID_UUID:
297 # serializing methods
299 def _setting_attr_string(self, setting):
300 value = getattr(self, setting)
303 if type(value) not in types.StringTypes:
307 def xml(self, indent=0, shortname=None):
309 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
310 >>> comm.uuid = "0123"
311 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
312 >>> print comm.xml(indent=2, shortname="com-1")
315 <short-name>com-1</short-name>
317 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
318 <content-type>text/plain</content-type>
324 if shortname == None:
325 shortname = self.uuid
326 if self.content_type.startswith('text/'):
327 body = (self.body or '').rstrip('\n')
329 maintype,subtype = self.content_type.split('/',1)
330 msg = email.mime.base.MIMEBase(maintype, subtype)
331 msg.set_payload(self.body or '')
332 email.encoders.encode_base64(msg)
333 body = base64.encodestring(self.body or '')
334 info = [('uuid', self.uuid),
335 ('alt-id', self.alt_id),
336 ('short-name', shortname),
337 ('in-reply-to', self.in_reply_to),
338 ('author', self._setting_attr_string('author')),
340 ('content-type', self.content_type),
342 lines = ['<comment>']
345 lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
346 for estr in self.extra_strings:
347 lines.append(' <extra-string>%s</extra-string>' % estr)
348 lines.append('</comment>')
351 return istring + sep.join(lines).rstrip('\n')
353 def from_xml(self, xml_string, verbose=True):
355 Note: If alt-id is not given, translates any <uuid> fields to
357 >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
358 >>> commA.uuid = "0123"
359 >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
360 >>> commA.author = u'Fran\xe7ois'
361 >>> commA.extra_strings += ['TAG: very helpful']
362 >>> xml = commA.xml(shortname="com-1")
363 >>> commB = Comment()
364 >>> commB.from_xml(xml, verbose=True)
365 >>> commB.explicit_attrs
366 ['author', 'date', 'content_type', 'body', 'alt_id']
367 >>> commB.xml(shortname="com-1") == xml
369 >>> commB.uuid = commB.alt_id
370 >>> commB.alt_id = None
371 >>> commB.xml(shortname="com-1") == xml
374 if type(xml_string) == types.UnicodeType:
375 xml_string = xml_string.strip().encode('unicode_escape')
376 comment = ElementTree.XML(xml_string)
377 if comment.tag != 'comment':
378 raise utility.InvalidXML( \
379 'comment', comment, 'root element must be <comment>')
380 tags=['uuid','alt-id','in-reply-to','author','date','content-type',
381 'body','extra-string']
382 self.explicit_attrs = []
386 for child in comment.getchildren():
387 if child.tag == 'short-name':
389 elif child.tag in tags:
390 if child.text == None or len(child.text) == 0:
391 text = settings_object.EMPTY
393 text = xml.sax.saxutils.unescape(child.text)
394 text = text.decode('unicode_escape').strip()
395 if child.tag == 'uuid':
397 continue # don't set the comment's uuid tag.
398 elif child.tag == 'body':
400 self.explicit_attrs.append(child.tag)
401 continue # don't set the comment's body yet.
402 elif child.tag == 'extra-string':
404 continue # don't set the comment's extra_string yet.
405 attr_name = child.tag.replace('-','_')
406 self.explicit_attrs.append(attr_name)
407 setattr(self, attr_name, text)
408 elif verbose == True:
409 print >> sys.stderr, 'Ignoring unknown tag %s in %s' \
410 % (child.tag, comment.tag)
411 if self.alt_id == None:
412 self.explicit_attrs.append('alt_id')
415 if self.content_type.startswith('text/'):
416 self.body = body+'\n' # restore trailing newline
418 self.body = base64.decodestring(body)
419 self.extra_strings = estrs
421 def merge(self, other, allow_changes=True):
423 Merge info from other into this comment. Overrides any
424 attributes in self that are listed in other.explicit_attrs.
425 >>> commA = Comment(bug=None, body='Some insightful remarks')
426 >>> commA.uuid = '0123'
427 >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
428 >>> commA.author = 'Frank'
429 >>> commA.extra_strings += ['TAG: very helpful']
430 >>> commA.extra_strings += ['TAG: favorite']
431 >>> commB = Comment(bug=None, body='More insightful remarks')
432 >>> commB.uuid = '3210'
433 >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000'
434 >>> commB.author = 'John'
435 >>> commB.explicit_attrs = ['author', 'body']
436 >>> commB.extra_strings += ['TAG: very helpful']
437 >>> commB.extra_strings += ['TAG: useful']
438 >>> commA.merge(commB, allow_changes=False)
439 Traceback (most recent call last):
441 ValueError: Merge would change author "Frank"->"John" for comment 0123
442 >>> commA.merge(commB)
443 >>> print commA.xml()
446 <short-name>0123</short-name>
447 <author>John</author>
448 <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
449 <content-type>text/plain</content-type>
450 <body>More insightful remarks</body>
451 <extra-string>TAG: favorite</extra-string>
452 <extra-string>TAG: useful</extra-string>
453 <extra-string>TAG: very helpful</extra-string>
456 for attr in other.explicit_attrs:
457 old = getattr(self, attr)
458 new = getattr(other, attr)
460 if allow_changes == True:
461 setattr(self, attr, new)
464 'Merge would change %s "%s"->"%s" for comment %s' \
465 % (attr, old, new, self.uuid)
466 if allow_changes == False and len(other.extra_strings) > 0:
468 'Merge would change extra_strings for comment %s' % self.uuid
469 for estr in other.extra_strings:
470 if not estr in self.extra_strings:
471 self.extra_strings.append(estr)
473 def string(self, indent=0, shortname=None):
475 >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
476 >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
477 >>> print comm.string(indent=2, shortname="com-1")
478 --------- Comment ---------
481 Date: Thu, 01 Jan 1970 00:00:00 +0000
487 if shortname == None:
488 shortname = self.uuid
490 lines.append("--------- Comment ---------")
491 lines.append("Name: %s" % shortname)
492 lines.append("From: %s" % (self._setting_attr_string("author")))
493 lines.append("Date: %s" % self.date)
495 if self.content_type.startswith("text/"):
496 lines.extend((self.body or "").splitlines())
498 lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
502 return istring + sep.join(lines).rstrip('\n')
504 def string_thread(self, string_method_name="string", name_map={},
505 indent=0, flatten=True,
506 auto_name_map=False, bug_shortname=None):
508 Return a string displaying a thread of comments.
509 bug_shortname is only used if auto_name_map == True.
511 string_method_name (defaults to "string") is the name of the
512 Comment method used to generate the output string for each
513 Comment in the thread. The method must take the arguments
514 indent and shortname.
516 SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
517 which will sort the tree by comment.time. Avoid by calling
519 for shortname,comment in comm.comment_shortnames(bug_shortname):
520 name_map[comment.uuid] = shortname
521 comm.sort(key=lambda c : c.author) # your sort
522 comm.string_thread(name_map=name_map)
524 >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
525 >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
526 >>> b = a.new_reply("Critique original comment")
528 >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
529 >>> c = b.new_reply("Begin flamewar :p")
531 >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
532 >>> d = a.new_reply("Useful examples")
534 >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
535 >>> a.sort(key=lambda comm : comm.time)
536 >>> print a.string_thread(flatten=True)
537 --------- Comment ---------
540 Date: Thu, 20 Nov 2008 01:00:00 +0000
543 --------- Comment ---------
546 Date: Thu, 20 Nov 2008 02:00:00 +0000
548 Critique original comment
549 --------- Comment ---------
552 Date: Thu, 20 Nov 2008 03:00:00 +0000
555 --------- Comment ---------
558 Date: Thu, 20 Nov 2008 04:00:00 +0000
561 >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
562 --------- Comment ---------
565 Date: Thu, 20 Nov 2008 01:00:00 +0000
568 --------- Comment ---------
571 Date: Thu, 20 Nov 2008 02:00:00 +0000
573 Critique original comment
574 --------- Comment ---------
577 Date: Thu, 20 Nov 2008 03:00:00 +0000
580 --------- Comment ---------
583 Date: Thu, 20 Nov 2008 04:00:00 +0000
587 if auto_name_map == True:
589 for shortname,comment in self.comment_shortnames(bug_shortname):
590 name_map[comment.uuid] = shortname
592 for depth,comment in self.thread(flatten=flatten):
594 if comment.uuid in name_map:
595 sname = name_map[comment.uuid]
598 string_fn = getattr(comment, string_method_name)
599 stringlist.append(string_fn(indent=ind, shortname=sname))
600 return '\n'.join(stringlist)
602 def xml_thread(self, name_map={}, indent=0,
603 auto_name_map=False, bug_shortname=None):
604 return self.string_thread(string_method_name="xml", name_map=name_map,
605 indent=indent, auto_name_map=auto_name_map,
606 bug_shortname=bug_shortname)
608 # methods for saving/loading/acessing settings and properties.
610 def get_path(self, *args):
611 dir = os.path.join(self.bug.get_path("comments"), self.uuid)
614 assert args[0] in ["values", "body"], str(args)
615 return os.path.join(dir, *args)
617 def set_sync_with_disk(self, value):
618 self.sync_with_disk = value
620 def load_settings(self):
621 if self.sync_with_disk == False:
622 raise DiskAccessRequired("load settings")
623 self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
624 self._setup_saved_settings()
626 def save_settings(self):
627 if self.sync_with_disk == False:
628 raise DiskAccessRequired("save settings")
629 self.vcs.mkdir(self.get_path())
630 path = self.get_path("values")
631 mapfile.map_save(self.vcs, path, self._get_saved_settings())
635 Save any loaded contents to disk.
637 However, if self.sync_with_disk = True, then any changes are
638 automatically written to disk as soon as they happen, so
639 calling this method will just waste time (unless something
640 else has been messing with your on-disk files).
642 sync_with_disk = self.sync_with_disk
643 if sync_with_disk == False:
644 self.set_sync_with_disk(True)
645 assert self.body != None, "Can't save blank comment"
647 self._set_comment_body(new=self.body, force=True)
648 if sync_with_disk == False:
649 self.set_sync_with_disk(False)
652 if self.sync_with_disk == False and self.uuid != INVALID_UUID:
653 raise DiskAccessRequired("remove")
654 for comment in self.traverse():
655 path = comment.get_path()
656 self.vcs.recursive_remove(path)
658 def add_reply(self, reply, allow_time_inversion=False):
659 if self.uuid != INVALID_UUID:
660 reply.in_reply_to = self.uuid
663 def new_reply(self, body=None, content_type=None):
665 >>> comm = Comment(bug=None, body="Some insightful remarks")
666 >>> repA = comm.new_reply("Critique original comment")
667 >>> repB = repA.new_reply("Begin flamewar :p")
668 >>> repB.in_reply_to == repA.uuid
671 reply = Comment(self.bug, body=body)
672 if content_type != None: # set before saving body to decide binary format
673 reply.content_type = content_type
675 reply.set_sync_with_disk(self.bug.sync_with_disk)
676 if reply.sync_with_disk == True:
678 self.add_reply(reply)
681 def comment_shortnames(self, bug_shortname=None):
683 Iterate through (id, comment) pairs, in time order.
684 (This is a user-friendly id, not the comment uuid).
686 SIDE-EFFECT : will sort the comment tree by comment.time
688 >>> a = Comment(bug=None, uuid="a")
689 >>> b = a.new_reply()
691 >>> c = b.new_reply()
693 >>> d = a.new_reply()
695 >>> for id,name in a.comment_shortnames("bug-1"):
696 ... print id, name.uuid
701 >>> for id,name in a.comment_shortnames():
702 ... print id, name.uuid
708 if bug_shortname == None:
710 self.sort(key=lambda comm : comm.time)
711 for num,comment in enumerate(self.traverse()):
712 yield ("%s:%d" % (bug_shortname, num+1), comment)
714 def comment_from_shortname(self, comment_shortname, *args, **kwargs):
716 Use a comment shortname to look up a comment.
717 >>> a = Comment(bug=None, uuid="a")
718 >>> b = a.new_reply()
720 >>> c = b.new_reply()
722 >>> d = a.new_reply()
724 >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
725 >>> id(comm) == id(c)
728 for cur_name, comment in self.comment_shortnames(*args, **kwargs):
729 if comment_shortname == cur_name:
731 raise InvalidShortname(comment_shortname,
732 list(self.comment_shortnames(*args, **kwargs)))
734 def comment_from_uuid(self, uuid, match_alt_id=True):
736 Use a comment shortname to look up a comment.
737 >>> a = Comment(bug=None, uuid="a")
738 >>> b = a.new_reply()
740 >>> c = b.new_reply()
742 >>> d = a.new_reply()
744 >>> d.alt_id = "d-alt"
745 >>> comm = a.comment_from_uuid("d")
746 >>> id(comm) == id(d)
748 >>> comm = a.comment_from_uuid("d-alt")
749 >>> id(comm) == id(d)
751 >>> comm = a.comment_from_uuid(None, match_alt_id=False)
752 Traceback (most recent call last):
756 for comment in self.traverse():
757 if comment.uuid == uuid:
759 if match_alt_id == True and uuid != None \
760 and comment.alt_id == uuid:
764 def cmp_attr(comment_1, comment_2, attr, invert=False):
766 Compare a general attribute between two comments using the conventional
767 comparison rule for that attribute type. If invert == True, sort
768 *against* that convention.
770 >>> commentA = Comment()
771 >>> commentB = Comment()
772 >>> commentA.author = "John Doe"
773 >>> commentB.author = "Jane Doe"
774 >>> cmp_attr(commentA, commentB, attr) > 0
776 >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
778 >>> commentB.author = "John Doe"
779 >>> cmp_attr(commentA, commentB, attr) == 0
782 if not hasattr(comment_2, attr) :
784 val_1 = getattr(comment_1, attr)
785 val_2 = getattr(comment_2, attr)
786 if val_1 == None: val_1 = None
787 if val_2 == None: val_2 = None
790 return -cmp(val_1, val_2)
792 return cmp(val_1, val_2)
794 # alphabetical rankings (a < z)
795 cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
796 cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
797 cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
798 cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
799 cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
800 cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings")
801 # chronological rankings (newer < older)
802 cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
805 DEFAULT_CMP_FULL_CMP_LIST = \
806 (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
807 cmp_uuid, cmp_extra_strings)
809 class CommentCompoundComparator (object):
810 def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
811 self.cmp_list = cmp_list
812 def __call__(self, comment_1, comment_2):
813 for comparison in self.cmp_list :
814 val = comparison(comment_1, comment_2)
819 cmp_full = CommentCompoundComparator()
821 suite = doctest.DocTestSuite()