diff options
Diffstat (limited to 'bindings/python/notmuch')
-rw-r--r-- | bindings/python/notmuch/__init__.py | 19 | ||||
-rw-r--r-- | bindings/python/notmuch/compat.py | 67 | ||||
-rw-r--r-- | bindings/python/notmuch/database.py | 638 | ||||
-rw-r--r-- | bindings/python/notmuch/directory.py | 185 | ||||
-rw-r--r-- | bindings/python/notmuch/errors.py | 183 | ||||
-rw-r--r-- | bindings/python/notmuch/filename.py | 121 | ||||
-rw-r--r-- | bindings/python/notmuch/filenames.py | 150 | ||||
-rw-r--r-- | bindings/python/notmuch/globals.py | 189 | ||||
-rw-r--r-- | bindings/python/notmuch/message.py | 566 | ||||
-rw-r--r-- | bindings/python/notmuch/messages.py | 282 | ||||
-rw-r--r-- | bindings/python/notmuch/query.py | 207 | ||||
-rw-r--r-- | bindings/python/notmuch/tag.py | 52 | ||||
-rw-r--r-- | bindings/python/notmuch/thread.py | 252 | ||||
-rw-r--r-- | bindings/python/notmuch/threads.py | 177 | ||||
-rw-r--r-- | bindings/python/notmuch/version.py | 2 |
15 files changed, 1591 insertions, 1499 deletions
diff --git a/bindings/python/notmuch/__init__.py b/bindings/python/notmuch/__init__.py index f3ff987..5561624 100644 --- a/bindings/python/notmuch/__init__.py +++ b/bindings/python/notmuch/__init__.py @@ -51,12 +51,17 @@ along with notmuch. If not, see <http://www.gnu.org/licenses/>. Copyright 2010-2011 Sebastian Spaeth <Sebastian@SSpaeth.de> """ -from notmuch.database import Database, Query -from notmuch.message import Messages, Message -from notmuch.thread import Threads, Thread -from notmuch.tag import Tags -from notmuch.globals import ( - nmlib, +from .database import Database +from .directory import Directory +from .filenames import Filenames +from .message import Message +from .messages import Messages +from .query import Query +from .tag import Tags +from .thread import Thread +from .threads import Threads +from .globals import nmlib +from .errors import ( STATUS, NotmuchError, OutOfMemoryError, @@ -71,6 +76,6 @@ from notmuch.globals import ( UnbalancedAtomicError, NotInitializedError, ) -from notmuch.version import __VERSION__ +from .version import __VERSION__ __LICENSE__ = "GPL v3+" __AUTHOR__ = 'Sebastian Spaeth <Sebastian@SSpaeth.de>' diff --git a/bindings/python/notmuch/compat.py b/bindings/python/notmuch/compat.py new file mode 100644 index 0000000..adc8d24 --- /dev/null +++ b/bindings/python/notmuch/compat.py @@ -0,0 +1,67 @@ +''' +This file is part of notmuch. + +This module handles differences between python2.x and python3.x and +allows the notmuch bindings to support both version families with one +source tree. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +Copyright 2012 Justus Winter <4winter@informatik.uni-hamburg.de> +''' + +import sys + +if sys.version_info[0] == 2: + from ConfigParser import SafeConfigParser + + class Python3StringMixIn(object): + def __str__(self): + return unicode(self).encode('utf-8') + + def encode_utf8(value): + ''' + Ensure a nicely utf-8 encoded string to pass to wrapped + libnotmuch functions. + + C++ code expects strings to be well formatted and unicode + strings to have no null bytes. + ''' + if not isinstance(value, basestring): + raise TypeError('Expected str or unicode, got %s' % type(value)) + + if isinstance(value, unicode): + return value.encode('utf-8', 'replace') + + return value +else: + from configparser import SafeConfigParser + + class Python3StringMixIn(object): + def __str__(self): + return self.__unicode__() + + def encode_utf8(value): + ''' + Ensure a nicely utf-8 encoded string to pass to wrapped + libnotmuch functions. + + C++ code expects strings to be well formatted and unicode + strings to have no null bytes. + ''' + if not isinstance(value, str): + raise TypeError('Expected str, got %s' % type(value)) + + return value.encode('utf-8', 'replace') diff --git a/bindings/python/notmuch/database.py b/bindings/python/notmuch/database.py index 24da8e9..5931f41 100644 --- a/bindings/python/notmuch/database.py +++ b/bindings/python/notmuch/database.py @@ -14,19 +14,34 @@ for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see <http://www.gnu.org/licenses/>. -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> """ import os -from ctypes import c_char_p, c_void_p, c_uint, c_long, byref, POINTER -from notmuch.globals import (nmlib, STATUS, NotmuchError, NotInitializedError, - NullPointerError, Enum, _str, - NotmuchDatabaseP, NotmuchDirectoryP, NotmuchMessageP, NotmuchTagsP, - NotmuchQueryP, NotmuchMessagesP, NotmuchThreadsP, NotmuchFilenamesP) -from notmuch.thread import Threads -from notmuch.message import Messages, Message -from notmuch.tag import Tags - +import codecs +from ctypes import c_char_p, c_void_p, c_uint, byref, POINTER +from .compat import SafeConfigParser +from .globals import ( + nmlib, + Enum, + _str, + NotmuchDatabaseP, + NotmuchDirectoryP, + NotmuchMessageP, + NotmuchTagsP, +) +from .errors import ( + STATUS, + FileError, + NotmuchError, + NullPointerError, + NotInitializedError, + ReadOnlyDatabaseError, +) +from .message import Message +from .tag import Tags +from .query import Query +from .directory import Directory class Database(object): """The :class:`Database` is the highest-level object that notmuch @@ -40,19 +55,16 @@ class Database(object): :exc:`XapianError` as the underlying database has been modified. Close and reopen the database to continue working with it. + :class:`Database` objects implement the context manager protocol + so you can use the :keyword:`with` statement to ensure that the + database is properly closed. See :meth:`close` for more + information. + .. note:: Any function in this class can and will throw an :exc:`NotInitializedError` if the database was not intitialized properly. - - .. note:: - - Do remember that as soon as we tear down (e.g. via `del db`) this - object, all underlying derived objects such as queries, threads, - messages, tags etc will be freed by the underlying library as well. - Accessing these objects will lead to segfaults and other unexpected - behavior. See above for more details. """ _std_db_path = None """Class attribute to cache user's default database""" @@ -62,8 +74,8 @@ class Database(object): """notmuch_database_get_directory""" _get_directory = nmlib.notmuch_database_get_directory - _get_directory.argtypes = [NotmuchDatabaseP, c_char_p] - _get_directory.restype = NotmuchDirectoryP + _get_directory.argtypes = [NotmuchDatabaseP, c_char_p, POINTER(NotmuchDirectoryP)] + _get_directory.restype = c_uint """notmuch_database_get_path""" _get_path = nmlib.notmuch_database_get_path @@ -77,8 +89,8 @@ class Database(object): """notmuch_database_open""" _open = nmlib.notmuch_database_open - _open.argtypes = [c_char_p, c_uint] - _open.restype = NotmuchDatabaseP + _open.argtypes = [c_char_p, c_uint, POINTER(NotmuchDatabaseP)] + _open.restype = c_uint """notmuch_database_upgrade""" _upgrade = nmlib.notmuch_database_upgrade @@ -104,10 +116,11 @@ class Database(object): """notmuch_database_create""" _create = nmlib.notmuch_database_create - _create.argtypes = [c_char_p] - _create.restype = NotmuchDatabaseP + _create.argtypes = [c_char_p, POINTER(NotmuchDatabaseP)] + _create.restype = c_uint - def __init__(self, path=None, create=False, mode=0): + 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 configured database. The location of the configuration file can be specified through the environment variable @@ -125,10 +138,11 @@ class Database(object): :param mode: Mode to open a database in. Is always :attr:`MODE`.READ_WRITE when creating a new one. :type mode: :attr:`MODE` - :exception: :exc:`NotmuchError` or derived exception in case of + :raises: :exc:`NotmuchError` or derived exception in case of failure. """ self._db = None + self.mode = mode if path is None: # no path specified. use a user's default database if Database._std_db_path is None: @@ -141,9 +155,17 @@ class Database(object): else: self.create(path) + _destroy = nmlib.notmuch_database_destroy + _destroy.argtypes = [NotmuchDatabaseP] + _destroy.restype = None + + def __del__(self): + if self._db: + self._destroy(self._db) + def _assert_db_is_initialized(self): """Raises :exc:`NotInitializedError` if self._db is `None`""" - if self._db is None: + if not self._db: raise NotInitializedError() def create(self, path): @@ -158,20 +180,20 @@ class Database(object): :param path: A directory in which we should create the database. :type path: str - :returns: Nothing - :exception: :exc:`NotmuchError` in case of any failure + :raises: :exc:`NotmuchError` in case of any failure (possibly after printing an error message on stderr). """ - if self._db is not None: + if self._db: raise NotmuchError(message="Cannot create db, this Database() " "already has an open one.") - res = Database._create(_str(path), Database.MODE.READ_WRITE) + db = NotmuchDatabaseP() + status = Database._create(_str(path), Database.MODE.READ_WRITE, byref(db)) - if res is None: - raise NotmuchError( - message="Could not create the specified database") - self._db = res + if status != STATUS.SUCCESS: + raise NotmuchError(status) + self._db = db + return status def open(self, path, mode=0): """Opens an existing database @@ -182,15 +204,46 @@ class Database(object): :param status: Open the database in read-only or read-write mode :type status: :attr:`MODE` - :returns: Nothing - :exception: Raises :exc:`NotmuchError` in case of any failure + :raises: Raises :exc:`NotmuchError` in case of any failure (possibly after printing an error message on stderr). """ - res = Database._open(_str(path), mode) + db = NotmuchDatabaseP() + status = Database._open(_str(path), mode, byref(db)) - if res is None: - raise NotmuchError(message="Could not open the specified database") - self._db = res + if status != STATUS.SUCCESS: + raise NotmuchError(status) + self._db = db + return status + + _close = nmlib.notmuch_database_close + _close.argtypes = [NotmuchDatabaseP] + _close.restype = None + + def close(self): + ''' + Closes the notmuch database. + + .. warning:: + + This function closes the notmuch database. From that point + on every method invoked on any object ever derived from + the closed database may cease to function and raise a + NotmuchError. + ''' + if self._db: + self._close(self._db) + + def __enter__(self): + ''' + Implements the context manager protocol. + ''' + return self + + def __exit__(self, exc_type, exc_value, traceback): + ''' + Implements the context manager protocol. + ''' + self.close() def get_path(self): """Returns the file path of an open database""" @@ -256,7 +309,7 @@ class Database(object): neither begin nor end necessarily flush modifications to disk. :returns: :attr:`STATUS`.SUCCESS or raises - :exception: :exc:`NotmuchError`: :attr:`STATUS`.XAPIAN_EXCEPTION + :raises: :exc:`NotmuchError`: :attr:`STATUS`.XAPIAN_EXCEPTION Xapian exception occurred; atomic section not entered. *Added in notmuch 0.9*""" @@ -277,7 +330,7 @@ class Database(object): :returns: :attr:`STATUS`.SUCCESS or raises - :exception: + :raises: :exc:`NotmuchError`: :attr:`STATUS`.XAPIAN_EXCEPTION A Xapian exception occurred; atomic section not @@ -294,41 +347,38 @@ class Database(object): def get_directory(self, path): """Returns a :class:`Directory` of path, - (creating it if it does not exist(?)) - - .. warning:: - - This call needs a writeable database in - :attr:`Database.MODE`.READ_WRITE mode. The underlying library will - exit the program if this method is used on a read-only database! :param path: An unicode string containing the path relative to the path of database (see :meth:`get_path`), or else should be an absolute path with initial components that match the path of 'database'. :returns: :class:`Directory` or raises an exception. - :exception: - :exc:`NotmuchError` with :attr:`STATUS`.FILE_ERROR - If path is not relative database or absolute with initial - components same as database. + :raises: :exc:`FileError` if path is not relative database or absolute + with initial components same as database. """ self._assert_db_is_initialized() + # sanity checking if path is valid, and make path absolute - if path[0] == os.sep: + if path and path[0] == os.sep: # we got an absolute path if not path.startswith(self.get_path()): # but its initial components are not equal to the db path - raise NotmuchError(STATUS.FILE_ERROR, - message="Database().get_directory() called " - "with a wrong absolute path.") + raise FileError('Database().get_directory() called ' + 'with a wrong absolute path') abs_dirpath = path else: #we got a relative path, make it absolute abs_dirpath = os.path.abspath(os.path.join(self.get_path(), path)) - dir_p = Database._get_directory(self._db, _str(path)) + dir_p = NotmuchDirectoryP() + status = Database._get_directory(self._db, _str(path), byref(dir_p)) + + if status != STATUS.SUCCESS: + raise NotmuchError(status) + if not dir_p: + return None # return the Directory, init it with the absolute path - return Directory(_str(abs_dirpath), dir_p, self) + return Directory(abs_dirpath, dir_p, self) _add_message = nmlib.notmuch_database_add_message _add_message.argtypes = [NotmuchDatabaseP, c_char_p, @@ -371,7 +421,7 @@ class Database(object): :rtype: 2-tuple(:class:`Message`, :attr:`STATUS`) - :exception: Raises a :exc:`NotmuchError` with the following meaning. + :raises: Raises a :exc:`NotmuchError` with the following meaning. If such an exception occurs, nothing was added to the database. :attr:`STATUS`.FILE_ERROR @@ -421,7 +471,7 @@ class Database(object): 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. + :raises: Raises a :exc:`NotmuchError` with the following meaning. If such an exception occurs, nothing was removed from the database. @@ -440,7 +490,7 @@ class Database(object): :param msgid: The message ID :type msgid: unicode or str :returns: :class:`Message` or `None` if no message is found. - :exception: + :raises: :exc:`OutOfMemoryError` If an Out-of-memory occured while constructing the message. :exc:`XapianError` @@ -462,31 +512,25 @@ class Database(object): def find_message_by_filename(self, filename): """Find a message with the given filename - .. warning:: - - This call needs a writeable database in - :attr:`Database.MODE`.READ_WRITE mode. The underlying library will - exit the program if this method is used on a read-only database! - :returns: If the database contains a message with the given filename, then a class:`Message:` is returned. This function returns None if no message is found with the given filename. - :exception: - :exc:`OutOfMemoryError` - If an Out-of-memory occured while constructing the message. - :exc:`XapianError` - In case of a Xapian Exception. These exceptions - include "Database modified" situations, e.g. when the - notmuch database has been modified by another program - in the meantime. In this case, you should close and - reopen the database and retry. - :exc:`NotInitializedError` if - the database was not intitialized. + :raises: :exc:`OutOfMemoryError` if an Out-of-memory occured while + constructing the message. + :raises: :exc:`XapianError` in case of a Xapian Exception. + These exceptions include "Database modified" + situations, e.g. when the notmuch database has been + modified by another program in the meantime. In this + case, you should close and reopen the database and + retry. + :raises: :exc:`NotInitializedError` if the database was not + intitialized. *Added in notmuch 0.9*""" self._assert_db_is_initialized() + msg_p = NotmuchMessageP() status = Database._find_message_by_filename(self._db, _str(filename), byref(msg_p)) @@ -503,8 +547,8 @@ class Database(object): """ self._assert_db_is_initialized() tags_p = Database._get_all_tags(self._db) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) + if not tags_p: + raise NullPointerError() return Tags(tags_p, self) def create_query(self, querystring): @@ -530,444 +574,26 @@ class Database(object): def __repr__(self): return "'Notmuch DB " + self.get_path() + "'" - _close = nmlib.notmuch_database_close - _close.argtypes = [NotmuchDatabaseP] - _close.restype = None - - def __del__(self): - """Close and free the notmuch database if needed""" - if self._db is not None: - self._close(self._db) - def _get_user_default_db(self): """ Reads a user's notmuch config and returns his db location Throws a NotmuchError if it cannot find it""" - try: - # python3.x - from configparser import SafeConfigParser - except ImportError: - # python2.x - from ConfigParser import SafeConfigParser - config = SafeConfigParser() conf_f = os.getenv('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config')) - config.read(conf_f) + config.readfp(codecs.open(conf_f, 'r', 'utf-8')) if not config.has_option('database', 'path'): raise NotmuchError(message="No DB path specified" " and no user default found") - return config.get('database', 'path').decode('utf-8') + return config.get('database', 'path') @property def db_p(self): """Property returning a pointer to `notmuch_database_t` or `None` - This should normally not be needed by a user (and is not yet - guaranteed to remain stable in future versions). + .. deprecated:: 0.14 + If you really need a pointer to the notmuch + database object use the `_pointer` field. This + alias will be removed in notmuch 0.15. """ return self._db - - -class Query(object): - """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. - - :class:`Query` provides an instance attribute :attr:`sort`, which - contains the sort order (if specified via :meth:`set_sort`) or - `None`. - - Any function in this class may throw an :exc:`NotInitializedError` - in case the underlying query object was not set up correctly. - - .. note:: Do remember that as soon as we tear down this object, - all underlying derived objects such as threads, - messages, tags etc will be freed by the underlying library - as well. Accessing these objects will lead to segfaults and - other unexpected behavior. See above for more details. - """ - # constants - SORT = Enum(['OLDEST_FIRST', 'NEWEST_FIRST', 'MESSAGE_ID', 'UNSORTED']) - """Constants: Sort order in which to return results""" - - """notmuch_query_create""" - _create = nmlib.notmuch_query_create - _create.argtypes = [NotmuchDatabaseP, c_char_p] - _create.restype = NotmuchQueryP - - """notmuch_query_search_threads""" - _search_threads = nmlib.notmuch_query_search_threads - _search_threads.argtypes = [NotmuchQueryP] - _search_threads.restype = NotmuchThreadsP - - """notmuch_query_search_messages""" - _search_messages = nmlib.notmuch_query_search_messages - _search_messages.argtypes = [NotmuchQueryP] - _search_messages.restype = NotmuchMessagesP - - """notmuch_query_count_messages""" - _count_messages = nmlib.notmuch_query_count_messages - _count_messages.argtypes = [NotmuchQueryP] - _count_messages.restype = c_uint - - def __init__(self, db, querystr): - """ - :param db: An open database which we derive the Query from. - :type db: :class:`Database` - :param querystr: The query string for the message. - :type querystr: utf-8 encoded str or unicode - """ - self._db = None - self._query = None - self.sort = None - self.create(db, querystr) - - def _assert_query_is_initialized(self): - """Raises :exc:`NotInitializedError` if self._query is `None`""" - if self._query is None: - raise NotInitializedError() - - def create(self, db, querystr): - """Creates a new query derived from a Database - - This function is utilized by __init__() and usually does not need to - be called directly. - - :param db: Database to create the query from. - :type db: :class:`Database` - :param querystr: The query string - :type querystr: utf-8 encoded str or unicode - :returns: Nothing - :exception: - :exc:`NullPointerError` if the query creation failed - (e.g. too little memory). - :exc:`NotInitializedError` if the underlying db was not - intitialized. - """ - db._assert_db_is_initialized() - # create reference to parent db to keep it alive - self._db = db - # create query, return None if too little mem available - query_p = Query._create(db.db_p, _str(querystr)) - if query_p is None: - raise NullPointerError - self._query = query_p - - _set_sort = nmlib.notmuch_query_set_sort - _set_sort.argtypes = [NotmuchQueryP, c_uint] - _set_sort.argtypes = None - - def set_sort(self, sort): - """Set the sort order future results will be delivered in - - :param sort: Sort order (see :attr:`Query.SORT`) - """ - self._assert_query_is_initialized() - self.sort = sort - self._set_sort(self._query, sort) - - def search_threads(self): - """Execute a query for threads - - Execute a query for threads, returning a :class:`Threads` iterator. - The returned threads are owned by the query and as such, will only be - valid until the Query is deleted. - - The method sets :attr:`Message.FLAG`\.MATCH for those messages that - match the query. The method :meth:`Message.get_flag` allows us - to get the value of this flag. - - :returns: :class:`Threads` - :exception: :exc:`NullPointerError` if search_threads failed - """ - self._assert_query_is_initialized() - threads_p = Query._search_threads(self._query) - - if threads_p is None: - raise NullPointerError - return Threads(threads_p, self) - - def search_messages(self): - """Filter messages according to the query and return - :class:`Messages` in the defined sort order - - :returns: :class:`Messages` - :exception: :exc:`NullPointerError` if search_messages failed - """ - self._assert_query_is_initialized() - msgs_p = Query._search_messages(self._query) - - if msgs_p is None: - raise NullPointerError - 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` - """ - self._assert_query_is_initialized() - return Query._count_messages(self._query) - - _destroy = nmlib.notmuch_query_destroy - _destroy.argtypes = [NotmuchQueryP] - _destroy.restype = None - - def __del__(self): - """Close and free the Query""" - if self._query is not None: - self._destroy(self._query) - - -class Directory(object): - """Represents a directory entry in the notmuch directory - - Modifying attributes of this object will modify the - database, not the real directory attributes. - - The Directory object is usually derived from another object - e.g. via :meth:`Database.get_directory`, and will automatically be - become invalid whenever that parent is deleted. You should - therefore initialized this object handing it a reference to the - parent, preventing the parent from automatically being garbage - collected. - """ - - """notmuch_directory_get_mtime""" - _get_mtime = nmlib.notmuch_directory_get_mtime - _get_mtime.argtypes = [NotmuchDirectoryP] - _get_mtime.restype = c_long - - """notmuch_directory_set_mtime""" - _set_mtime = nmlib.notmuch_directory_set_mtime - _set_mtime.argtypes = [NotmuchDirectoryP, c_long] - _set_mtime.restype = c_uint - - """notmuch_directory_get_child_files""" - _get_child_files = nmlib.notmuch_directory_get_child_files - _get_child_files.argtypes = [NotmuchDirectoryP] - _get_child_files.restype = NotmuchFilenamesP - - """notmuch_directory_get_child_directories""" - _get_child_directories = nmlib.notmuch_directory_get_child_directories - _get_child_directories.argtypes = [NotmuchDirectoryP] - _get_child_directories.restype = NotmuchFilenamesP - - def _assert_dir_is_initialized(self): - """Raises a NotmuchError(:attr:`STATUS`.NOT_INITIALIZED) - if dir_p is None""" - if self._dir_p is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - def __init__(self, path, dir_p, parent): - """ - :param path: The absolute path of the directory object as unicode. - :param dir_p: The pointer to an internal notmuch_directory_t object. - :param parent: The object this Directory is derived from - (usually a :class:`Database`). We do not directly use - this, but store a reference to it as long as - this Directory object lives. This keeps the - parent object alive. - """ - assert isinstance(path, unicode), "Path needs to be an UNICODE object" - self._path = path - self._dir_p = dir_p - self._parent = parent - - def set_mtime(self, mtime): - """Sets the mtime value of this directory in the database - - The intention is for the caller to use the mtime to allow efficient - identification of new messages to be added to the database. The - recommended usage is as follows: - - * Read the mtime of a directory from the filesystem - - * Call :meth:`Database.add_message` for all mail files in - the directory - - * Call notmuch_directory_set_mtime with the mtime read from the - filesystem. Then, when wanting to check for updates to the - directory in the future, the client can call :meth:`get_mtime` - and know that it only needs to add files if the mtime of the - directory and files are newer than the stored timestamp. - - .. note:: - - :meth:`get_mtime` function does not allow the caller to - distinguish a timestamp of 0 from a non-existent timestamp. So - don't store a timestamp of 0 unless you are comfortable with - that. - - :param mtime: A (time_t) timestamp - :returns: Nothing on success, raising an exception on failure. - :exception: :exc:`NotmuchError`: - - :attr:`STATUS`.XAPIAN_EXCEPTION - A Xapian exception occurred, mtime not stored. - :attr:`STATUS`.READ_ONLY_DATABASE - Database was opened in read-only mode so directory - mtime cannot be modified. - :attr:`STATUS`.NOT_INITIALIZED - The directory has not been initialized - """ - self._assert_dir_is_initialized() - #TODO: make sure, we convert the mtime parameter to a 'c_long' - status = Directory._set_mtime(self._dir_p, mtime) - - #return on success - if status == STATUS.SUCCESS: - return - #fail with Exception otherwise - raise NotmuchError(status) - - def get_mtime(self): - """Gets the mtime value of this directory in the database - - Retrieves a previously stored mtime for this directory. - - :param mtime: A (time_t) timestamp - :returns: Nothing on success, raising an exception on failure. - :exception: :exc:`NotmuchError`: - - :attr:`STATUS`.NOT_INITIALIZED - The directory has not been initialized - """ - self._assert_dir_is_initialized() - return Directory._get_mtime(self._dir_p) - - # Make mtime attribute a property of Directory() - mtime = property(get_mtime, set_mtime, doc="""Property that allows getting - and setting of the Directory *mtime* (read-write) - - See :meth:`get_mtime` and :meth:`set_mtime` for usage and - possible exceptions.""") - - def get_child_files(self): - """Gets a Filenames iterator listing all the filenames of - messages in the database within the given directory. - - The returned filenames will be the basename-entries only (not - complete paths. - """ - self._assert_dir_is_initialized() - files_p = Directory._get_child_files(self._dir_p) - return Filenames(files_p, self) - - def get_child_directories(self): - """Gets a :class:`Filenames` iterator listing all the filenames of - sub-directories in the database within the given directory - - The returned filenames will be the basename-entries only (not - complete paths. - """ - self._assert_dir_is_initialized() - files_p = Directory._get_child_directories(self._dir_p) - return Filenames(files_p, self) - - @property - def path(self): - """Returns the absolute path of this Directory (read-only)""" - return self._path - - def __repr__(self): - """Object representation""" - return "<notmuch Directory object '%s'>" % self._path - - _destroy = nmlib.notmuch_directory_destroy - _destroy.argtypes = [NotmuchDirectoryP] - _destroy.argtypes = None - - def __del__(self): - """Close and free the Directory""" - if self._dir_p is not None: - self._destroy(self._dir_p) - - -class Filenames(object): - """An iterator over File- or Directory names stored in the database""" - - #notmuch_filenames_get - _get = nmlib.notmuch_filenames_get - _get.argtypes = [NotmuchFilenamesP] - _get.restype = c_char_p - - def __init__(self, files_p, parent): - """ - :param files_p: The pointer to an internal notmuch_filenames_t object. - :param parent: The object this Directory is derived from - (usually a Directory()). We do not directly use - this, but store a reference to it as long as - this Directory object lives. This keeps the - parent object alive. - """ - self._files_p = files_p - self._parent = parent - - def __iter__(self): - """ Make Filenames an iterator """ - return self - - _valid = nmlib.notmuch_filenames_valid - _valid.argtypes = [NotmuchFilenamesP] - _valid.restype = bool - - _move_to_next = nmlib.notmuch_filenames_move_to_next - _move_to_next.argtypes = [NotmuchFilenamesP] - _move_to_next.restype = None - - def __next__(self): - if self._files_p is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not self._valid(self._files_p): - self._files_p = None - raise StopIteration - - file_ = Filenames._get(self._files_p) - self._move_to_next(self._files_p) - return file_.decode('utf-8', 'ignore') - next = __next__ # python2.x iterator protocol compatibility - - def __len__(self): - """len(:class:`Filenames`) returns the number of contained files - - .. note:: - - As this iterates over the files, we will not be able to - iterate over them again! So this will fail:: - - #THIS FAILS - files = Database().get_directory('').get_child_files() - if len(files) > 0: # this 'exhausts' msgs - # next line raises - # NotmuchError(:attr:`STATUS`.NOT_INITIALIZED) - for file in files: print file - """ - if self._files_p is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i = 0 - while self._valid(self._files_p): - self._move_to_next(self._files_p) - i += 1 - self._files_p = None - return i - - _destroy = nmlib.notmuch_filenames_destroy - _destroy.argtypes = [NotmuchFilenamesP] - _destroy.restype = None - - def __del__(self): - """Close and free Filenames""" - if self._files_p is not None: - self._destroy(self._files_p) diff --git a/bindings/python/notmuch/directory.py b/bindings/python/notmuch/directory.py new file mode 100644 index 0000000..3b0a525 --- /dev/null +++ b/bindings/python/notmuch/directory.py @@ -0,0 +1,185 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +""" + +from ctypes import c_uint, c_long +from .globals import ( + nmlib, + NotmuchDirectoryP, + NotmuchFilenamesP +) +from .errors import ( + STATUS, + NotmuchError, + NotInitializedError, +) +from .filenames import Filenames + +class Directory(object): + """Represents a directory entry in the notmuch directory + + Modifying attributes of this object will modify the + database, not the real directory attributes. + + The Directory object is usually derived from another object + e.g. via :meth:`Database.get_directory`, and will automatically be + become invalid whenever that parent is deleted. You should + therefore initialized this object handing it a reference to the + parent, preventing the parent from automatically being garbage + collected. + """ + + """notmuch_directory_get_mtime""" + _get_mtime = nmlib.notmuch_directory_get_mtime + _get_mtime.argtypes = [NotmuchDirectoryP] + _get_mtime.restype = c_long + + """notmuch_directory_set_mtime""" + _set_mtime = nmlib.notmuch_directory_set_mtime + _set_mtime.argtypes = [NotmuchDirectoryP, c_long] + _set_mtime.restype = c_uint + + """notmuch_directory_get_child_files""" + _get_child_files = nmlib.notmuch_directory_get_child_files + _get_child_files.argtypes = [NotmuchDirectoryP] + _get_child_files.restype = NotmuchFilenamesP + + """notmuch_directory_get_child_directories""" + _get_child_directories = nmlib.notmuch_directory_get_child_directories + _get_child_directories.argtypes = [NotmuchDirectoryP] + _get_child_directories.restype = NotmuchFilenamesP + + def _assert_dir_is_initialized(self): + """Raises a NotmuchError(:attr:`STATUS`.NOT_INITIALIZED) + if dir_p is None""" + if not self._dir_p: + raise NotInitializedError() + + def __init__(self, path, dir_p, parent): + """ + :param path: The absolute path of the directory object. + :param dir_p: The pointer to an internal notmuch_directory_t object. + :param parent: The object this Directory is derived from + (usually a :class:`Database`). We do not directly use + this, but store a reference to it as long as + this Directory object lives. This keeps the + parent object alive. + """ + self._path = path + self._dir_p = dir_p + self._parent = parent + + def set_mtime(self, mtime): + """Sets the mtime value of this directory in the database + + The intention is for the caller to use the mtime to allow efficient + identification of new messages to be added to the database. The + recommended usage is as follows: + + * Read the mtime of a directory from the filesystem + + * Call :meth:`Database.add_message` for all mail files in + the directory + + * Call notmuch_directory_set_mtime with the mtime read from the + filesystem. Then, when wanting to check for updates to the + directory in the future, the client can call :meth:`get_mtime` + and know that it only needs to add files if the mtime of the + directory and files are newer than the stored timestamp. + + .. note:: + + :meth:`get_mtime` function does not allow the caller to + distinguish a timestamp of 0 from a non-existent timestamp. So + don't store a timestamp of 0 unless you are comfortable with + that. + + :param mtime: A (time_t) timestamp + :raises: :exc:`XapianError` a Xapian exception occurred, mtime + not stored + :raises: :exc:`ReadOnlyDatabaseError` the database was opened + in read-only mode so directory mtime cannot be modified + :raises: :exc:`NotInitializedError` the directory object has not + been initialized + """ + self._assert_dir_is_initialized() + status = Directory._set_mtime(self._dir_p, mtime) + + if status != STATUS.SUCCESS: + raise NotmuchError(status) + + def get_mtime(self): + """Gets the mtime value of this directory in the database + + Retrieves a previously stored mtime for this directory. + + :param mtime: A (time_t) timestamp + :raises: :exc:`NotmuchError`: + + :attr:`STATUS`.NOT_INITIALIZED + The directory has not been initialized + """ + self._assert_dir_is_initialized() + return Directory._get_mtime(self._dir_p) + + # Make mtime attribute a property of Directory() + mtime = property(get_mtime, set_mtime, doc="""Property that allows getting + and setting of the Directory *mtime* (read-write) + + See :meth:`get_mtime` and :meth:`set_mtime` for usage and + possible exceptions.""") + + def get_child_files(self): + """Gets a Filenames iterator listing all the filenames of + messages in the database within the given directory. + + The returned filenames will be the basename-entries only (not + complete paths. + """ + self._assert_dir_is_initialized() + files_p = Directory._get_child_files(self._dir_p) + return Filenames(files_p, self) + + def get_child_directories(self): + """Gets a :class:`Filenames` iterator listing all the filenames of + sub-directories in the database within the given directory + + The returned filenames will be the basename-entries only (not + complete paths. + """ + self._assert_dir_is_initialized() + files_p = Directory._get_child_directories(self._dir_p) + return Filenames(files_p, self) + + @property + def path(self): + """Returns the absolute path of this Directory (read-only)""" + return self._path + + def __repr__(self): + """Object representation""" + return "<notmuch Directory object '%s'>" % self._path + + _destroy = nmlib.notmuch_directory_destroy + _destroy.argtypes = [NotmuchDirectoryP] + _destroy.restype = None + + def __del__(self): + """Close and free the Directory""" + if self._dir_p: + self._destroy(self._dir_p) diff --git a/bindings/python/notmuch/errors.py b/bindings/python/notmuch/errors.py new file mode 100644 index 0000000..f153a9c --- /dev/null +++ b/bindings/python/notmuch/errors.py @@ -0,0 +1,183 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +""" + +from ctypes import c_char_p, c_int + +from .globals import ( + nmlib, + Enum, + Python3StringMixIn, +) + +class Status(Enum): + """Enum with a string representation of a notmuch_status_t value.""" + _status2str = nmlib.notmuch_status_to_string + _status2str.restype = c_char_p + _status2str.argtypes = [c_int] + + def __init__(self, statuslist): + """It is initialized with a list of strings that are available as + Status().string1 - Status().stringn attributes. + """ + super(Status, self).__init__(statuslist) + + @classmethod + def status2str(self, status): + """Get a (unicode) string representation of a notmuch_status_t value.""" + # define strings for custom error messages + if status == STATUS.NOT_INITIALIZED: + return "Operation on uninitialized object impossible." + return unicode(Status._status2str(status)) + +STATUS = Status(['SUCCESS', + 'OUT_OF_MEMORY', + 'READ_ONLY_DATABASE', + 'XAPIAN_EXCEPTION', + 'FILE_ERROR', + 'FILE_NOT_EMAIL', + 'DUPLICATE_MESSAGE_ID', + 'NULL_POINTER', + 'TAG_TOO_LONG', + 'UNBALANCED_FREEZE_THAW', + 'UNBALANCED_ATOMIC', + 'NOT_INITIALIZED']) +"""STATUS is a class, whose attributes provide constants that serve as return +indicators for notmuch functions. Currently the following ones are defined. For +possible return values and specific meaning for each method, see the method +description. + + * SUCCESS + * OUT_OF_MEMORY + * READ_ONLY_DATABASE + * XAPIAN_EXCEPTION + * FILE_ERROR + * FILE_NOT_EMAIL + * DUPLICATE_MESSAGE_ID + * NULL_POINTER + * TAG_TOO_LONG + * UNBALANCED_FREEZE_THAW + * UNBALANCED_ATOMIC + * NOT_INITIALIZED + +Invoke the class method `notmuch.STATUS.status2str` with a status value as +argument to receive a human readable string""" +STATUS.__name__ = 'STATUS' + + +class NotmuchError(Exception, Python3StringMixIn): + """Is initiated with a (notmuch.STATUS[, message=None]). It will not + return an instance of the class NotmuchError, but a derived instance + of a more specific Error Message, e.g. OutOfMemoryError. Each status + but SUCCESS has a corresponding subclassed Exception.""" + + @classmethod + def get_exc_subclass(cls, status): + """Returns a fine grained Exception() type, + detailing the error status""" + subclasses = { + STATUS.OUT_OF_MEMORY: OutOfMemoryError, + STATUS.READ_ONLY_DATABASE: ReadOnlyDatabaseError, + STATUS.XAPIAN_EXCEPTION: XapianError, + STATUS.FILE_ERROR: FileError, + STATUS.FILE_NOT_EMAIL: FileNotEmailError, + STATUS.DUPLICATE_MESSAGE_ID: DuplicateMessageIdError, + STATUS.NULL_POINTER: NullPointerError, + STATUS.TAG_TOO_LONG: TagTooLongError, + STATUS.UNBALANCED_FREEZE_THAW: UnbalancedFreezeThawError, + STATUS.UNBALANCED_ATOMIC: UnbalancedAtomicError, + STATUS.NOT_INITIALIZED: NotInitializedError, + } + assert 0 < status <= len(subclasses) + return subclasses[status] + + def __new__(cls, *args, **kwargs): + """Return a correct subclass of NotmuchError if needed + + We return a NotmuchError instance if status is None (or 0) and a + subclass that inherits from NotmuchError depending on the + 'status' parameter otherwise.""" + # get 'status'. Passed in as arg or kwarg? + status = args[0] if len(args) else kwargs.get('status', None) + # no 'status' or cls is subclass already, return 'cls' instance + if not status or cls != NotmuchError: + return super(NotmuchError, cls).__new__(cls) + subclass = cls.get_exc_subclass(status) # which class to use? + return subclass.__new__(subclass, *args, **kwargs) + + def __init__(self, status=None, message=None): + self.status = status + self.message = message + + def __unicode__(self): + if self.message is not None: + return self.message + elif self.status is not None: + return STATUS.status2str(self.status) + else: + return 'Unknown error' + + +# List of Subclassed exceptions that correspond to STATUS values and are +# subclasses of NotmuchError. +class OutOfMemoryError(NotmuchError): + status = STATUS.OUT_OF_MEMORY + + +class ReadOnlyDatabaseError(NotmuchError): + status = STATUS.READ_ONLY_DATABASE + + +class XapianError(NotmuchError): + status = STATUS.XAPIAN_EXCEPTION + + +class FileError(NotmuchError): + status = STATUS.FILE_ERROR + + +class FileNotEmailError(NotmuchError): + status = STATUS.FILE_NOT_EMAIL + + +class DuplicateMessageIdError(NotmuchError): + status = STATUS.DUPLICATE_MESSAGE_ID + + +class NullPointerError(NotmuchError): + status = STATUS.NULL_POINTER + + +class TagTooLongError(NotmuchError): + status = STATUS.TAG_TOO_LONG + + +class UnbalancedFreezeThawError(NotmuchError): + status = STATUS.UNBALANCED_FREEZE_THAW + + +class UnbalancedAtomicError(NotmuchError): + status = STATUS.UNBALANCED_ATOMIC + + +class NotInitializedError(NotmuchError): + """Derived from NotmuchError, this occurs if the underlying data + structure (e.g. database is not initialized (yet) or an iterator has + been exhausted. You can test for NotmuchError with .status = + STATUS.NOT_INITIALIZED""" + status = STATUS.NOT_INITIALIZED diff --git a/bindings/python/notmuch/filename.py b/bindings/python/notmuch/filename.py deleted file mode 100644 index 51dae20..0000000 --- a/bindings/python/notmuch/filename.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -This file is part of notmuch. - -Notmuch is free software: you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation, either version 3 of the License, or (at your -option) any later version. - -Notmuch is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with notmuch. If not, see <http://www.gnu.org/licenses/>. - -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' -""" -from ctypes import c_char_p -from notmuch.globals import (nmlib, STATUS, NotmuchError, - NotmuchFilenamesP, NotmuchMessageP, _str, Python3StringMixIn) - - -class Filenames(Python3StringMixIn): - """Represents a list of filenames as returned by notmuch - - This object contains the Filenames iterator. The main function is - as_generator() which will return a generator so we can do a Filenamesth an - iterator over a list of notmuch filenames. Do note that the underlying - library only provides a one-time iterator (it cannot reset the iterator to - the start). Thus iterating over the function will "exhaust" the list of - tags, and a subsequent iteration attempt will raise a :exc:`NotmuchError` - STATUS.NOT_INITIALIZED. Also note, that any function that uses iteration - (nearly all) will also exhaust the tags. So both:: - - for name in filenames: print name - - as well as:: - - number_of_names = len(names) - - and even a simple:: - - #str() iterates over all tags to construct a space separated list - print(str(filenames)) - - will "exhaust" the Filenames. However, you can use - :meth:`Message.get_filenames` repeatedly to get fresh Filenames - objects to perform various actions on filenames. - """ - - #notmuch_filenames_get - _get = nmlib.notmuch_filenames_get - _get.argtypes = [NotmuchFilenamesP] - _get.restype = c_char_p - - def __init__(self, files_p, parent): - """ - :param files_p: A pointer to an underlying *notmuch_tags_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Tags` object - herself. They are usually handed back as a result, - e.g. in :meth:`Database.get_all_tags`. *tags_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. - :type files_p: :class:`ctypes.c_void_p` - :param parent: The parent object (ie :class:`Message` these - filenames are derived from, and saves a - reference to it, so we can automatically delete the db object - once all derived objects are dead. - """ - if files_p is None: - raise NotmuchError(STATUS.NULL_POINTER) - - self._files = files_p - #save reference to parent object so we keep it alive - self._parent = parent - - _valid = nmlib.notmuch_filenames_valid - _valid.argtypes = [NotmuchFilenamesP] - _valid.restype = bool - - _move_to_next = nmlib.notmuch_filenames_move_to_next - _move_to_next.argtypes = [NotmuchFilenamesP] - _move_to_next.restype = None - - def as_generator(self): - """Return generator of Filenames - - This is the main function that will usually be used by the - user.""" - if self._files is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - while self._valid(self._files): - yield Filenames._get(self._files).decode('utf-8', 'ignore') - self._move_to_next(self._files) - - self._files = None - - def __unicode__(self): - """Represent Filenames() as newline-separated list of full paths - - .. note:: As this iterates over the filenames, we will not be - able to iterate over them again (as in retrieve them)! If - the tags have been exhausted already, this will raise a - :exc:`NotmuchError` STATUS.NOT_INITIALIZED on subsequent - attempts. However, you can use - :meth:`Message.get_filenames` repeatedly to perform - various actions on filenames. - """ - return "\n".join(self) - - _destroy = nmlib.notmuch_filenames_destroy - _destroy.argtypes = [NotmuchMessageP] - _destroy.restype = None - - def __del__(self): - """Close and free the notmuch filenames""" - if self._files is not None: - self._destroy(self._files) diff --git a/bindings/python/notmuch/filenames.py b/bindings/python/notmuch/filenames.py new file mode 100644 index 0000000..229f414 --- /dev/null +++ b/bindings/python/notmuch/filenames.py @@ -0,0 +1,150 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +""" +from ctypes import c_char_p +from .globals import ( + nmlib, + NotmuchMessageP, + NotmuchFilenamesP, + Python3StringMixIn, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) + + +class Filenames(Python3StringMixIn): + """Represents a list of filenames as returned by notmuch + + Objects of this class implement the iterator protocol. + + .. note:: + + The underlying library only provides a one-time iterator (it + cannot reset the iterator to the start). Thus iterating over + the function will "exhaust" the list of tags, and a subsequent + iteration attempt will raise a + :exc:`NotInitializedError`. Also note, that any function that + uses iteration (nearly all) will also exhaust the tags. So + both:: + + for name in filenames: print name + + as well as:: + + number_of_names = len(names) + + and even a simple:: + + #str() iterates over all tags to construct a space separated list + print(str(filenames)) + + will "exhaust" the Filenames. However, you can use + :meth:`Message.get_filenames` repeatedly to get fresh + Filenames objects to perform various actions on filenames. + """ + + #notmuch_filenames_get + _get = nmlib.notmuch_filenames_get + _get.argtypes = [NotmuchFilenamesP] + _get.restype = c_char_p + + def __init__(self, files_p, parent): + """ + :param files_p: A pointer to an underlying *notmuch_tags_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Tags` object + herself. They are usually handed back as a result, + e.g. in :meth:`Database.get_all_tags`. *tags_p* must be + valid, we will raise an :exc:`NullPointerError` + if it is `None`. + :type files_p: :class:`ctypes.c_void_p` + :param parent: The parent object (ie :class:`Message` these + filenames are derived from, and saves a + reference to it, so we can automatically delete the db object + once all derived objects are dead. + """ + if not files_p: + raise NullPointerError() + + self._files_p = files_p + #save reference to parent object so we keep it alive + self._parent = parent + + def __iter__(self): + """ Make Filenames an iterator """ + return self + + _valid = nmlib.notmuch_filenames_valid + _valid.argtypes = [NotmuchFilenamesP] + _valid.restype = bool + + _move_to_next = nmlib.notmuch_filenames_move_to_next + _move_to_next.argtypes = [NotmuchFilenamesP] + _move_to_next.restype = None + + def __next__(self): + if not self._files_p: + raise NotInitializedError() + + if not self._valid(self._files_p): + self._files_p = None + raise StopIteration + + file_ = Filenames._get(self._files_p) + self._move_to_next(self._files_p) + return file_.decode('utf-8', 'ignore') + next = __next__ # python2.x iterator protocol compatibility + + def __unicode__(self): + """Represent Filenames() as newline-separated list of full paths + + .. note:: + + This method exhausts the iterator object, so you will not be able to + iterate over them again. + """ + return "\n".join(self) + + _destroy = nmlib.notmuch_filenames_destroy + _destroy.argtypes = [NotmuchMessageP] + _destroy.restype = None + + def __del__(self): + """Close and free the notmuch filenames""" + if self._files_p: + self._destroy(self._files_p) + + def __len__(self): + """len(:class:`Filenames`) returns the number of contained files + + .. note:: + + This method exhausts the iterator object, so you will not be able to + iterate over them again. + """ + if not self._files_p: + raise NotInitializedError() + + i = 0 + while self._valid(self._files_p): + self._move_to_next(self._files_p) + i += 1 + self._files_p = None + return i diff --git a/bindings/python/notmuch/globals.py b/bindings/python/notmuch/globals.py index 32ed9ae..c7632c3 100644 --- a/bindings/python/notmuch/globals.py +++ b/bindings/python/notmuch/globals.py @@ -14,28 +14,19 @@ for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see <http://www.gnu.org/licenses/>. -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> """ -import sys -from ctypes import CDLL, c_char_p, c_int, Structure, POINTER + +from ctypes import CDLL, Structure, POINTER #----------------------------------------------------------------------------- #package-global instance of the notmuch library try: - nmlib = CDLL("libnotmuch.so.2") + nmlib = CDLL("libnotmuch.so.3") except: raise ImportError("Could not find shared 'notmuch' library.") - -if sys.version_info[0] == 2: - class Python3StringMixIn(object): - def __str__(self): - return unicode(self).encode('utf-8') -else: - class Python3StringMixIn(object): - def __str__(self): - return self.__unicode__() - +from .compat import Python3StringMixIn, encode_utf8 as _str class Enum(object): """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" @@ -44,176 +35,6 @@ class Enum(object): setattr(self, name, number) -class Status(Enum): - """Enum with a string representation of a notmuch_status_t value.""" - _status2str = nmlib.notmuch_status_to_string - _status2str.restype = c_char_p - _status2str.argtypes = [c_int] - - def __init__(self, statuslist): - """It is initialized with a list of strings that are available as - Status().string1 - Status().stringn attributes. - """ - super(Status, self).__init__(statuslist) - - @classmethod - def status2str(self, status): - """Get a (unicode) string representation of a notmuch_status_t value.""" - # define strings for custom error messages - if status == STATUS.NOT_INITIALIZED: - return "Operation on uninitialized object impossible." - return unicode(Status._status2str(status)) - -STATUS = Status(['SUCCESS', - 'OUT_OF_MEMORY', - 'READ_ONLY_DATABASE', - 'XAPIAN_EXCEPTION', - 'FILE_ERROR', - 'FILE_NOT_EMAIL', - 'DUPLICATE_MESSAGE_ID', - 'NULL_POINTER', - 'TAG_TOO_LONG', - 'UNBALANCED_FREEZE_THAW', - 'UNBALANCED_ATOMIC', - 'NOT_INITIALIZED']) -"""STATUS is a class, whose attributes provide constants that serve as return -indicators for notmuch functions. Currently the following ones are defined. For -possible return values and specific meaning for each method, see the method -description. - - * SUCCESS - * OUT_OF_MEMORY - * READ_ONLY_DATABASE - * XAPIAN_EXCEPTION - * FILE_ERROR - * FILE_NOT_EMAIL - * DUPLICATE_MESSAGE_ID - * NULL_POINTER - * TAG_TOO_LONG - * UNBALANCED_FREEZE_THAW - * UNBALANCED_ATOMIC - * NOT_INITIALIZED - -Invoke the class method `notmuch.STATUS.status2str` with a status value as -argument to receive a human readable string""" -STATUS.__name__ = 'STATUS' - - -class NotmuchError(Exception, Python3StringMixIn): - """Is initiated with a (notmuch.STATUS[, message=None]). It will not - return an instance of the class NotmuchError, but a derived instance - of a more specific Error Message, e.g. OutOfMemoryError. Each status - but SUCCESS has a corresponding subclassed Exception.""" - - @classmethod - def get_exc_subclass(cls, status): - """Returns a fine grained Exception() type, - detailing the error status""" - subclasses = { - STATUS.OUT_OF_MEMORY: OutOfMemoryError, - STATUS.READ_ONLY_DATABASE: ReadOnlyDatabaseError, - STATUS.XAPIAN_EXCEPTION: XapianError, - STATUS.FILE_ERROR: FileError, - STATUS.FILE_NOT_EMAIL: FileNotEmailError, - STATUS.DUPLICATE_MESSAGE_ID: DuplicateMessageIdError, - STATUS.NULL_POINTER: NullPointerError, - STATUS.TAG_TOO_LONG: TagTooLongError, - STATUS.UNBALANCED_FREEZE_THAW: UnbalancedFreezeThawError, - STATUS.UNBALANCED_ATOMIC: UnbalancedAtomicError, - STATUS.NOT_INITIALIZED: NotInitializedError, - } - assert 0 < status <= len(subclasses) - return subclasses[status] - - def __new__(cls, *args, **kwargs): - """Return a correct subclass of NotmuchError if needed - - We return a NotmuchError instance if status is None (or 0) and a - subclass that inherits from NotmuchError depending on the - 'status' parameter otherwise.""" - # get 'status'. Passed in as arg or kwarg? - status = args[0] if len(args) else kwargs.get('status', None) - # no 'status' or cls is subclass already, return 'cls' instance - if not status or cls != NotmuchError: - return super(NotmuchError, cls).__new__(cls) - subclass = cls.get_exc_subclass(status) # which class to use? - return subclass.__new__(subclass, *args, **kwargs) - - def __init__(self, status=None, message=None): - self.status = status - self.message = message - - def __unicode__(self): - if self.message is not None: - return self.message - elif self.status is not None: - return STATUS.status2str(self.status) - else: - return 'Unknown error' - - -# List of Subclassed exceptions that correspond to STATUS values and are -# subclasses of NotmuchError. -class OutOfMemoryError(NotmuchError): - status = STATUS.OUT_OF_MEMORY - - -class ReadOnlyDatabaseError(NotmuchError): - status = STATUS.READ_ONLY_DATABASE - - -class XapianError(NotmuchError): - status = STATUS.XAPIAN_EXCEPTION - - -class FileError(NotmuchError): - status = STATUS.FILE_ERROR - - -class FileNotEmailError(NotmuchError): - status = STATUS.FILE_NOT_EMAIL - - -class DuplicateMessageIdError(NotmuchError): - status = STATUS.DUPLICATE_MESSAGE_ID - - -class NullPointerError(NotmuchError): - status = STATUS.NULL_POINTER - - -class TagTooLongError(NotmuchError): - status = STATUS.TAG_TOO_LONG - - -class UnbalancedFreezeThawError(NotmuchError): - status = STATUS.UNBALANCED_FREEZE_THAW - - -class UnbalancedAtomicError(NotmuchError): - status = STATUS.UNBALANCED_ATOMIC - - -class NotInitializedError(NotmuchError): - """Derived from NotmuchError, this occurs if the underlying data - structure (e.g. database is not initialized (yet) or an iterator has - been exhausted. You can test for NotmuchError with .status = - STATUS.NOT_INITIALIZED""" - status = STATUS.NOT_INITIALIZED - - -def _str(value): - """Ensure a nicely utf-8 encoded string to pass to libnotmuch - - C++ code expects strings to be well formatted and - unicode strings to have no null bytes.""" - if not isinstance(value, basestring): - raise TypeError("Expected str or unicode, got %s" % str(type(value))) - if isinstance(value, unicode): - return value.encode('UTF-8') - return value - - class NotmuchDatabaseS(Structure): pass NotmuchDatabaseP = POINTER(NotmuchDatabaseS) diff --git a/bindings/python/notmuch/message.py b/bindings/python/notmuch/message.py index d40a575..d1c1b58 100644 --- a/bindings/python/notmuch/message.py +++ b/bindings/python/notmuch/message.py @@ -14,256 +14,33 @@ for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see <http://www.gnu.org/licenses/>. -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> Jesse Rosenthal <jrosenthal@jhu.edu> """ from ctypes import c_char_p, c_long, c_uint, c_int from datetime import date -from notmuch.globals import ( - nmlib, STATUS, NotmuchError, Enum, _str, Python3StringMixIn, - NotmuchTagsP, NotmuchMessagesP, NotmuchMessageP, NotmuchFilenamesP) -from notmuch.tag import Tags -from notmuch.filename import Filenames -import sys -import email -try: - import simplejson as json -except ImportError: - import json - - -class Messages(object): - """Represents a list of notmuch messages - - This object provides an iterator over a list of notmuch messages - (Technically, it provides a wrapper for the underlying - *notmuch_messages_t* structure). Do note that the underlying library - only provides a one-time iterator (it cannot reset the iterator to - the start). Thus iterating over the function will "exhaust" the list - of messages, and a subsequent iteration attempt will raise a - :exc:`NotmuchError` STATUS.NOT_INITIALIZED. If you need to - re-iterate over a list of messages you will need to retrieve a new - :class:`Messages` object or cache your :class:`Message`\s in a list - via:: - - msglist = list(msgs) - - You can store and reuse the single :class:`Message` objects as often - as you want as long as you keep the parent :class:`Messages` object - around. (Due to hierarchical memory allocation, all derived - :class:`Message` objects will be invalid when we delete the parent - :class:`Messages` object, even if it was already exhausted.) So - this works:: - - db = Database() - msgs = Query(db,'').search_messages() #get a Messages() object - msglist = list(msgs) - - # msgs is "exhausted" now and msgs.next() will raise an exception. - # However it will be kept alive until all retrieved Message() - # objects are also deleted. If you do e.g. an explicit del(msgs) - # here, the following lines would fail. - - # You can reiterate over *msglist* however as often as you want. - # It is simply a list with :class:`Message`s. - - print (msglist[0].get_filename()) - print (msglist[1].get_filename()) - print (msglist[0].get_message_id()) - - - As :class:`Message` implements both __hash__() and __cmp__(), it is - possible to make sets out of :class:`Messages` and use set - arithmetic (this happens in python and will of course be *much* - slower than redoing a proper query with the appropriate filters:: - - s1, s2 = set(msgs1), set(msgs2) - s.union(s2) - s1 -= s2 - ... - - Be careful when using set arithmetic between message sets derived - from different Databases (ie the same database reopened after - messages have changed). If messages have added or removed associated - files in the meantime, it is possible that the same message would be - considered as a different object (as it points to a different file). - """ - - #notmuch_messages_get - _get = nmlib.notmuch_messages_get - _get.argtypes = [NotmuchMessagesP] - _get.restype = NotmuchMessageP - - _collect_tags = nmlib.notmuch_messages_collect_tags - _collect_tags.argtypes = [NotmuchMessagesP] - _collect_tags.restype = NotmuchTagsP - - def __init__(self, msgs_p, parent=None): - """ - :param msgs_p: A pointer to an underlying *notmuch_messages_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Messages` object - herself. They are usually handed back as a result, - e.g. in :meth:`Query.search_messages`. *msgs_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. - :type msgs_p: :class:`ctypes.c_void_p` - :param parent: The parent object - (ie :class:`Query`) these tags are derived from. It saves - a reference to it, so we can automatically delete the db - object once all derived objects are dead. - :TODO: Make the iterator work more than once and cache the tags in - the Python object.(?) - """ - if msgs_p is None: - raise NotmuchError(STATUS.NULL_POINTER) - - self._msgs = msgs_p - #store parent, so we keep them alive as long as self is alive - self._parent = parent - - def collect_tags(self): - """Return the unique :class:`Tags` in the contained messages - - :returns: :class:`Tags` - :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not init'ed - - .. note:: - - :meth:`collect_tags` will iterate over the messages and therefore - will not allow further iterations. - """ - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - # collect all tags (returns NULL on error) - tags_p = Messages._collect_tags(self._msgs) - #reset _msgs as we iterated over it and can do so only once - self._msgs = None - - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def __iter__(self): - """ Make Messages an iterator """ - return self - - _valid = nmlib.notmuch_messages_valid - _valid.argtypes = [NotmuchMessagesP] - _valid.restype = bool - - _move_to_next = nmlib.notmuch_messages_move_to_next - _move_to_next.argtypes = [NotmuchMessagesP] - _move_to_next.restype = None - - def __next__(self): - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not self._valid(self._msgs): - self._msgs = None - raise StopIteration +from .globals import ( + nmlib, + Enum, + _str, + Python3StringMixIn, + NotmuchTagsP, + NotmuchMessageP, + NotmuchMessagesP, + NotmuchFilenamesP, +) +from .errors import ( + STATUS, + NotmuchError, + NullPointerError, + NotInitializedError, +) +from .tag import Tags +from .filenames import Filenames - msg = Message(Messages._get(self._msgs), self) - self._move_to_next(self._msgs) - return msg - next = __next__ # python2.x iterator protocol compatibility - - def __nonzero__(self): - """ - :return: True if there is at least one more thread in the - Iterator, False if not.""" - return self._msgs is not None and \ - self._valid(self._msgs) > 0 - - _destroy = nmlib.notmuch_messages_destroy - _destroy.argtypes = [NotmuchMessagesP] - _destroy.restype = None - - def __del__(self): - """Close and free the notmuch Messages""" - if self._msgs is not None: - self._destroy(self._msgs) - - def format_messages(self, format, indent=0, entire_thread=False): - """Formats messages as needed for 'notmuch show'. - - :param format: A string of either 'text' or 'json'. - :param indent: A number indicating the reply depth of these messages. - :param entire_thread: A bool, indicating whether we want to output - whole threads or only the matching messages. - :return: a list of lines - """ - result = list() - - if format.lower() == "text": - set_start = "" - set_end = "" - set_sep = "" - elif format.lower() == "json": - set_start = "[" - set_end = "]" - set_sep = ", " - else: - raise TypeError("format must be either 'text' or 'json'") - - first_set = True - - result.append(set_start) - - # iterate through all toplevel messages in this thread - for msg in self: - # if not msg: - # break - if not first_set: - result.append(set_sep) - first_set = False - - result.append(set_start) - match = msg.is_match() - next_indent = indent - - if (match or entire_thread): - if format.lower() == "text": - result.append(msg.format_message_as_text(indent)) - else: - result.append(msg.format_message_as_json(indent)) - next_indent = indent + 1 - - # get replies and print them also out (if there are any) - replies = msg.get_replies().format_messages(format, next_indent, entire_thread) - if replies: - result.append(set_sep) - result.extend(replies) - - result.append(set_end) - result.append(set_end) - - return result - - def print_messages(self, format, indent=0, entire_thread=False, handle=sys.stdout): - """Outputs messages as needed for 'notmuch show' to a file like object. - - :param format: A string of either 'text' or 'json'. - :param handle: A file like object to print to (default is sys.stdout). - :param indent: A number indicating the reply depth of these messages. - :param entire_thread: A bool, indicating whether we want to output - whole threads or only the matching messages. - """ - handle.write(''.join(self.format_messages(format, indent, entire_thread))) - - -class EmptyMessagesResult(Messages): - def __init__(self, parent): - self._msgs = None - self._parent = parent - - def __next__(self): - raise StopIteration() - next = __next__ +import email class Message(Python3StringMixIn): @@ -341,16 +118,16 @@ class Message(Python3StringMixIn): def __init__(self, msg_p, parent=None): """ :param msg_p: A pointer to an internal notmuch_message_t - Structure. If it is `None`, we will raise an :exc:`NotmuchError` - STATUS.NULL_POINTER. + Structure. If it is `None`, we will raise an + :exc:`NullPointerError`. :param parent: A 'parent' object is passed which this message is derived from. We save a reference to it, so we can automatically delete the parent object once all derived objects are dead. """ - if msg_p is None: - raise NotmuchError(STATUS.NULL_POINTER) + if not msg_p: + raise NullPointerError() self._msg = msg_p #keep reference to parent, so we keep it alive self._parent = parent @@ -359,11 +136,11 @@ class Message(Python3StringMixIn): """Returns the message ID :returns: String with a message ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._get_message_id(self._msg).decode('utf-8', 'ignore') def get_thread_id(self): @@ -376,11 +153,11 @@ class Message(Python3StringMixIn): message belongs to a single thread. :returns: String with a thread ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._get_thread_id(self._msg).decode('utf-8', 'ignore') @@ -399,15 +176,17 @@ class Message(Python3StringMixIn): an empty Messages iterator. :returns: :class:`Messages`. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() msgs_p = Message._get_replies(self._msg) - if msgs_p is None: + from .messages import Messages, EmptyMessagesResult + + if not msgs_p: return EmptyMessagesResult(self) return Messages(msgs_p, self) @@ -421,11 +200,11 @@ class Message(Python3StringMixIn): :returns: A time_t timestamp. :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._get_date(self._msg) def get_header(self, header): @@ -441,30 +220,28 @@ class Message(Python3StringMixIn): It is not case-sensitive. :type header: str :returns: The header value as string - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if the message - is not initialized. - * STATUS.NULL_POINTER if any error occured. + :raises: :exc:`NotInitializedError` if the message is not + initialized + :raises: :exc:`NullPointerError` if any error occured """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() #Returns NULL if any error occurs. header = Message._get_header(self._msg, _str(header)) if header == None: - raise NotmuchError(STATUS.NULL_POINTER) + raise NullPointerError() return header.decode('UTF-8', 'ignore') def get_filename(self): """Returns the file path of the message file :returns: Absolute file path & name of the message file - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._get_filename(self._msg).decode('utf-8', 'ignore') def get_filenames(self): @@ -473,12 +250,12 @@ class Message(Python3StringMixIn): Returns a Filenames() generator with all absolute filepaths for messages recorded to have the same Message-ID. These files must not necessarily have identical content.""" - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() files_p = Message._get_filenames(self._msg) - return Filenames(files_p, self).as_generator() + return Filenames(files_p, self) def get_flag(self, flag): """Checks whether a specific flag is set for this message @@ -490,11 +267,11 @@ class Message(Python3StringMixIn): :param flag: One of the :attr:`Message.FLAG` values (currently only *Message.FLAG.MATCH* :returns: An unsigned int (0/1), indicating whether the flag is set. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._get_flag(self._msg, flag) def set_flag(self, flag, value): @@ -504,30 +281,27 @@ class Message(Python3StringMixIn): *Message.FLAG.MATCH* :param value: A bool indicating whether to set or unset the flag. - :returns: Nothing - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() self._set_flag(self._msg, flag, value) def get_tags(self): """Returns the message tags :returns: A :class:`Tags` iterator. - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if the message - is not initialized. - * STATUS.NULL_POINTER, on error + :raises: :exc:`NotInitializedError` if the message is not + initialized + :raises: :exc:`NullPointerError` if any error occured """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() tags_p = Message._get_tags(self._msg) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) + if not tags_p: + raise NullPointerError() return Tags(tags_p, self) _add_tag = nmlib.notmuch_message_add_tag @@ -552,21 +326,16 @@ class Message(Python3StringMixIn): :returns: STATUS.SUCCESS if the tag was successfully added. Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - 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. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + :raises: :exc:`NullPointerError` if the `tag` argument is NULL + :raises: :exc:`TagTooLongError` if the length of `tag` exceeds + Message.NOTMUCH_TAG_MAX) + :raises: :exc:`ReadOnlyDatabaseError` if the database was opened + in read-only mode so message cannot be modified + :raises: :exc:`NotInitializedError` if message has not been + initialized + """ + if not self._msg: + raise NotInitializedError() status = self._add_tag(self._msg, _str(tag)) @@ -600,21 +369,16 @@ class Message(Python3StringMixIn): :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 - STATUS.TAG_TOO_LONG - The length of 'tag' is too long - (exceeds 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. + :raises: :exc:`NullPointerError` if the `tag` argument is NULL + :raises: :exc:`TagTooLongError` if the length of `tag` exceeds + Message.NOTMUCH_TAG_MAX) + :raises: :exc:`ReadOnlyDatabaseError` if the database was opened + in read-only mode so message cannot be modified + :raises: :exc:`NotInitializedError` if message has not been + initialized """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() status = self._remove_tag(self._msg, _str(tag)) # bail out on error @@ -646,16 +410,13 @@ class Message(Python3StringMixIn): :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. + :raises: :exc:`ReadOnlyDatabaseError` if the database was opened + in read-only mode so message cannot be modified + :raises: :exc:`NotInitializedError` if message has not been + initialized """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() status = self._remove_all_tags(self._msg) @@ -704,16 +465,13 @@ class Message(Python3StringMixIn): :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. + :raises: :exc:`ReadOnlyDatabaseError` if the database was opened + in read-only mode so message cannot be modified + :raises: :exc:`NotInitializedError` if message has not been + initialized """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() status = self._freeze(self._msg) @@ -742,17 +500,15 @@ class Message(Python3StringMixIn): :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. + :raises: :exc:`UnbalancedFreezeThawError` if 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`. + :raises: :exc:`NotInitializedError` if message has not been + initialized """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() status = self._thaw(self._msg) @@ -787,8 +543,8 @@ class Message(Python3StringMixIn): :returns: a :class:`STATUS` value. In short, you want to see notmuch.STATUS.SUCCESS here. See there for details.""" - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._tags_to_maildir_flags(self._msg) def maildir_flags_to_tags(self): @@ -814,8 +570,8 @@ class Message(Python3StringMixIn): :returns: a :class:`STATUS`. In short, you want to see notmuch.STATUS.SUCCESS here. See there for details.""" - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._msg: + raise NotInitializedError() return Message._tags_to_maildir_flags(self._msg) def __repr__(self): @@ -850,114 +606,10 @@ class Message(Python3StringMixIn): out_part = parts[(num - 1)] return out_part.get_payload(decode=True) - def format_message_internal(self): - """Create an internal representation of the message parts, - which can easily be output to json, text, or another output - format. The argument match tells whether this matched a - query.""" - output = {} - output["id"] = self.get_message_id() - output["match"] = self.is_match() - output["filename"] = self.get_filename() - output["tags"] = list(self.get_tags()) - - headers = {} - for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]: - headers[h] = self.get_header(h) - output["headers"] = headers - - body = [] - parts = self.get_message_parts() - for i in xrange(len(parts)): - msg = parts[i] - part_dict = {} - part_dict["id"] = i + 1 - # We'll be using this is a lot, so let's just get it once. - cont_type = msg.get_content_type() - part_dict["content-type"] = cont_type - # NOTE: - # Now we emulate the current behaviour, where it ignores - # the html if there's a text representation. - # - # This is being worked on, but it will be easier to fix - # here in the future than to end up with another - # incompatible solution. - disposition = msg["Content-Disposition"] - if disposition and disposition.lower().startswith("attachment"): - part_dict["filename"] = msg.get_filename() - else: - if cont_type.lower() == "text/plain": - part_dict["content"] = msg.get_payload() - elif (cont_type.lower() == "text/html" and - i == 0): - part_dict["content"] = msg.get_payload() - body.append(part_dict) - - output["body"] = body - - return output - - def format_message_as_json(self, indent=0): - """Outputs the message as json. This is essentially the same - as python's dict format, but we run it through, just so we - don't have to worry about the details.""" - return json.dumps(self.format_message_internal()) - - def format_message_as_text(self, indent=0): - """Outputs it in the old-fashioned notmuch text form. Will be - easy to change to a new format when the format changes.""" - - format = self.format_message_internal() - output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \ - % (format['id'], indent, format['match'], format['filename']) - output += "\n\fheader{" - - #Todo: this date is supposed to be prettified, as in the index. - output += "\n%s (%s) (" % (format["headers"]["From"], - format["headers"]["Date"]) - output += ", ".join(format["tags"]) - output += ")" - - output += "\nSubject: %s" % format["headers"]["Subject"] - output += "\nFrom: %s" % format["headers"]["From"] - output += "\nTo: %s" % format["headers"]["To"] - if format["headers"]["Cc"]: - output += "\nCc: %s" % format["headers"]["Cc"] - if format["headers"]["Bcc"]: - output += "\nBcc: %s" % format["headers"]["Bcc"] - output += "\nDate: %s" % format["headers"]["Date"] - output += "\n\fheader}" - - output += "\n\fbody{" - - parts = format["body"] - parts.sort(key=lambda x: x['id']) - for p in parts: - if not "filename" in p: - output += "\n\fpart{ " - output += "ID: %d, Content-type: %s\n" % (p["id"], - p["content-type"]) - if "content" in p: - output += "\n%s\n" % p["content"] - else: - output += "Non-text part: %s\n" % p["content-type"] - output += "\n\fpart}" - else: - output += "\n\fattachment{ " - output += "ID: %d, Content-type:%s\n" % (p["id"], - p["content-type"]) - output += "Attachment: %s\n" % p["filename"] - output += "\n\fattachment}\n" - - output += "\n\fbody}\n" - output += "\n\fmessage}" - - return output - def __hash__(self): """Implement hash(), so we can use Message() sets""" file = self.get_filename() - if file is None: + if not file: return None return hash(file) @@ -981,5 +633,5 @@ class Message(Python3StringMixIn): def __del__(self): """Close and free the notmuch Message""" - if self._msg is not None: + if self._msg: self._destroy(self._msg) diff --git a/bindings/python/notmuch/messages.py b/bindings/python/notmuch/messages.py new file mode 100644 index 0000000..e83455b --- /dev/null +++ b/bindings/python/notmuch/messages.py @@ -0,0 +1,282 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> + Jesse Rosenthal <jrosenthal@jhu.edu> +""" + +from .globals import ( + nmlib, + NotmuchTagsP, + NotmuchMessageP, + NotmuchMessagesP, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) +from .tag import Tags +from .message import Message + +import sys + +class Messages(object): + """Represents a list of notmuch messages + + This object provides an iterator over a list of notmuch messages + (Technically, it provides a wrapper for the underlying + *notmuch_messages_t* structure). Do note that the underlying library + only provides a one-time iterator (it cannot reset the iterator to + the start). Thus iterating over the function will "exhaust" the list + of messages, and a subsequent iteration attempt will raise a + :exc:`NotInitializedError`. If you need to + re-iterate over a list of messages you will need to retrieve a new + :class:`Messages` object or cache your :class:`Message`\s in a list + via:: + + msglist = list(msgs) + + You can store and reuse the single :class:`Message` objects as often + as you want as long as you keep the parent :class:`Messages` object + around. (Due to hierarchical memory allocation, all derived + :class:`Message` objects will be invalid when we delete the parent + :class:`Messages` object, even if it was already exhausted.) So + this works:: + + db = Database() + msgs = Query(db,'').search_messages() #get a Messages() object + msglist = list(msgs) + + # msgs is "exhausted" now and msgs.next() will raise an exception. + # However it will be kept alive until all retrieved Message() + # objects are also deleted. If you do e.g. an explicit del(msgs) + # here, the following lines would fail. + + # You can reiterate over *msglist* however as often as you want. + # It is simply a list with :class:`Message`s. + + print (msglist[0].get_filename()) + print (msglist[1].get_filename()) + print (msglist[0].get_message_id()) + + + As :class:`Message` implements both __hash__() and __cmp__(), it is + possible to make sets out of :class:`Messages` and use set + arithmetic (this happens in python and will of course be *much* + slower than redoing a proper query with the appropriate filters:: + + s1, s2 = set(msgs1), set(msgs2) + s.union(s2) + s1 -= s2 + ... + + Be careful when using set arithmetic between message sets derived + from different Databases (ie the same database reopened after + messages have changed). If messages have added or removed associated + files in the meantime, it is possible that the same message would be + considered as a different object (as it points to a different file). + """ + + #notmuch_messages_get + _get = nmlib.notmuch_messages_get + _get.argtypes = [NotmuchMessagesP] + _get.restype = NotmuchMessageP + + _collect_tags = nmlib.notmuch_messages_collect_tags + _collect_tags.argtypes = [NotmuchMessagesP] + _collect_tags.restype = NotmuchTagsP + + def __init__(self, msgs_p, parent=None): + """ + :param msgs_p: A pointer to an underlying *notmuch_messages_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Messages` object + herself. They are usually handed back as a result, + e.g. in :meth:`Query.search_messages`. *msgs_p* must be + valid, we will raise an :exc:`NullPointerError` if it is + `None`. + :type msgs_p: :class:`ctypes.c_void_p` + :param parent: The parent object + (ie :class:`Query`) these tags are derived from. It saves + a reference to it, so we can automatically delete the db + object once all derived objects are dead. + :TODO: Make the iterator work more than once and cache the tags in + the Python object.(?) + """ + if not msgs_p: + raise NullPointerError() + + self._msgs = msgs_p + #store parent, so we keep them alive as long as self is alive + self._parent = parent + + def collect_tags(self): + """Return the unique :class:`Tags` in the contained messages + + :returns: :class:`Tags` + :exceptions: :exc:`NotInitializedError` if not init'ed + + .. note:: + + :meth:`collect_tags` will iterate over the messages and therefore + will not allow further iterations. + """ + if not self._msgs: + raise NotInitializedError() + + # collect all tags (returns NULL on error) + tags_p = Messages._collect_tags(self._msgs) + #reset _msgs as we iterated over it and can do so only once + self._msgs = None + + if not tags_p: + raise NullPointerError() + return Tags(tags_p, self) + + def __iter__(self): + """ Make Messages an iterator """ + return self + + _valid = nmlib.notmuch_messages_valid + _valid.argtypes = [NotmuchMessagesP] + _valid.restype = bool + + _move_to_next = nmlib.notmuch_messages_move_to_next + _move_to_next.argtypes = [NotmuchMessagesP] + _move_to_next.restype = None + + def __next__(self): + if not self._msgs: + raise NotInitializedError() + + if not self._valid(self._msgs): + self._msgs = None + raise StopIteration + + msg = Message(Messages._get(self._msgs), self) + self._move_to_next(self._msgs) + return msg + next = __next__ # python2.x iterator protocol compatibility + + def __nonzero__(self): + ''' + Implement truth value testing. If __nonzero__ is not + implemented, the python runtime would fall back to `len(..) > + 0` thus exhausting the iterator. + + :returns: True if the wrapped iterator has at least one more object + left. + ''' + return self._msgs and self._valid(self._msgs) + + _destroy = nmlib.notmuch_messages_destroy + _destroy.argtypes = [NotmuchMessagesP] + _destroy.restype = None + + def __del__(self): + """Close and free the notmuch Messages""" + if self._msgs: + self._destroy(self._msgs) + + def format_messages(self, format, indent=0, entire_thread=False): + """Formats messages as needed for 'notmuch show'. + + :param format: A string of either 'text' or 'json'. + :param indent: A number indicating the reply depth of these messages. + :param entire_thread: A bool, indicating whether we want to output + whole threads or only the matching messages. + :return: a list of lines + + .. deprecated:: 0.14 + This code adds functionality at the python + level that is unlikely to be useful for + anyone. Furthermore the python bindings strive + to be a thin wrapper around libnotmuch, so + this code will be removed in notmuch 0.15. + """ + result = list() + + if format.lower() == "text": + set_start = "" + set_end = "" + set_sep = "" + elif format.lower() == "json": + set_start = "[" + set_end = "]" + set_sep = ", " + else: + raise TypeError("format must be either 'text' or 'json'") + + first_set = True + + result.append(set_start) + + # iterate through all toplevel messages in this thread + for msg in self: + # if not msg: + # break + if not first_set: + result.append(set_sep) + first_set = False + + result.append(set_start) + match = msg.is_match() + next_indent = indent + + if (match or entire_thread): + if format.lower() == "text": + result.append(msg.format_message_as_text(indent)) + else: + result.append(msg.format_message_as_json(indent)) + next_indent = indent + 1 + + # get replies and print them also out (if there are any) + replies = msg.get_replies().format_messages(format, next_indent, entire_thread) + if replies: + result.append(set_sep) + result.extend(replies) + + result.append(set_end) + result.append(set_end) + + return result + + def print_messages(self, format, indent=0, entire_thread=False, handle=sys.stdout): + """Outputs messages as needed for 'notmuch show' to a file like object. + + :param format: A string of either 'text' or 'json'. + :param handle: A file like object to print to (default is sys.stdout). + :param indent: A number indicating the reply depth of these messages. + :param entire_thread: A bool, indicating whether we want to output + whole threads or only the matching messages. + + .. deprecated:: 0.14 + This code adds functionality at the python + level that is unlikely to be useful for + anyone. Furthermore the python bindings strive + to be a thin wrapper around libnotmuch, so + this code will be removed in notmuch 0.15. + """ + handle.write(''.join(self.format_messages(format, indent, entire_thread))) + +class EmptyMessagesResult(Messages): + def __init__(self, parent): + self._msgs = None + self._parent = parent + + def __next__(self): + raise StopIteration() + next = __next__ diff --git a/bindings/python/notmuch/query.py b/bindings/python/notmuch/query.py new file mode 100644 index 0000000..4abba5b --- /dev/null +++ b/bindings/python/notmuch/query.py @@ -0,0 +1,207 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +""" + +from ctypes import c_char_p, c_uint +from .globals import ( + nmlib, + Enum, + _str, + NotmuchQueryP, + NotmuchThreadsP, + NotmuchDatabaseP, + NotmuchMessagesP, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) +from .threads import Threads +from .messages import Messages + + +class Query(object): + """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. + + :class:`Query` provides an instance attribute :attr:`sort`, which + contains the sort order (if specified via :meth:`set_sort`) or + `None`. + + Any function in this class may throw an :exc:`NotInitializedError` + in case the underlying query object was not set up correctly. + + .. note:: Do remember that as soon as we tear down this object, + all underlying derived objects such as threads, + messages, tags etc will be freed by the underlying library + as well. Accessing these objects will lead to segfaults and + other unexpected behavior. See above for more details. + """ + # constants + SORT = Enum(['OLDEST_FIRST', 'NEWEST_FIRST', 'MESSAGE_ID', 'UNSORTED']) + """Constants: Sort order in which to return results""" + + def __init__(self, db, querystr): + """ + :param db: An open database which we derive the Query from. + :type db: :class:`Database` + :param querystr: The query string for the message. + :type querystr: utf-8 encoded str or unicode + """ + self._db = None + self._query = None + self.sort = None + self.create(db, querystr) + + def _assert_query_is_initialized(self): + """Raises :exc:`NotInitializedError` if self._query is `None`""" + if not self._query: + raise NotInitializedError() + + """notmuch_query_create""" + _create = nmlib.notmuch_query_create + _create.argtypes = [NotmuchDatabaseP, c_char_p] + _create.restype = NotmuchQueryP + + def create(self, db, querystr): + """Creates a new query derived from a Database + + This function is utilized by __init__() and usually does not need to + be called directly. + + :param db: Database to create the query from. + :type db: :class:`Database` + :param querystr: The query string + :type querystr: utf-8 encoded str or unicode + :raises: + :exc:`NullPointerError` if the query creation failed + (e.g. too little memory). + :exc:`NotInitializedError` if the underlying db was not + intitialized. + """ + db._assert_db_is_initialized() + # create reference to parent db to keep it alive + self._db = db + # create query, return None if too little mem available + query_p = Query._create(db.db_p, _str(querystr)) + if not query_p: + raise NullPointerError + self._query = query_p + + _set_sort = nmlib.notmuch_query_set_sort + _set_sort.argtypes = [NotmuchQueryP, c_uint] + _set_sort.argtypes = None + + def set_sort(self, sort): + """Set the sort order future results will be delivered in + + :param sort: Sort order (see :attr:`Query.SORT`) + """ + self._assert_query_is_initialized() + self.sort = sort + self._set_sort(self._query, sort) + + """notmuch_query_search_threads""" + _search_threads = nmlib.notmuch_query_search_threads + _search_threads.argtypes = [NotmuchQueryP] + _search_threads.restype = NotmuchThreadsP + + def search_threads(self): + """Execute a query for threads + + Execute a query for threads, returning a :class:`Threads` iterator. + The returned threads are owned by the query and as such, will only be + valid until the Query is deleted. + + The method sets :attr:`Message.FLAG`\.MATCH for those messages that + match the query. The method :meth:`Message.get_flag` allows us + to get the value of this flag. + + :returns: :class:`Threads` + :raises: :exc:`NullPointerError` if search_threads failed + """ + self._assert_query_is_initialized() + threads_p = Query._search_threads(self._query) + + if not threads_p: + raise NullPointerError + return Threads(threads_p, self) + + """notmuch_query_search_messages""" + _search_messages = nmlib.notmuch_query_search_messages + _search_messages.argtypes = [NotmuchQueryP] + _search_messages.restype = NotmuchMessagesP + + def search_messages(self): + """Filter messages according to the query and return + :class:`Messages` in the defined sort order + + :returns: :class:`Messages` + :raises: :exc:`NullPointerError` if search_messages failed + """ + self._assert_query_is_initialized() + msgs_p = Query._search_messages(self._query) + + if not msgs_p: + raise NullPointerError + return Messages(msgs_p, self) + + _count_messages = nmlib.notmuch_query_count_messages + _count_messages.argtypes = [NotmuchQueryP] + _count_messages.restype = c_uint + + def count_messages(self): + ''' + This function performs a search and returns Xapian's best + guess as to the number of matching messages. + + :returns: the estimated number of messages matching this query + :rtype: int + ''' + self._assert_query_is_initialized() + return Query._count_messages(self._query) + + _count_threads = nmlib.notmuch_query_count_threads + _count_threads.argtypes = [NotmuchQueryP] + _count_threads.restype = c_uint + + def count_threads(self): + ''' + This function performs a search and returns the number of + unique thread IDs in the matching messages. This is the same + as number of threads matching a search. + + Note that this is a significantly heavier operation than + meth:`Query.count_messages`. + + :returns: the number of threads returned by this query + :rtype: int + ''' + self._assert_query_is_initialized() + return Query._count_threads(self._query) + + _destroy = nmlib.notmuch_query_destroy + _destroy.argtypes = [NotmuchQueryP] + _destroy.restype = None + + def __del__(self): + """Close and free the Query""" + if self._query: + self._destroy(self._query) diff --git a/bindings/python/notmuch/tag.py b/bindings/python/notmuch/tag.py index ceb7244..1d52345 100644 --- a/bindings/python/notmuch/tag.py +++ b/bindings/python/notmuch/tag.py @@ -14,10 +14,18 @@ for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see <http://www.gnu.org/licenses/>. -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> """ from ctypes import c_char_p -from notmuch.globals import nmlib, STATUS, NotmuchError, NotmuchTagsP, _str, Python3StringMixIn +from .globals import ( + nmlib, + Python3StringMixIn, + NotmuchTagsP, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) class Tags(Python3StringMixIn): @@ -29,9 +37,9 @@ class Tags(Python3StringMixIn): Do note that the underlying library only provides a one-time iterator (it cannot reset the iterator to the start). Thus iterating over the function will "exhaust" the list of tags, and a subsequent - iteration attempt will raise a :exc:`NotmuchError` - STATUS.NOT_INITIALIZED. Also note, that any function that uses - iteration (nearly all) will also exhaust the tags. So both:: + iteration attempt will raise a :exc:`NotInitializedError`. + Also note, that any function that uses iteration (nearly all) will + also exhaust the tags. So both:: for tag in tags: print tag @@ -60,8 +68,8 @@ class Tags(Python3StringMixIn): will almost never instantiate a :class:`Tags` object herself. They are usually handed back as a result, e.g. in :meth:`Database.get_all_tags`. *tags_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. + valid, we will raise an :exc:`NullPointerError` if it is + `None`. :type tags_p: :class:`ctypes.c_void_p` :param parent: The parent object (ie :class:`Database` or :class:`Message` these tags are derived from, and saves a @@ -70,8 +78,8 @@ class Tags(Python3StringMixIn): :TODO: Make the iterator optionally work more than once by cache the tags in the Python object(?) """ - if tags_p is None: - raise NotmuchError(STATUS.NULL_POINTER) + if not tags_p: + raise NullPointerError() self._tags = tags_p #save reference to parent object so we keep it alive @@ -90,8 +98,8 @@ class Tags(Python3StringMixIn): _move_to_next.restype = None def __next__(self): - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._tags: + raise NotInitializedError() if not self._valid(self._tags): self._tags = None raise StopIteration @@ -101,15 +109,15 @@ class Tags(Python3StringMixIn): next = __next__ # python2.x iterator protocol compatibility def __nonzero__(self): - """Implement bool(Tags) check that can be repeatedly used + ''' + Implement truth value testing. If __nonzero__ is not + implemented, the python runtime would fall back to `len(..) > + 0` thus exhausting the iterator. - If __nonzero__ is not implemented, "if Tags()" - will implicitly call __len__, using up our iterator, so it is - important that this function is defined. - - :returns: True if the Tags() iterator has at least one more Tag - left.""" - return self._valid(self._tags) > 0 + :returns: True if the wrapped iterator has at least one more object + left. + ''' + return self._tags and self._valid(self._tags) def __unicode__(self): """string representation of :class:`Tags`: a space separated list of tags @@ -118,8 +126,8 @@ class Tags(Python3StringMixIn): As this iterates over the tags, we will not be able to iterate over them again (as in retrieve them)! If the tags have been exhausted - already, this will raise a :exc:`NotmuchError` - STATUS.NOT_INITIALIZED on subsequent attempts. + already, this will raise a :exc:`NotInitializedError`on subsequent + attempts. """ return " ".join(self) @@ -129,5 +137,5 @@ class Tags(Python3StringMixIn): def __del__(self): """Close and free the notmuch tags""" - if self._tags is not None: + if self._tags: self._destroy(self._tags) diff --git a/bindings/python/notmuch/thread.py b/bindings/python/notmuch/thread.py index e81ff1b..009cb2b 100644 --- a/bindings/python/notmuch/thread.py +++ b/bindings/python/notmuch/thread.py @@ -14,169 +14,24 @@ for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see <http://www.gnu.org/licenses/>. -Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>' +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> """ from ctypes import c_char_p, c_long, c_int -from notmuch.globals import (nmlib, STATUS, - NotmuchError, NotmuchThreadP, NotmuchThreadsP, NotmuchMessagesP, - NotmuchTagsP, Python3StringMixIn) -from notmuch.message import Messages -from notmuch.tag import Tags +from .globals import ( + nmlib, + NotmuchThreadP, + NotmuchMessagesP, + NotmuchTagsP, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) +from .messages import Messages +from .tag import Tags from datetime import date - -class Threads(Python3StringMixIn): - """Represents a list of notmuch threads - - This object provides an iterator over a list of notmuch threads - (Technically, it provides a wrapper for the underlying - *notmuch_threads_t* structure). Do note that the underlying - library only provides a one-time iterator (it cannot reset the - iterator to the start). Thus iterating over the function will - "exhaust" the list of threads, and a subsequent iteration attempt - will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also - note, that any function that uses iteration will also - exhaust the messages. So both:: - - for thread in threads: print thread - - as well as:: - - number_of_msgs = len(threads) - - will "exhaust" the threads. If you need to re-iterate over a list of - messages you will need to retrieve a new :class:`Threads` object. - - Things are not as bad as it seems though, you can store and reuse - the single Thread objects as often as you want as long as you - keep the parent Threads object around. (Recall that due to - hierarchical memory allocation, all derived Threads objects will - be invalid when we delete the parent Threads() object, even if it - was already "exhausted".) So this works:: - - db = Database() - threads = Query(db,'').search_threads() #get a Threads() object - threadlist = [] - for thread in threads: - threadlist.append(thread) - - # threads is "exhausted" now and even len(threads) will raise an - # exception. - # However it will be kept around until all retrieved Thread() objects are - # also deleted. If you did e.g. an explicit del(threads) here, the - # following lines would fail. - - # You can reiterate over *threadlist* however as often as you want. - # It is simply a list with Thread objects. - - print (threadlist[0].get_thread_id()) - print (threadlist[1].get_thread_id()) - print (threadlist[0].get_total_messages()) - """ - - #notmuch_threads_get - _get = nmlib.notmuch_threads_get - _get.argtypes = [NotmuchThreadsP] - _get.restype = NotmuchThreadP - - def __init__(self, threads_p, parent=None): - """ - :param threads_p: A pointer to an underlying *notmuch_threads_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Threads` object - herself. They are usually handed back as a result, - e.g. in :meth:`Query.search_threads`. *threads_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. - :type threads_p: :class:`ctypes.c_void_p` - :param parent: The parent object - (ie :class:`Query`) these tags are derived from. It saves - a reference to it, so we can automatically delete the db - object once all derived objects are dead. - :TODO: Make the iterator work more than once and cache the tags in - the Python object.(?) - """ - if threads_p is None: - raise NotmuchError(STATUS.NULL_POINTER) - - self._threads = threads_p - #store parent, so we keep them alive as long as self is alive - self._parent = parent - - def __iter__(self): - """ Make Threads an iterator """ - return self - - _valid = nmlib.notmuch_threads_valid - _valid.argtypes = [NotmuchThreadsP] - _valid.restype = bool - - _move_to_next = nmlib.notmuch_threads_move_to_next - _move_to_next.argtypes = [NotmuchThreadsP] - _move_to_next.restype = None - - def __next__(self): - if self._threads is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not self._valid(self._threads): - self._threads = None - raise StopIteration - - thread = Thread(Threads._get(self._threads), self) - self._move_to_next(self._threads) - return thread - next = __next__ # python2.x iterator protocol compatibility - - def __len__(self): - """len(:class:`Threads`) returns the number of contained Threads - - .. note:: As this iterates over the threads, we will not be able to - iterate over them again! So this will fail:: - - #THIS FAILS - threads = Database().create_query('').search_threads() - if len(threads) > 0: #this 'exhausts' threads - # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! - for thread in threads: print thread - """ - if self._threads is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i = 0 - # returns 'bool'. On out-of-memory it returns None - while self._valid(self._threads): - self._move_to_next(self._threads) - i += 1 - # reset self._threads to mark as "exhausted" - self._threads = None - return i - - def __nonzero__(self): - """Check if :class:`Threads` contains at least one more valid thread - - The existence of this function makes 'if Threads: foo' work, as - that will implicitely call len() exhausting the iterator if - __nonzero__ does not exist. This function makes `bool(Threads())` - work repeatedly. - - :return: True if there is at least one more thread in the - Iterator, False if not. None on a "Out-of-memory" error. - """ - return self._threads is not None and \ - self._valid(self._threads) > 0 - - _destroy = nmlib.notmuch_threads_destroy - _destroy.argtypes = [NotmuchThreadsP] - _destroy.argtypes = None - - def __del__(self): - """Close and free the notmuch Threads""" - if self._threads is not None: - self._destroy(self._threads) - - class Thread(object): """Represents a single message thread.""" @@ -220,16 +75,16 @@ class Thread(object): will almost never instantiate a :class:`Thread` object herself. They are usually handed back as a result, e.g. when iterating through :class:`Threads`. *thread_p* - must be valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. + must be valid, we will raise an :exc:`NullPointerError` + if it is `None`. :param parent: A 'parent' object is passed which this message is derived from. We save a reference to it, so we can automatically delete the parent object once all derived objects are dead. """ - if thread_p is None: - raise NotmuchError(STATUS.NULL_POINTER) + if not thread_p: + raise NullPointerError() self._thread = thread_p #keep reference to parent, so we keep it alive self._parent = parent @@ -241,11 +96,11 @@ class Thread(object): for as long as the thread is valid. :returns: String with a message ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + :raises: :exc:`NotInitializedError` if the thread is not initialized. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() return Thread._get_thread_id(self._thread).decode('utf-8', 'ignore') _get_total_messages = nmlib.notmuch_thread_get_total_messages @@ -258,11 +113,11 @@ class Thread(object): :returns: The number of all messages in the database belonging to this thread. Contrast with :meth:`get_matched_messages`. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + :raises: :exc:`NotInitializedError` if the thread is not initialized. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() return self._get_total_messages(self._thread) def get_toplevel_messages(self): @@ -279,18 +134,16 @@ class Thread(object): messages, etc.). :returns: :class:`Messages` - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if query is not inited - * STATUS.NULL_POINTER if search_messages failed + :raises: :exc:`NotInitializedError` if query is not initialized + :raises: :exc:`NullPointerError` if search_messages failed """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() msgs_p = Thread._get_toplevel_messages(self._thread) - if msgs_p is None: - raise NotmuchError(STATUS.NULL_POINTER) + if not msgs_p: + raise NullPointerError() return Messages(msgs_p, self) @@ -304,11 +157,11 @@ class Thread(object): :returns: The number of all messages belonging to this thread that matched the :class:`Query`from which this thread was created. Contrast with :meth:`get_total_messages`. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + :raises: :exc:`NotInitializedError` if the thread is not initialized. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() return self._get_matched_messages(self._thread) def get_authors(self): @@ -321,10 +174,10 @@ class Thread(object): The returned string belongs to 'thread' and will only be valid for as long as this Thread() is not deleted. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() authors = Thread._get_authors(self._thread) - if authors is None: + if not authors: return None return authors.decode('UTF-8', 'ignore') @@ -334,10 +187,10 @@ class Thread(object): The returned string belongs to 'thread' and will only be valid for as long as this Thread() is not deleted. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() subject = Thread._get_subject(self._thread) - if subject is None: + if not subject: return None return subject.decode('UTF-8', 'ignore') @@ -346,11 +199,11 @@ class Thread(object): :returns: A time_t timestamp. :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() return Thread._get_newest_date(self._thread) def get_oldest_date(self): @@ -358,11 +211,11 @@ class Thread(object): :returns: A time_t timestamp. :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() return Thread._get_oldest_date(self._thread) def get_tags(self): @@ -378,18 +231,15 @@ class Thread(object): query from which it derived is explicitely deleted). :returns: A :class:`Tags` iterator. - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if the thread - is not initialized. - * STATUS.NULL_POINTER, on error + :raises: :exc:`NotInitializedError` if query is not initialized + :raises: :exc:`NullPointerError` if search_messages failed """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + if not self._thread: + raise NotInitializedError() tags_p = Thread._get_tags(self._thread) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) + if not tags_p: + raise NullPointerError() return Tags(tags_p, self) def __unicode__(self): @@ -410,5 +260,5 @@ class Thread(object): def __del__(self): """Close and free the notmuch Thread""" - if self._thread is not None: + if self._thread: self._destroy(self._thread) diff --git a/bindings/python/notmuch/threads.py b/bindings/python/notmuch/threads.py new file mode 100644 index 0000000..f8ca34a --- /dev/null +++ b/bindings/python/notmuch/threads.py @@ -0,0 +1,177 @@ +""" +This file is part of notmuch. + +Notmuch is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation, either version 3 of the License, or (at your +option) any later version. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see <http://www.gnu.org/licenses/>. + +Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de> +""" + +from .globals import ( + nmlib, + Python3StringMixIn, + NotmuchThreadP, + NotmuchThreadsP, +) +from .errors import ( + NullPointerError, + NotInitializedError, +) +from .thread import Thread + +class Threads(Python3StringMixIn): + """Represents a list of notmuch threads + + This object provides an iterator over a list of notmuch threads + (Technically, it provides a wrapper for the underlying + *notmuch_threads_t* structure). Do note that the underlying + library only provides a one-time iterator (it cannot reset the + iterator to the start). Thus iterating over the function will + "exhaust" the list of threads, and a subsequent iteration attempt + will raise a :exc:`NotInitializedError`. Also + note, that any function that uses iteration will also + exhaust the messages. So both:: + + for thread in threads: print thread + + as well as:: + + number_of_msgs = len(threads) + + will "exhaust" the threads. If you need to re-iterate over a list of + messages you will need to retrieve a new :class:`Threads` object. + + Things are not as bad as it seems though, you can store and reuse + the single Thread objects as often as you want as long as you + keep the parent Threads object around. (Recall that due to + hierarchical memory allocation, all derived Threads objects will + be invalid when we delete the parent Threads() object, even if it + was already "exhausted".) So this works:: + + db = Database() + threads = Query(db,'').search_threads() #get a Threads() object + threadlist = [] + for thread in threads: + threadlist.append(thread) + + # threads is "exhausted" now and even len(threads) will raise an + # exception. + # However it will be kept around until all retrieved Thread() objects are + # also deleted. If you did e.g. an explicit del(threads) here, the + # following lines would fail. + + # You can reiterate over *threadlist* however as often as you want. + # It is simply a list with Thread objects. + + print (threadlist[0].get_thread_id()) + print (threadlist[1].get_thread_id()) + print (threadlist[0].get_total_messages()) + """ + + #notmuch_threads_get + _get = nmlib.notmuch_threads_get + _get.argtypes = [NotmuchThreadsP] + _get.restype = NotmuchThreadP + + def __init__(self, threads_p, parent=None): + """ + :param threads_p: A pointer to an underlying *notmuch_threads_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Threads` object + herself. They are usually handed back as a result, + e.g. in :meth:`Query.search_threads`. *threads_p* must be + valid, we will raise an :exc:`NullPointerError` if it is + `None`. + :type threads_p: :class:`ctypes.c_void_p` + :param parent: The parent object + (ie :class:`Query`) these tags are derived from. It saves + a reference to it, so we can automatically delete the db + object once all derived objects are dead. + :TODO: Make the iterator work more than once and cache the tags in + the Python object.(?) + """ + if not threads_p: + raise NullPointerError() + + self._threads = threads_p + #store parent, so we keep them alive as long as self is alive + self._parent = parent + + def __iter__(self): + """ Make Threads an iterator """ + return self + + _valid = nmlib.notmuch_threads_valid + _valid.argtypes = [NotmuchThreadsP] + _valid.restype = bool + + _move_to_next = nmlib.notmuch_threads_move_to_next + _move_to_next.argtypes = [NotmuchThreadsP] + _move_to_next.restype = None + + def __next__(self): + if not self._threads: + raise NotInitializedError() + + if not self._valid(self._threads): + self._threads = None + raise StopIteration + + thread = Thread(Threads._get(self._threads), self) + self._move_to_next(self._threads) + return thread + next = __next__ # python2.x iterator protocol compatibility + + def __len__(self): + """len(:class:`Threads`) returns the number of contained Threads + + .. note:: As this iterates over the threads, we will not be able to + iterate over them again! So this will fail:: + + #THIS FAILS + threads = Database().create_query('').search_threads() + if len(threads) > 0: #this 'exhausts' threads + # next line raises :exc:`NotInitializedError`!!! + for thread in threads: print thread + """ + if not self._threads: + raise NotInitializedError() + + i = 0 + # returns 'bool'. On out-of-memory it returns None + while self._valid(self._threads): + self._move_to_next(self._threads) + i += 1 + # reset self._threads to mark as "exhausted" + self._threads = None + return i + + def __nonzero__(self): + ''' + Implement truth value testing. If __nonzero__ is not + implemented, the python runtime would fall back to `len(..) > + 0` thus exhausting the iterator. + + :returns: True if the wrapped iterator has at least one more object + left. + ''' + return self._threads and self._valid(self._threads) + + _destroy = nmlib.notmuch_threads_destroy + _destroy.argtypes = [NotmuchThreadsP] + _destroy.restype = None + + def __del__(self): + """Close and free the notmuch Threads""" + if self._threads: + self._destroy(self._threads) diff --git a/bindings/python/notmuch/version.py b/bindings/python/notmuch/version.py index 59c396f..90bcadb 100644 --- a/bindings/python/notmuch/version.py +++ b/bindings/python/notmuch/version.py @@ -1,2 +1,2 @@ # this file should be kept in sync with ../../../version -__VERSION__ = '0.11' +__VERSION__ = '0.13.2' |