Added cmp functions to libbe.comment, and fleshed them out in libbe.bug.
[be.git] / libbe / comment.py
index e2f4ba7f936ef06f818c7c013a7e2ff58814c90d..bf149209305ca0714c0462cc3eae7db9d78152b8 100644 (file)
@@ -61,6 +61,11 @@ class MissingReference(ValueError):
         self.reference = comment.in_reply_to
         self.comment = comment
 
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
 
 def list_to_root(comments, bug, root=None,
@@ -115,6 +120,8 @@ def loadComments(bug, load_full=False):
     Set load_full=True when you want to load the comment completely
     from disk *now*, rather than waiting and lazy loading as required.
     """
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("load comments")
     path = bug.get_path("comments")
     if not os.path.isdir(path):
         return Comment(bug, uuid=INVALID_UUID)
@@ -123,6 +130,7 @@ def loadComments(bug, load_full=False):
         if uuid.startswith('.'):
             continue
         comm = Comment(bug, uuid, from_disk=True)
+        comm.set_sync_with_disk(bug.sync_with_disk)
         if load_full == True:
             comm.load_settings()
             dummy = comm.body # force the body to load
@@ -130,8 +138,8 @@ def loadComments(bug, load_full=False):
     return list_to_root(comments, bug)
 
 def saveComments(bug):
-    path = bug.get_path("comments")
-    bug.rcs.mkdir(path)
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("save comments")
     for comment in bug.comment_root.traverse():
         comment.save()
 
@@ -163,9 +171,9 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                          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.")
     def alt_id(): return {}
 
-    @_versioned_property(name="From",
+    @_versioned_property(name="Author",
                          doc="The author of the comment")
-    def From(): return {}
+    def author(): return {}
 
     @_versioned_property(name="In-reply-to",
                          doc="UUID for parent comment or bug")
@@ -179,17 +187,17 @@ class Comment(Tree, settings_object.SavedSettingsObject):
 
     @_versioned_property(name="Date",
                          doc="An RFC 2822 timestamp for comment creation")
-    def time_string(): return {}
+    def date(): return {}
 
     def _get_time(self):
-        if self.time_string == None:
+        if self.date == None:
             return None
-        return utility.str_to_time(self.time_string)
+        return utility.str_to_time(self.date)
     def _set_time(self, value):
-        self.time_string = utility.time_to_str(value)
+        self.date = utility.time_to_str(value)
     time = property(fget=_get_time,
                     fset=_set_time,
-                    doc="An integer version of .time_string")
+                    doc="An integer version of .date")
 
     def _get_comment_body(self):
         if self.rcs != None and self.sync_with_disk == True:
@@ -219,6 +227,20 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     @doc_property(doc="A revision control system instance.")
     def rcs(): return {}
 
+    def _extra_strings_check_fn(value):
+        return utility.iterable_full_of_strings(value, \
+                         alternative=settings_object.EMPTY)
+    def _extra_strings_change_hook(self, old, new):
+        self.extra_strings.sort() # to make merging easier
+        self._prop_save_settings(old, new)
+    @_versioned_property(name="extra_strings",
+                         doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+                         default=[],
+                         check_fn=_extra_strings_check_fn,
+                         change_hook=_extra_strings_change_hook,
+                         mutable=True)
+    def extra_strings(): return {}
+
     def __init__(self, bug=None, uuid=None, from_disk=False,
                  in_reply_to=None, body=None):
         """
@@ -245,10 +267,29 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 self.uuid = uuid_gen()
             self.time = int(time.time()) # only save to second precision
             if self.rcs != None:
-                self.From = self.rcs.get_user_id()
+                self.author = self.rcs.get_user_id()
             self.in_reply_to = in_reply_to
             self.body = body
 
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    def __str__(self):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> comm.uuid = "com-1"
+        >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+        >>> comm.author = "Jane Doe <jdoe@example.com>"
+        >>> print comm
+        --------- Comment ---------
+        Name: com-1
+        From: Jane Doe <jdoe@example.com>
+        Date: Thu, 20 Nov 2008 15:55:11 +0000
+        <BLANKLINE>
+        Some insightful remarks
+        """
+        return self.string()
+
     def traverse(self, *args, **kwargs):
         """Avoid working with the possible dummy root comment"""
         for comment in Tree.traverse(self, *args, **kwargs):
@@ -256,23 +297,24 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 continue
             yield comment
 
+    # serializing methods
+
     def _setting_attr_string(self, setting):
         value = getattr(self, setting)
-        if value in [None, settings_object.EMPTY]:
+        if value == None:
             return ""
-        else:
-            return str(value)
+        return str(value)
 
     def xml(self, indent=0, shortname=None):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> comm.uuid = "0123"
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.xml(indent=2, shortname="com-1")
           <comment>
             <uuid>0123</uuid>
             <short-name>com-1</short-name>
-            <from></from>
+            <author></author>
             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
             <content-type>text/plain</content-type>
             <body>Some
@@ -294,8 +336,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 ("alt-id", self.alt_id),
                 ("short-name", shortname),
                 ("in-reply-to", self.in_reply_to),
-                ("from", self._setting_attr_string("From")),
-                ("date", self.time_string),
+                ("author", self._setting_attr_string("author")),
+                ("date", self.date),
                 ("content-type", self.content_type),
                 ("body", body)]
         lines = ["<comment>"]
@@ -313,11 +355,11 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         <alt-id> fields.
         >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> commA.uuid = "0123"
-        >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> xml = commA.xml(shortname="com-1")
         >>> commB = Comment()
         >>> commB.from_xml(xml)
-        >>> attrs=['uuid','alt_id','in_reply_to','From','time_string','content_type','body']
+        >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
         >>> for attr in attrs: # doctest: +ELLIPSIS
         ...     if getattr(commB, attr) != getattr(commA, attr):
         ...         estr = "Mismatch on %s: '%s' should be '%s'"
@@ -327,15 +369,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         Mismatch on alt_id: '0123' should be 'None'
         >>> print commB.alt_id
         0123
-        >>> commA.From
-        >>> commB.From
+        >>> commA.author
+        >>> commB.author
         """
         if type(xml_string) == types.UnicodeType:
             xml_string = xml_string.strip().encode("unicode_escape")
         comment = ElementTree.XML(xml_string)
         if comment.tag != "comment":
             raise InvalidXML(comment, "root element must be <comment>")
-        tags=['uuid','alt-id','in-reply-to','from','date','content-type','body']
+        tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
         uuid = None
         body = None
         for child in comment.getchildren():
@@ -353,10 +395,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 if child.tag == "body":
                     body = text
                     continue # don't set the bug's body yet.
-                elif child.tag == 'from':
-                    attr_name = "From"
-                elif child.tag == 'date':
-                    attr_name = 'time_string'
                 else:
                     attr_name = child.tag.replace('-','_')
                 setattr(self, attr_name, text)
@@ -374,7 +412,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     def string(self, indent=0, shortname=None):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.string(indent=2, shortname="com-1")
           --------- Comment ---------
           Name: com-1
@@ -390,8 +428,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         lines = []
         lines.append("--------- Comment ---------")
         lines.append("Name: %s" % shortname)
-        lines.append("From: %s" % (self._setting_attr_string("From")))
-        lines.append("Date: %s" % self.time_string)
+        lines.append("From: %s" % (self._setting_attr_string("author")))
+        lines.append("Date: %s" % self.date)
         lines.append("")
         if self.content_type.startswith("text/"):
             lines.extend((self.body or "").splitlines())
@@ -402,68 +440,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         sep = '\n' + istring
         return istring + sep.join(lines).rstrip('\n')
 
-    def __str__(self):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> comm.uuid = "com-1"
-        >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
-        >>> comm.From = "Jane Doe <jdoe@example.com>"
-        >>> print comm
-        --------- Comment ---------
-        Name: com-1
-        From: Jane Doe <jdoe@example.com>
-        Date: Thu, 20 Nov 2008 15:55:11 +0000
-        <BLANKLINE>
-        Some insightful remarks
-        """
-        return self.string()
-
-    def get_path(self, name=None):
-        my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
-        if name is None:
-            return my_dir
-        assert name in ["values", "body"]
-        return os.path.join(my_dir, name)
-
-    def load_settings(self):
-        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
-        self._setup_saved_settings()
-
-    def save_settings(self):
-        parent_dir = os.path.dirname(self.get_path())
-        self.rcs.mkdir(parent_dir)
-        self.rcs.mkdir(self.get_path())
-        path = self.get_path("values")
-        mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
-    def save(self):
-        assert self.body != None, "Can't save blank comment"
-        self.save_settings()
-        self._set_comment_body(new=self.body, force=True)
-
-    def remove(self):
-        for comment in self.traverse():
-            path = comment.get_path()
-            self.rcs.recursive_remove(path)
-
-    def add_reply(self, reply, allow_time_inversion=False):
-        if self.uuid != INVALID_UUID:
-            reply.in_reply_to = self.uuid
-        self.append(reply)
-        #raise Exception, "adding reply \n%s\n%s" % (self, reply)
-
-    def new_reply(self, body=None):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> repA = comm.new_reply("Critique original comment")
-        >>> repB = repA.new_reply("Begin flamewar :p")
-        >>> repB.in_reply_to == repA.uuid
-        True
-        """
-        reply = Comment(self.bug, body=body)
-        self.add_reply(reply)
-        return reply
-
     def string_thread(self, string_method_name="string", name_map={},
                       indent=0, flatten=True,
                       auto_name_map=False, bug_shortname=None):
@@ -481,7 +457,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
           name_map = {}
           for shortname,comment in comm.comment_shortnames(bug_shortname):
               name_map[comment.uuid] = shortname
-          comm.sort(key=lambda c : c.From) # your sort
+          comm.sort(key=lambda c : c.author) # your sort
           comm.string_thread(name_map=name_map)
 
         >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
@@ -568,6 +544,81 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                                   indent=indent, auto_name_map=auto_name_map,
                                   bug_shortname=bug_shortname)
 
+    # methods for saving/loading/acessing settings and properties.
+
+    def get_path(self, name=None):
+        my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+        if name is None:
+            return my_dir
+        assert name in ["values", "body"]
+        return os.path.join(my_dir, name)
+
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+
+    def load_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+        # hack to deal with old BE comments:
+        if "From" in self.settings:
+            self.settings["Author"] = self.settings.pop("From")
+        self._setup_saved_settings()
+
+    def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
+        self.rcs.mkdir(self.get_path())
+        path = self.get_path("values")
+        mapfile.map_save(self.rcs, path, self._get_saved_settings())
+
+    def save(self):
+        """
+        Save any loaded contents to disk.
+        
+        However, if self.sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        assert self.body != None, "Can't save blank comment"
+        self.save_settings()
+        self._set_comment_body(new=self.body, force=True)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def remove(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("remove")
+        for comment in self.traverse():
+            path = comment.get_path()
+            self.rcs.recursive_remove(path)
+
+    def add_reply(self, reply, allow_time_inversion=False):
+        if self.uuid != INVALID_UUID:
+            reply.in_reply_to = self.uuid
+        self.append(reply)
+
+    def new_reply(self, body=None):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> repA = comm.new_reply("Critique original comment")
+        >>> repB = repA.new_reply("Begin flamewar :p")
+        >>> repB.in_reply_to == repA.uuid
+        True
+        """
+        reply = Comment(self.bug, body=body)
+        if self.bug != None:
+            reply.set_sync_with_disk(self.bug.sync_with_disk)
+        if reply.sync_with_disk == True:
+            raise Exception, self.bug.sync_with_disk
+            reply.save()
+        self.add_reply(reply)
+        return reply
+
     def comment_shortnames(self, bug_shortname=None):
         """
         Iterate through (id, comment) pairs, in time order.
@@ -634,4 +685,59 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 return comment
         raise KeyError(uuid)
 
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+    """
+    Compare a general attribute between two comments using the conventional
+    comparison rule for that attribute type.  If invert == True, sort
+    *against* that convention.
+    >>> attr="author"
+    >>> commentA = Comment()
+    >>> commentB = Comment()
+    >>> commentA.author = "John Doe"
+    >>> commentB.author = "Jane Doe"
+    >>> cmp_attr(commentA, commentB, attr) < 0
+    True
+    >>> cmp_attr(commentA, commentB, attr, invert=True) > 0
+    True
+    >>> commentB.author = "John Doe"
+    >>> cmp_attr(commentA, commentB, attr) == 0
+    True
+    """
+    if not hasattr(comment_2, attr) :
+        return 1
+    val_1 = getattr(comment_1, attr)
+    val_2 = getattr(comment_2, attr)
+    if val_1 == None: val_1 = None
+    if val_2 == None: val_2 = None
+    
+    if invert == True :
+        return -cmp(val_1, val_2)
+    else :
+        return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+    (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+     cmp_uuid)
+
+class CommentCompoundComparator (object):
+    def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+        self.cmp_list = cmp_list
+    def __call__(self, comment_1, comment_2):
+        for comparison in self.cmp_list :
+            val = comparison(comment_1, comment_2)
+            if val != 0 :
+                return val
+        return 0
+        
+cmp_full = CommentCompoundComparator()
+
 suite = doctest.DocTestSuite()