]> git.notmuchmail.org Git - notmuch/blobdiff - cnotmuch/database.py
Fix Database().create_query() to actually initialize the Query with the correct object.
[notmuch] / cnotmuch / database.py
index 79f57ea2275f2a12ea7e57f50491068899cbb3a4..dde7da16eecea67fec9e513d27c16588a1abccf8 100644 (file)
@@ -1,5 +1,5 @@
-import ctypes
-from ctypes import c_int, c_char_p, c_void_p, c_uint64
+import ctypes, os
+from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool, byref
 from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum
 import logging
 from datetime import date
@@ -13,16 +13,20 @@ class Database(object):
            as well. Accessing these objects will lead to segfaults and
            other unexpected behavior. See above for more details.
     """
-    MODE = Enum(['READ_ONLY','READ_WRITE'])
-    """Constants: Mode in which to open the database"""
-
     _std_db_path = None
     """Class attribute to cache user's default database"""
 
+    MODE = Enum(['READ_ONLY','READ_WRITE'])
+    """Constants: Mode in which to open the database"""
+
     """notmuch_database_get_path (notmuch_database_t *database)"""
     _get_path = nmlib.notmuch_database_get_path
     _get_path.restype = c_char_p
 
+    """notmuch_database_get_version"""
+    _get_version = nmlib.notmuch_database_get_version
+    _get_version.restype = c_uint
+
     """notmuch_database_open (const char *path, notmuch_database_mode_t mode)"""
     _open = nmlib.notmuch_database_open 
     _open.restype = c_void_p
@@ -39,11 +43,14 @@ class Database(object):
     _create = nmlib.notmuch_database_create
     _create.restype = c_void_p
 
-    def __init__(self, path=None, create=False, mode= MODE.READ_ONLY):
-        """If *path* is *None*, we will try to read a users notmuch
-        configuration and use his default database. If *create* is `True`,
-        the database will always be created in
-        :attr:`MODE`.READ_WRITE mode.
+    def __init__(self, path=None, create=False, mode= 0):
+        """If *path* is *None*, we will try to read a users notmuch 
+        configuration and use his configured database. The location of the 
+        configuration file can be specified through the environment variable
+        *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`.
+
+        If *create* is `True`, the database will always be created in
+        :attr:`MODE`.READ_WRITE mode. Default mode for opening is READ_ONLY.
 
         :param path:   Directory to open/create the database in (see
                        above for behavior if `None`)
@@ -70,6 +77,11 @@ class Database(object):
         else:
             self.create(path)
 
+    def _verify_initialized_db(self):
+        """Raises a NotmuchError in case self._db is still None"""
+        if self._db is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)            
+
     def create(self, path):
         """Creates a new notmuch database
 
@@ -90,14 +102,14 @@ class Database(object):
             raise NotmuchError(
             message="Cannot create db, this Database() already has an open one.")
 
-        res = Database._create(path, MODE.READ_WRITE)
+        res = Database._create(path, Database.MODE.READ_WRITE)
 
         if res is None:
             raise NotmuchError(
                 message="Could not create the specified database")
         self._db = res
 
-    def open(self, path, mode= MODE.READ_ONLY): 
+    def open(self, path, mode= 0): 
         """Opens an existing database
 
         This function is used by __init__() and usually does not need
@@ -122,8 +134,131 @@ class Database(object):
         """Returns the file path of an open database
 
         Wraps notmuch_database_get_path"""
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
         return Database._get_path(self._db)
 
+    def get_version(self):
+        """Returns the database format version
+
+        :returns: The database version as positive integer
+        :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if
+                    the database was not intitialized.
+        """
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
+        return Database._get_version (self._db)
+
+    def needs_upgrade(self):
+        """Does this database need to be upgraded before writing to it?
+
+        If this function returns True then no functions that modify the
+        database (:meth:`add_message`, :meth:`add_tag`,
+        :meth:`Directory.set_mtime`, etc.) will work unless :meth:`upgrade` 
+        is called successfully first.
+
+        :returns: `True` or `False`
+        :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if
+                    the database was not intitialized.
+        """
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
+        return notmuch_database_needs_upgrade(self.db) 
+
+    def add_message(self, filename):
+        """Adds a new message to the database
+
+        `filename` should be a path relative to the path of the open
+        database (see :meth:`get_path`), or else should be an absolute
+        filename with initial components that match the path of the
+        database.
+
+        The file should be a single mail message (not a multi-message mbox)
+        that is expected to remain at its current location, since the
+        notmuch database will reference the filename, and will not copy the
+        entire contents of the file.
+
+        :returns: On success, we return 
+
+           1) a :class:`Message` object that can be used for things
+              such as adding tags to the just-added message.
+           2) one of the following STATUS values:
+
+              STATUS.SUCCESS
+                  Message successfully added to database.
+              STATUS.DUPLICATE_MESSAGE_ID
+                  Message has the same message ID as another message already
+                  in the database. The new filename was successfully added
+                  to the message in the database.
+
+        :rtype:   2-tuple(:class:`Message`, STATUS)
+
+        :exception: Raises a :exc:`NotmuchError` with the following meaning.
+              If such an exception occurs, nothing was added to the database.
+
+              STATUS.FILE_ERROR
+                      An error occurred trying to open the file, (such as 
+                      permission denied, or file not found, etc.).
+              STATUS.FILE_NOT_EMAIL
+                      The contents of filename don't look like an email message.
+              STATUS.READ_ONLY_DATABASE
+                      Database was opened in read-only mode so no message can
+                      be added.
+              STATUS.NOT_INITIALIZED
+                      The database has not been initialized.
+        """
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
+        msg_p = c_void_p()
+        status = nmlib.notmuch_database_add_message(self._db,
+                                                  filename,
+                                                  byref(msg_p))
+        if not status in [STATUS.SUCCESS,STATUS.DUPLICATE_MESSAGE_ID]:
+            raise NotmuchError(status)
+
+        #construct Message() and return
+        msg = Message(msg_p, self)
+        return (msg, status)
+
+    def remove_message(self, filename):
+        """Removes a message from the given notmuch database
+
+        Note that only this particular filename association is removed from
+        the database. If the same message (as determined by the message ID)
+        is still available via other filenames, then the message will
+        persist in the database for those filenames. When the last filename
+        is removed for a particular message, the database content for that
+        message will be entirely removed.
+
+        :returns: A STATUS.* value with the following meaning:
+
+             STATUS.SUCCESS
+               The last filename was removed and the message was removed 
+               from the database.
+             STATUS.DUPLICATE_MESSAGE_ID
+               This filename was removed but the message persists in the 
+               database with at least one other filename.
+
+        :exception: Raises a :exc:`NotmuchError` with the following meaning.
+             If such an exception occurs, nothing was removed from the database.
+
+             STATUS.READ_ONLY_DATABASE
+               Database was opened in read-only mode so no message can be 
+               removed.
+             STATUS.NOT_INITIALIZED
+               The database has not been initialized.
+        """
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
+        status = nmlib.notmuch_database_remove_message(self._db,
+                                                       filename)
+
     def find_message(self, msgid):
         """Returns a :class:`Message` as identified by its message ID
 
@@ -136,8 +271,9 @@ class Database(object):
         :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if
                   the database was not intitialized.
         """
-        if self._db is None:
-            raise NotmuchError(STATUS.NOT_INITIALIZED)
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
         msg_p = Database._find_message(self._db, msgid)
         if msg_p is None:
             return None
@@ -149,14 +285,37 @@ class Database(object):
         :returns: :class:`Tags`
         :execption: :exc:`NotmuchError` with STATUS.NULL_POINTER on error
         """
-        if self._db is None:
-            raise NotmuchError(STATUS.NOT_INITIALIZED)
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
 
         tags_p = Database._get_all_tags (self._db)
         if tags_p == None:
             raise NotmuchError(STATUS.NULL_POINTER)
         return Tags(tags_p, self)
 
+    def create_query(self, querystring):
+        """Returns a :class:`Query` derived from this database
+
+        This is a shorthand method for doing::
+
+          # short version
+          # Automatically frees the Database() when 'q' is deleted
+
+          q  = Database(dbpath).create_query('from:"Biene Maja"')
+
+          # long version, which is functionally equivalent but will keep the
+          # Database in the 'db' variable around after we delete 'q':
+
+          db = Database(dbpath)
+          q  = Query(db,'from:"Biene Maja"')
+
+        This function is a python extension and not in the underlying C API.
+        """
+        # Raise a NotmuchError if not initialized
+        self._verify_initialized_db()
+
+        return Query(self, querystring)
+
     def __repr__(self):
         return "'Notmuch DB " + self.get_path() + "'"
 
@@ -171,9 +330,10 @@ class Database(object):
 
         Throws a NotmuchError if it cannot find it"""
         from ConfigParser import SafeConfigParser
-        import os.path
         config = SafeConfigParser()
-        config.read(os.path.expanduser('~/.notmuch-config'))
+        conf_f = os.getenv('NOTMUCH_CONFIG',
+                           os.path.expanduser('~/.notmuch-config'))
+        config.read(conf_f)
         if not config.has_option('database','path'):
             raise NotmuchError(message=
                                "No DB path specified and no user default found")
@@ -181,14 +341,16 @@ class Database(object):
 
     @property
     def db_p(self):
-        """Property returning a pointer to the notmuch_database_t or `None`
+        """Property returning a pointer to `notmuch_database_t` or `None`
 
-        This should normally not be needed by a user."""
+        This should normally not be needed by a user (and is not yet
+        guaranteed to remain stable in future versions).
+        """
         return self._db
 
 #------------------------------------------------------------------------------
 class Query(object):
-    """ Represents a search query on an opened :class:`Database`.
+    """Represents a search query on an opened :class:`Database`.
 
     A query selects and filters a subset of messages from the notmuch
     database we derive from.
@@ -213,6 +375,11 @@ class Query(object):
     _search_messages = nmlib.notmuch_query_search_messages
     _search_messages.restype = c_void_p
 
+
+    """notmuch_query_count_messages"""
+    _count_messages = nmlib.notmuch_query_count_messages
+    _count_messages.restype = c_uint
+
     def __init__(self, db, querystr):
         """
         :param db: An open database which we derive the Query from.
@@ -225,7 +392,7 @@ class Query(object):
         self.create(db, querystr)
 
     def create(self, db, querystr):
-        """Creates a new query derived from a Database.
+        """Creates a new query derived from a Database
 
         This function is utilized by __init__() and usually does not need to 
         be called directly.
@@ -290,6 +457,25 @@ class Query(object):
 
         return Messages(msgs_p,self)
 
+    def count_messages(self):
+        """Estimate the number of messages matching the query
+
+        This function performs a search and returns Xapian's best
+        guess as to the number of matching messages. It is much faster
+        than performing :meth:`search_messages` and counting the
+        result with `len()` (although it always returned the same
+        result in my tests). Technically, it wraps the underlying
+        *notmuch_query_count_messages* function.
+
+        :returns: :class:`Messages`
+        :exception: :exc:`NotmuchError`
+
+                      * STATUS.NOT_INITIALIZED if query is not inited
+        """
+        if self._query is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)            
+
+        return Query._count_messages(self._query)
 
     def __del__(self):
         """Close and free the Query"""
@@ -528,7 +714,13 @@ class Messages(object):
         """len(:class:`Messages`) returns the number of contained messages
 
         .. note:: As this iterates over the messages, we will not be able to 
-               iterate over them again (as in retrieve them)!
+               iterate over them again! So this will fail::
+
+                 #THIS FAILS
+                 msgs = Database().create_query('').search_message()
+                 if len(msgs) > 0:              #this 'exhausts' msgs
+                     # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
+                     for msg in msgs: print msg
         """
         if self._msgs is None:
             raise NotmuchError(STATUS.NOT_INITIALIZED)
@@ -559,10 +751,19 @@ class Message(object):
     """notmuch_message_get_filename (notmuch_message_t *message)"""
     _get_filename = nmlib.notmuch_message_get_filename
     _get_filename.restype = c_char_p 
+
     """notmuch_message_get_message_id (notmuch_message_t *message)"""
     _get_message_id = nmlib.notmuch_message_get_message_id
     _get_message_id.restype = c_char_p 
 
+    """notmuch_message_get_thread_id"""
+    _get_thread_id = nmlib.notmuch_message_get_thread_id
+    _get_thread_id.restype = c_char_p
+
+    """notmuch_message_get_replies"""
+    _get_replies = nmlib.notmuch_message_get_replies
+    _get_replies.restype = c_void_p
+
     """notmuch_message_get_tags (notmuch_message_t *message)"""
     _get_tags = nmlib.notmuch_message_get_tags
     _get_tags.restype = c_void_p
@@ -592,7 +793,7 @@ class Message(object):
 
 
     def get_message_id(self):
-        """Return the message ID
+        """Returns the message ID
         
         :returns: String with a message ID
         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
@@ -602,6 +803,51 @@ class Message(object):
             raise NotmuchError(STATUS.NOT_INITIALIZED)
         return Message._get_message_id(self._msg)
 
+    def get_thread_id(self):
+        """Returns the thread ID
+
+        The returned string belongs to 'message' will only be valid for as 
+        long as the message is valid.
+
+        This function will not return None since Notmuch ensures that every
+        message belongs to a single thread.
+
+        :returns: String with a thread ID
+        :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
+                    is not initialized.
+        """
+        if self._msg is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)
+
+        return Message._get_thread_id (self._msg);
+
+    def get_replies(self):
+        """Gets all direct replies to this message as :class:`Messages` iterator
+
+        .. note:: This call only makes sense if 'message' was
+          ultimately obtained from a :class:`Thread` object, (such as
+          by coming directly from the result of calling
+          :meth:`Thread.get_toplevel_messages` or by any number of
+          subsequent calls to :meth:`get_replies`). If this message was
+          obtained through some non-thread means, (such as by a call
+          to :meth:`Query.search_messages`), then this function will
+          return `None`.
+
+        :returns: :class:`Messages` or `None` if there are no replies to 
+            this message.
+        :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
+                    is not initialized.
+        """
+        if self._msg is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)
+
+        msgs_p = Message._get_replies(self._msg);
+
+        if msgs_p is None:
+            return None
+
+        return Messages(msgs_p,self)
+
     def get_date(self):
         """Returns time_t of the message date
 
@@ -688,15 +934,12 @@ class Message(object):
 
                   STATUS.NULL_POINTER
                     The 'tag' argument is NULL
-
                   STATUS.TAG_TOO_LONG
                     The length of 'tag' is too long 
                     (exceeds Message.NOTMUCH_TAG_MAX)
-
                   STATUS.READ_ONLY_DATABASE
                     Database was opened in read-only mode so message cannot be 
                     modified.
-
                   STATUS.NOT_INITIALIZED
                      The message has not been initialized.
        """
@@ -714,22 +957,25 @@ class Message(object):
     def remove_tag(self, tag):
         """Removes a tag from the given message
 
+        If the message has no such tag, this is a non-operation and
+        will report success anyway.
+
         :param tag: String with a 'tag' to be removed.
-        :returns: STATUS.SUCCESS if the tag was successfully removed.
+        :returns: STATUS.SUCCESS if the tag was successfully removed or if 
+                  the message had no such tag.
                   Raises an exception otherwise.
         :exception: :exc:`NotmuchError`. They have the following meaning:
 
                    STATUS.NULL_POINTER
                      The 'tag' argument is NULL
-                   NOTMUCH_STATUS_TAG_TOO_LONG
+                   STATUS.TAG_TOO_LONG
                      The length of 'tag' is too long
                      (exceeds NOTMUCH_TAG_MAX)
-                   NOTMUCH_STATUS_READ_ONLY_DATABASE
+                   STATUS.READ_ONLY_DATABASE
                      Database was opened in read-only mode so message cannot 
                      be modified.
                    STATUS.NOT_INITIALIZED
                      The message has not been initialized.
-
         """
         if self._msg is None:
             raise NotmuchError(STATUS.NOT_INITIALIZED)
@@ -742,13 +988,129 @@ class Message(object):
 
         raise NotmuchError(status)
 
+    def remove_all_tags(self):
+        """Removes all tags from the given message.
+
+        See :meth:`freeze` for an example showing how to safely
+        replace tag values.
+
+        :returns: STATUS.SUCCESS if the tags were successfully removed.
+                  Raises an exception otherwise.
+        :exception: :exc:`NotmuchError`. They have the following meaning:
+
+                   STATUS.READ_ONLY_DATABASE
+                     Database was opened in read-only mode so message cannot 
+                     be modified.
+                   STATUS.NOT_INITIALIZED
+                     The message has not been initialized.
+        """
+        if self._msg is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)
+        status = nmlib.notmuch_message_remove_all_tags(self._msg)
+
+        if STATUS.SUCCESS == status:
+            # return on success
+            return status
+
+        raise NotmuchError(status)
+
+    def freeze(self):
+        """Freezes the current state of 'message' within the database
+
+        This means that changes to the message state, (via :meth:`add_tag`, 
+        :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be 
+        committed to the database until the message is :meth:`thaw`ed.
+
+        Multiple calls to freeze/thaw are valid and these calls will
+        "stack". That is there must be as many calls to thaw as to freeze
+        before a message is actually thawed.
+
+        The ability to do freeze/thaw allows for safe transactions to
+        change tag values. For example, explicitly setting a message to
+        have a given set of tags might look like this::
+
+          msg.freeze()
+          msg.remove_all_tags()
+          for tag in new_tags:
+              msg.add_tag(tag)
+          msg.thaw()
+
+        With freeze/thaw used like this, the message in the database is
+        guaranteed to have either the full set of original tag values, or
+        the full set of new tag values, but nothing in between.
+
+        Imagine the example above without freeze/thaw and the operation
+        somehow getting interrupted. This could result in the message being
+        left with no tags if the interruption happened after
+        :meth:`remove_all_tags` but before :meth:`add_tag`.
+
+        :returns: STATUS.SUCCESS if the message was successfully frozen.
+                  Raises an exception otherwise.
+        :exception: :exc:`NotmuchError`. They have the following meaning:
+
+                   STATUS.READ_ONLY_DATABASE
+                     Database was opened in read-only mode so message cannot 
+                     be modified.
+                   STATUS.NOT_INITIALIZED
+                     The message has not been initialized.
+        """
+        if self._msg is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)
+        status = nmlib.notmuch_message_freeze(self._msg)
+
+        if STATUS.SUCCESS == status:
+            # return on success
+            return status
+
+        raise NotmuchError(status)
+
+    def thaw(self):
+        """Thaws the current 'message'
+
+        Thaw the current 'message', synchronizing any changes that may have 
+        occurred while 'message' was frozen into the notmuch database.
+
+        See :meth:`freeze` for an example of how to use this
+        function to safely provide tag changes.
+
+        Multiple calls to freeze/thaw are valid and these calls with
+        "stack". That is there must be as many calls to thaw as to freeze
+        before a message is actually thawed.
+
+        :returns: STATUS.SUCCESS if the message was successfully frozen.
+                  Raises an exception otherwise.
+        :exception: :exc:`NotmuchError`. They have the following meaning:
+
+                   STATUS.UNBALANCED_FREEZE_THAW
+                     An attempt was made to thaw an unfrozen message. 
+                     That is, there have been an unbalanced number of calls 
+                     to :meth:`freeze` and :meth:`thaw`.
+                   STATUS.NOT_INITIALIZED
+                     The message has not been initialized.
+        """
+        if self._msg is None:
+            raise NotmuchError(STATUS.NOT_INITIALIZED)
+        status = nmlib.notmuch_message_thaw(self._msg)
+
+        if STATUS.SUCCESS == status:
+            # return on success
+            return status
+
+        raise NotmuchError(status)
+
+    
     def __str__(self):
         """A message() is represented by a 1-line summary"""
         msg = {}
         msg['from'] = self.get_header('from')
         msg['tags'] = str(self.get_tags())
         msg['date'] = date.fromtimestamp(self.get_date())
-        return "%(from)s (%(date)s) (%(tags)s)" % (msg)
+        replies = self.get_replies()
+        msg['replies'] = len(replies) if replies is not None else -1
+        return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
 
     def format_as_text(self):
         """Output like notmuch show (Not implemented)"""