From: Sebastian Spaeth Date: Mon, 19 Apr 2010 19:14:47 +0000 (+0200) Subject: cnotmuch -> notmuch X-Git-Tag: 0.3~121^2~6 X-Git-Url: https://git.notmuchmail.org/git?p=notmuch;a=commitdiff_plain;h=3c6321cdb2cbc11fe654fe49e4beaa5c2479e280 cnotmuch -> notmuch and GPL v3+ boilerplate code to each source file. --HG-- rename : cnotmuch/__init__.py => notmuch/__init__.py rename : cnotmuch/database.py => notmuch/database.py rename : cnotmuch/globals.py => notmuch/globals.py rename : cnotmuch/message.py => notmuch/message.py rename : cnotmuch/notmuch.py => notmuch/notmuch.py rename : cnotmuch/tag.py => notmuch/tag.py rename : cnotmuch/thread.py => notmuch/thread.py --- diff --git a/cnotmuch/__init__.py b/cnotmuch/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cnotmuch/database.py b/cnotmuch/database.py deleted file mode 100644 index fd2d9a99..00000000 --- a/cnotmuch/database.py +++ /dev/null @@ -1,822 +0,0 @@ -import os -from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref -from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.thread import Threads -from cnotmuch.message import Messages, Message -from cnotmuch.tag import Tags - -class Database(object): - """Represents a notmuch database (wraps notmuch_database_t) - - .. note:: Do remember that as soon as we tear down 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""" - - MODE = Enum(['READ_ONLY','READ_WRITE']) - """Constants: Mode in which to open the database""" - - """notmuch_database_get_directory""" - _get_directory = nmlib.notmuch_database_get_directory - _get_directory.restype = c_void_p - - """notmuch_database_get_path""" - _get_path = nmlib.notmuch_database_get_path - _get_path.restype = c_char_p - - """notmuch_database_get_version""" - _get_version = nmlib.notmuch_database_get_version - _get_version.restype = c_uint - - """notmuch_database_open""" - _open = nmlib.notmuch_database_open - _open.restype = c_void_p - - """notmuch_database_upgrade""" - _upgrade = nmlib.notmuch_database_upgrade - _upgrade.argtypes = [c_void_p, c_void_p, c_void_p] - - """ notmuch_database_find_message""" - _find_message = nmlib.notmuch_database_find_message - _find_message.restype = c_void_p - - """notmuch_database_get_all_tags""" - _get_all_tags = nmlib.notmuch_database_get_all_tags - _get_all_tags.restype = c_void_p - - """notmuch_database_create""" - _create = nmlib.notmuch_database_create - _create.restype = c_void_p - - def __init__(self, path=None, create=False, mode= 0): - """If *path* is `None`, we will try to read a users notmuch - configuration and use his configured database. The location of the - configuration file can be specified through the environment variable - *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`. - - If *create* is `True`, the database will always be created in - :attr:`MODE`.READ_WRITE mode. Default mode for opening is READ_ONLY. - - :param path: Directory to open/create the database in (see - above for behavior if `None`) - :type path: `str` or `None` - :param create: Pass `False` to open an existing, `True` to create a new - database. - :type create: bool - :param mode: Mode to open a database in. Is always - :attr:`MODE`.READ_WRITE when creating a new one. - :type mode: :attr:`MODE` - :returns: Nothing - :exception: :exc:`NotmuchError` in case of failure. - """ - self._db = None - if path is None: - # no path specified. use a user's default database - if Database._std_db_path is None: - #the following line throws a NotmuchError if it fails - Database._std_db_path = self._get_user_default_db() - path = Database._std_db_path - - if create == False: - self.open(path, mode) - else: - self.create(path) - - def _verify_initialized_db(self): - """Raises a NotmuchError in case self._db is still None""" - if self._db is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - def create(self, path): - """Creates a new notmuch database - - This function is used by __init__() and usually does not need - to be called directly. It wraps the underlying - *notmuch_database_create* function and creates a new notmuch - database at *path*. It will always return a database in :attr:`MODE` - .READ_WRITE mode as creating an empty database for - reading only does not make a great deal of sense. - - :param path: A directory in which we should create the database. - :type path: str - :returns: Nothing - :exception: :exc:`NotmuchError` in case of any failure - (after printing an error message on stderr). - """ - if self._db is not None: - raise NotmuchError( - message="Cannot create db, this Database() already has an open one.") - - res = Database._create(path, Database.MODE.READ_WRITE) - - if res is None: - raise NotmuchError( - message="Could not create the specified database") - self._db = res - - def open(self, path, mode= 0): - """Opens an existing database - - This function is used by __init__() and usually does not need - to be called directly. It wraps the underlying - *notmuch_database_open* function. - - :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 (after printing an error message on stderr). - """ - - res = Database._open(path, mode) - - if res is None: - raise NotmuchError( - message="Could not open the specified database") - self._db = res - - def get_path(self): - """Returns the file path of an open database - - Wraps *notmuch_database_get_path*.""" - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - return Database._get_path(self._db) - - def get_version(self): - """Returns the database format version - - :returns: The database version as positive integer - :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if - the database was not intitialized. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - return Database._get_version (self._db) - - def needs_upgrade(self): - """Does this database need to be upgraded before writing to it? - - If this function returns `True` then no functions that modify the - database (:meth:`add_message`, - :meth:`Message.add_tag`, :meth:`Directory.set_mtime`, - etc.) will work unless :meth:`upgrade` is called successfully first. - - :returns: `True` or `False` - :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if - the database was not intitialized. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - return notmuch_database_needs_upgrade(self._db) - - def upgrade(self): - """Upgrades the current database - - After opening a database in read-write mode, the client should - check if an upgrade is needed (notmuch_database_needs_upgrade) and - if so, upgrade with this function before making any modifications. - - NOT IMPLEMENTED: The optional progress_notify callback can be - used by the caller to provide progress indication to the - user. If non-NULL it will be called periodically with - 'progress' as a floating-point value in the range of [0.0..1.0] - indicating the progress made so far in the upgrade process. - - :TODO: catch exceptions, document return values and etc... - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - status = Database._upgrade (self._db, None, None) - #TODO: catch exceptions, document return values and etc - return status - - 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 - Database.MODE.READ_WRITE mode. The underlying library will exit the - program if this method is used on a read-only database! - - :param path: A str 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` - - STATUS.NOT_INITIALIZED - If the database was not intitialized. - - STATUS.FILE_ERROR - If path is not relative database or absolute with initial - components same as database. - - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - # sanity checking if path is valid, and make path absolute - if 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.") - 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, path); - - # return the Directory, init it with the absolute path - return Directory(abs_dirpath, dir_p, self) - - def add_message(self, filename): - """Adds a new message to the database - - `filename` should be a path relative to the path of the open - database (see :meth:`get_path`), or else should be an absolute - filename with initial components that match the path of the - database. - - The file should be a single mail message (not a multi-message mbox) - that is expected to remain at its current location, since the - notmuch database will reference the filename, and will not copy the - entire contents of the file. - - :returns: On success, we return - - 1) a :class:`Message` object that can be used for things - such as adding tags to the just-added message. - 2) one of the following STATUS values: - - STATUS.SUCCESS - Message successfully added to database. - STATUS.DUPLICATE_MESSAGE_ID - Message has the same message ID as another message already - in the database. The new filename was successfully added - to the message in the database. - - :rtype: 2-tuple(:class:`Message`, STATUS) - - :exception: Raises a :exc:`NotmuchError` with the following meaning. - If such an exception occurs, nothing was added to the database. - - STATUS.FILE_ERROR - An error occurred trying to open the file, (such as - permission denied, or file not found, etc.). - STATUS.FILE_NOT_EMAIL - The contents of filename don't look like an email message. - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so no message can - be added. - STATUS.NOT_INITIALIZED - The database has not been initialized. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - msg_p = c_void_p() - status = nmlib.notmuch_database_add_message(self._db, - filename, - byref(msg_p)) - - if not status in [STATUS.SUCCESS,STATUS.DUPLICATE_MESSAGE_ID]: - raise NotmuchError(status) - - #construct Message() and return - msg = Message(msg_p, self) - return (msg, status) - - def remove_message(self, filename): - """Removes a message from the given notmuch database - - Note that only this particular filename association is removed from - the database. If the same message (as determined by the message ID) - is still available via other filenames, then the message will - persist in the database for those filenames. When the last filename - is removed for a particular message, the database content for that - message will be entirely removed. - - :returns: A STATUS value with the following meaning: - - STATUS.SUCCESS - The last filename was removed and the message was removed - from the database. - STATUS.DUPLICATE_MESSAGE_ID - This filename was removed but the message persists in the - database with at least one other filename. - - :exception: Raises a :exc:`NotmuchError` with the following meaning. - If such an exception occurs, nothing was removed from the database. - - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so no message can be - removed. - STATUS.NOT_INITIALIZED - The database has not been initialized. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - return nmlib.notmuch_database_remove_message(self._db, - filename) - - def find_message(self, msgid): - """Returns a :class:`Message` as identified by its message ID - - Wraps the underlying *notmuch_database_find_message* function. - - :param msgid: The message ID - :type msgid: string - :returns: :class:`Message` or `None` if no message is found or if an - out-of-memory situation occurs. - :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if - the database was not intitialized. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - msg_p = Database._find_message(self._db, msgid) - if msg_p is None: - return None - return Message(msg_p, self) - - def get_all_tags(self): - """Returns :class:`Tags` with a list of all tags found in the database - - :returns: :class:`Tags` - :execption: :exc:`NotmuchError` with STATUS.NULL_POINTER on error - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - tags_p = Database._get_all_tags (self._db) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def create_query(self, querystring): - """Returns a :class:`Query` derived from this database - - This is a shorthand method for doing:: - - # short version - # Automatically frees the Database() when 'q' is deleted - - q = Database(dbpath).create_query('from:"Biene Maja"') - - # long version, which is functionally equivalent but will keep the - # Database in the 'db' variable around after we delete 'q': - - db = Database(dbpath) - q = Query(db,'from:"Biene Maja"') - - This function is a python extension and not in the underlying C API. - """ - # Raise a NotmuchError if not initialized - self._verify_initialized_db() - - return Query(self, querystring) - - def __repr__(self): - return "'Notmuch DB " + self.get_path() + "'" - - def __del__(self): - """Close and free the notmuch database if needed""" - if self._db is not None: - nmlib.notmuch_database_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""" - from ConfigParser import SafeConfigParser - config = SafeConfigParser() - conf_f = os.getenv('NOTMUCH_CONFIG', - os.path.expanduser('~/.notmuch-config')) - config.read(conf_f) - if not config.has_option('database','path'): - raise NotmuchError(message= - "No DB path specified and no user default found") - 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). - """ - 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. - - Query() provides an instance attribute :attr:`sort`, which - contains the sort order (if specified via :meth:`set_sort`) or - `None`. - - Technically, it wraps the underlying *notmuch_query_t* struct. - - .. 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']) - """Constants: Sort order in which to return results""" - - """notmuch_query_create""" - _create = nmlib.notmuch_query_create - _create.restype = c_void_p - - """notmuch_query_search_threads""" - _search_threads = nmlib.notmuch_query_search_threads - _search_threads.restype = c_void_p - - """notmuch_query_search_messages""" - _search_messages = nmlib.notmuch_query_search_messages - _search_messages.restype = c_void_p - - - """notmuch_query_count_messages""" - _count_messages = nmlib.notmuch_query_count_messages - _count_messages.restype = c_uint - - def __init__(self, db, querystr): - """ - :param db: An open database which we derive the Query from. - :type db: :class:`Database` - :param querystr: The query string for the message. - :type querystr: str - """ - self._db = None - self._query = None - self.sort = None - self.create(db, querystr) - - 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: str - :returns: Nothing - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if db is not inited - * STATUS.NULL_POINTER if the query creation failed - (too little memory) - """ - if db.db_p is None: - raise NotmuchError(STATUS.NOT_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, querystr) - if query_p is None: - NotmuchError(STATUS.NULL_POINTER) - self._query = query_p - - def set_sort(self, sort): - """Set the sort order future results will be delivered in - - Wraps the underlying *notmuch_query_set_sort* function. - - :param sort: Sort order (see :attr:`Query.SORT`) - :returns: Nothing - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if query has not - been initialized. - """ - if self._query is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - self.sort = sort - nmlib.notmuch_query_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. - - Technically, it wraps the underlying - *notmuch_query_search_threads* function. - - :returns: :class:`Threads` - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if query is not inited - * STATUS.NULL_POINTER if search_threads failed - """ - if self._query is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - threads_p = Query._search_threads(self._query) - - if threads_p is None: - NotmuchError(STATUS.NULL_POINTER) - - return Threads(threads_p,self) - - def search_messages(self): - """Filter messages according to the query and return - :class:`Messages` in the defined sort order - - Technically, it wraps the underlying - *notmuch_query_search_messages* function. - - :returns: :class:`Messages` - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if query is not inited - * STATUS.NULL_POINTER if search_messages failed - """ - if self._query is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - msgs_p = Query._search_messages(self._query) - - if msgs_p is None: - NotmuchError(STATUS.NULL_POINTER) - - return Messages(msgs_p,self) - - def count_messages(self): - """Estimate the number of messages matching the query - - This function performs a search and returns Xapian's best - guess as to the number of matching messages. It is much faster - than performing :meth:`search_messages` and counting the - result with `len()` (although it always returned the same - result in my tests). Technically, it wraps the underlying - *notmuch_query_count_messages* function. - - :returns: :class:`Messages` - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if query is not inited - """ - if self._query is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - return Query._count_messages(self._query) - - def __del__(self): - """Close and free the Query""" - if self._query is not None: - nmlib.notmuch_query_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.restype = c_long - - """notmuch_directory_set_mtime""" - _set_mtime = nmlib.notmuch_directory_set_mtime - _set_mtime.argtypes = [c_char_p, c_long] - - """notmuch_directory_get_child_files""" - _get_child_files = nmlib.notmuch_directory_get_child_files - _get_child_files.restype = c_void_p - - """notmuch_directory_get_child_directories""" - _get_child_directories = nmlib.notmuch_directory_get_child_directories - _get_child_directories.restype = c_void_p - - def _verify_dir_initialized(self): - """Raises a NotmuchError(STATUS.NOT_INITIALIZED) if the 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. - :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 - :returns: Nothing on success, raising an exception on failure. - :exception: :exc:`NotmuchError`: - - STATUS.XAPIAN_EXCEPTION - A Xapian exception occurred, mtime not stored. - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so directory - mtime cannot be modified. - STATUS.NOT_INITIALIZED - The directory has not been initialized - """ - #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if the dir_p is None - self._verify_dir_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`: - - STATUS.NOT_INITIALIZED - The directory has not been initialized - """ - #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self.dir_p is None - self._verify_dir_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. - """ - #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None - self._verify_dir_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. - """ - #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None - self._verify_dir_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 "" % self._path - - def __del__(self): - """Close and free the Directory""" - if self._dir_p is not None: - nmlib.notmuch_directory_destroy(self._dir_p) - -#------------------------------------------------------------------------------ -class Filenames(object): - """An iterator over File- or Directory names that are stored in the database - """ - - #notmuch_filenames_get - _get = nmlib.notmuch_filenames_get - _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 - - def next(self): - if self._files_p is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_filenames_valid(self._files_p): - self._files_p = None - raise StopIteration - - file = Filenames._get (self._files_p) - nmlib.notmuch_filenames_move_to_next(self._files_p) - return file - - 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(STATUS.NOT_INITIALIZED)!!! - for file in files: print file - """ - if self._files_p is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i=0 - while nmlib.notmuch_filenames_valid(self._files_p): - nmlib.notmuch_filenames_move_to_next(self._files_p) - i += 1 - self._files_p = None - return i - - def __del__(self): - """Close and free Filenames""" - if self._files_p is not None: - nmlib.notmuch_filenames_destroy(self._files_p) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py deleted file mode 100644 index fa20ae81..00000000 --- a/cnotmuch/globals.py +++ /dev/null @@ -1,61 +0,0 @@ -from ctypes import CDLL, c_char_p, c_int -from ctypes.util import find_library - -#----------------------------------------------------------------------------- -#package-global instance of the notmuch library -try: - nmlib = CDLL("libnotmuch.so.1") -except: - raise ImportError("Could not find shared 'notmuch' library.") - -#----------------------------------------------------------------------------- -class Enum(object): - """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" - def __init__(self, names): - for number, name in enumerate(names): - setattr(self, name, number) - -#----------------------------------------------------------------------------- -class Status(Enum): - """Enum with a string representation of a notmuch_status_t value.""" - __name__="foo" - _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 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 str(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', - 'NOT_INITIALIZED']) - - -class NotmuchError(Exception): - def __init__(self, status=None, message=None): - """Is initiated with a (notmuch.STATUS[,message=None])""" - super(NotmuchError, self).__init__(message, status) - - def __str__(self): - if self.args[0] is not None: return self.args[0] - else: return STATUS.status2str(self.args[1]) - diff --git a/cnotmuch/message.py b/cnotmuch/message.py deleted file mode 100644 index 0e5057f4..00000000 --- a/cnotmuch/message.py +++ /dev/null @@ -1,774 +0,0 @@ -# This file is part of cnotmuch. -# -# cnotmuch 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. -# -# cnotmuch 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 cnotmuch. If not, see . -# -# (C) Copyright 2010 Sebastian Spaeth -# Jesse Rosenthal - -from ctypes import c_char_p, c_void_p, c_long, c_uint -from datetime import date -from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.tag import Tags -import sys -import email -import types -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. Also - note, that any function that uses iteration will also - exhaust the messages. So both:: - - for msg in msgs: print msg - - as well as:: - - number_of_msgs = len(msgs) - - will "exhaust" the Messages. If you need to re-iterate over a list of - messages you will need to retrieve a new :class:`Messages` object. - - Things are not as bad as it seems though, you can store and reuse - the single Message objects as often as you want as long as you - keep the parent Messages object around. (Recall that due to - hierarchical memory allocation, all derived Message objects will - be invalid when we delete the parent Messages() object, even if it - was already "exhausted".) So this works:: - - db = Database() - msgs = Query(db,'').search_messages() #get a Messages() object - msglist = [] - for m in msgs: - msglist.append(m) - - # msgs is "exhausted" now and even len(msgs) will raise an exception. - # However it will be kept around until all retrieved Message() objects are - # also deleted. If you did 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 Message objects. - - print (msglist[0].get_filename()) - print (msglist[1].get_filename()) - print (msglist[0].get_message_id()) - """ - - #notmuch_tags_get - _get = nmlib.notmuch_messages_get - _get.restype = c_void_p - - _collect_tags = nmlib.notmuch_messages_collect_tags - _collect_tags.restype = c_void_p - - 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: - 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 inited - - .. 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 - - def next(self): - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_messages_valid(self._msgs): - self._msgs = None - raise StopIteration - - msg = Message(Messages._get (self._msgs), self) - nmlib.notmuch_messages_move_to_next(self._msgs) - return msg - - def __len__(self): - """len(:class:`Messages`) returns the number of contained messages - - .. note:: As this iterates over the messages, we will not be able to - iterate over them again! So this will fail:: - - #THIS FAILS - msgs = Database().create_query('').search_message() - if len(msgs) > 0: #this 'exhausts' msgs - # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! - for msg in msgs: print msg - - Most of the time, using the - :meth:`Query.count_messages` is therefore more - appropriate (and much faster). While not guaranteeing - that it will return the exact same number than len(), - in my tests it effectively always did so. - """ - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i=0 - while nmlib.notmuch_messages_valid(self._msgs): - nmlib.notmuch_messages_move_to_next(self._msgs) - i += 1 - self._msgs = None - return i - - def __del__(self): - """Close and free the notmuch Messages""" - if self._msgs is not None: - nmlib.notmuch_messages_destroy (self._msgs) - - def print_messages(self, format, indent=0, entire_thread=False): - """Outputs messages as needed for 'notmuch show' to sys.stdout - - :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. - """ - if format.lower() == "text": - set_start = "" - set_end = "" - set_sep = "" - elif format.lower() == "json": - set_start = "[" - set_end = "]" - set_sep = ", " - else: - raise Exception - - first_set = True - - sys.stdout.write(set_start) - - # iterate through all toplevel messages in this thread - for msg in self: - # if not msg: - # break - if not first_set: - sys.stdout.write(set_sep) - first_set = False - - sys.stdout.write(set_start) - match = msg.is_match() - next_indent = indent - - if (match or entire_thread): - if format.lower() == "text": - sys.stdout.write(msg.format_message_as_text(indent)) - elif format.lower() == "json": - sys.stdout.write(msg.format_message_as_json(indent)) - else: - raise NotmuchError - next_indent = indent + 1 - - # get replies and print them also out (if there are any) - replies = msg.get_replies() - if not replies is None: - sys.stdout.write(set_sep) - replies.print_messages(format, next_indent, entire_thread) - - sys.stdout.write(set_end) - sys.stdout.write(set_end) - -#------------------------------------------------------------------------------ -class Message(object): - """Represents a single Email message - - Technically, this wraps the underlying *notmuch_message_t* structure. - """ - - """notmuch_message_get_filename (notmuch_message_t *message)""" - _get_filename = nmlib.notmuch_message_get_filename - _get_filename.restype = c_char_p - - """notmuch_message_get_flag""" - _get_flag = nmlib.notmuch_message_get_flag - _get_flag.restype = c_uint - - """notmuch_message_get_message_id (notmuch_message_t *message)""" - _get_message_id = nmlib.notmuch_message_get_message_id - _get_message_id.restype = c_char_p - - """notmuch_message_get_thread_id""" - _get_thread_id = nmlib.notmuch_message_get_thread_id - _get_thread_id.restype = c_char_p - - """notmuch_message_get_replies""" - _get_replies = nmlib.notmuch_message_get_replies - _get_replies.restype = c_void_p - - """notmuch_message_get_tags (notmuch_message_t *message)""" - _get_tags = nmlib.notmuch_message_get_tags - _get_tags.restype = c_void_p - - _get_date = nmlib.notmuch_message_get_date - _get_date.restype = c_long - - _get_header = nmlib.notmuch_message_get_header - _get_header.restype = c_char_p - - #Constants: Flags that can be set/get with set_flag - FLAG = Enum(['MATCH']) - - 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. - :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: - NotmuchError(STATUS.NULL_POINTER) - self._msg = msg_p - #keep reference to parent, so we keep it alive - self._parent = parent - - - def get_message_id(self): - """Returns the message ID - - :returns: String with a message ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_message_id(self._msg) - - def get_thread_id(self): - """Returns the thread ID - - The returned string belongs to 'message' will only be valid for as - long as the message is valid. - - This function will not return None since Notmuch ensures that every - message belongs to a single thread. - - :returns: String with a thread ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - return Message._get_thread_id (self._msg); - - def get_replies(self): - """Gets all direct replies to this message as :class:`Messages` iterator - - .. note:: This call only makes sense if 'message' was - ultimately obtained from a :class:`Thread` object, (such as - by coming directly from the result of calling - :meth:`Thread.get_toplevel_messages` or by any number of - subsequent calls to :meth:`get_replies`). If this message was - obtained through some non-thread means, (such as by a call - to :meth:`Query.search_messages`), then this function will - return `None`. - - :returns: :class:`Messages` or `None` if there are no replies to - this message. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - msgs_p = Message._get_replies(self._msg); - - if msgs_p is None: - return None - - return Messages(msgs_p,self) - - def get_date(self): - """Returns time_t of the message date - - For the original textual representation of the Date header from the - message call notmuch_message_get_header() with a header value of - "date". - - :returns: A time_t timestamp. - :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_date(self._msg) - - def get_header(self, header): - """Returns a message header - - This returns any message header that is stored in the notmuch database. - This is only a selected subset of headers, which is currently: - - TODO: add stored headers - - :param header: The name of the header to be retrieved. - It is not case-sensitive (TODO: confirm). - :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 no header was found - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - #Returns NULL if any error occurs. - header = Message._get_header (self._msg, header) - if header == None: - raise NotmuchError(STATUS.NULL_POINTER) - return header - - 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 - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_filename(self._msg) - - def get_flag(self, flag): - """Checks whether a specific flag is set for this message - - The method :meth:`Query.search_threads` sets - *Message.FLAG.MATCH* for those messages that match the - query. This method allows us to get the value of this flag. - - :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 - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_flag(self._msg, flag) - - def set_flag(self, flag, value): - """Sets/Unsets a specific flag for this message - - :param flag: One of the :attr:`Message.FLAG` values (currently only - *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 - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - nmlib.notmuch_message_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 - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - tags_p = Message._get_tags(self._msg) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def add_tag(self, tag): - """Adds a tag to the given message - - Adds a tag to the current message. The maximal tag length is defined in - the notmuch library and is currently 200 bytes. - - :param tag: String with a 'tag' to be added. - :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) - - status = nmlib.notmuch_message_add_tag (self._msg, tag) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def remove_tag(self, tag): - """Removes a tag from the given message - - If the message has no such tag, this is a non-operation and - will report success anyway. - - :param tag: String with a 'tag' to be removed. - :returns: STATUS.SUCCESS if the tag was successfully removed 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. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_remove_tag(self._msg, tag) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def remove_all_tags(self): - """Removes all tags from the given message. - - See :meth:`freeze` for an example showing how to safely - replace tag values. - - :returns: STATUS.SUCCESS if the tags were successfully removed. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot - be modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_remove_all_tags(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def freeze(self): - """Freezes the current state of 'message' within the database - - This means that changes to the message state, (via :meth:`add_tag`, - :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be - committed to the database until the message is :meth:`thaw`ed. - - Multiple calls to freeze/thaw are valid and these calls will - "stack". That is there must be as many calls to thaw as to freeze - before a message is actually thawed. - - The ability to do freeze/thaw allows for safe transactions to - change tag values. For example, explicitly setting a message to - have a given set of tags might look like this:: - - msg.freeze() - msg.remove_all_tags() - for tag in new_tags: - msg.add_tag(tag) - msg.thaw() - - With freeze/thaw used like this, the message in the database is - guaranteed to have either the full set of original tag values, or - the full set of new tag values, but nothing in between. - - Imagine the example above without freeze/thaw and the operation - somehow getting interrupted. This could result in the message being - left with no tags if the interruption happened after - :meth:`remove_all_tags` but before :meth:`add_tag`. - - :returns: STATUS.SUCCESS if the message was successfully frozen. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot - be modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_freeze(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def thaw(self): - """Thaws the current 'message' - - Thaw the current 'message', synchronizing any changes that may have - occurred while 'message' was frozen into the notmuch database. - - See :meth:`freeze` for an example of how to use this - function to safely provide tag changes. - - Multiple calls to freeze/thaw are valid and these calls with - "stack". That is there must be as many calls to thaw as to freeze - before a message is actually thawed. - - :returns: STATUS.SUCCESS if the message was successfully frozen. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.UNBALANCED_FREEZE_THAW - An attempt was made to thaw an unfrozen message. - That is, there have been an unbalanced number of calls - to :meth:`freeze` and :meth:`thaw`. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_thaw(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - - def is_match(self): - """(Not implemented)""" - return self.get_flag(Message.FLAG.MATCH) - - def __str__(self): - """A message() is represented by a 1-line summary""" - msg = {} - msg['from'] = self.get_header('from') - msg['tags'] = str(self.get_tags()) - msg['date'] = date.fromtimestamp(self.get_date()) - replies = self.get_replies() - msg['replies'] = len(replies) if replies is not None else -1 - return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) - - - def get_message_parts(self): - """Output like notmuch show""" - fp = open(self.get_filename()) - email_msg = email.message_from_file(fp) - fp.close() - - out = [] - for msg in email_msg.walk(): - if not msg.is_multipart(): - out.append(msg) - return out - - def get_part(self, num): - """Returns the nth message body part""" - parts = self.get_message_parts() - if (num <= 0 or num > len(parts)): - return "" - else: - 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(p): p["id"]) - for p in parts: - if not p.has_key("filename"): - output += "\n\fpart{ " - output += "ID: %d, Content-type: %s\n" % (p["id"], - p["content-type"]) - if p.has_key("content"): - 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 __del__(self): - """Close and free the notmuch Message""" - if self._msg is not None: - nmlib.notmuch_message_destroy (self._msg) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py deleted file mode 100644 index f6a9a232..00000000 --- a/cnotmuch/notmuch.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The :mod:`notmuch` module provides most of the functionality that a user is likely to need. - -.. note:: The underlying notmuch library is build on a hierarchical - memory allocator called talloc. All objects derive from a - top-level :class:`Database` object. - - This means that as soon as an object is deleted, all underlying - derived objects such as Queries, Messages, Message, and Tags will - be freed by the underlying library as well. Accessing these - objects will then lead to segfaults and other unexpected behavior. - - We implement reference counting, so that parent objects can be - automatically freed when they are not needed anymore. For - example:: - - db = Database('path',create=True) - msgs = Query(db,'from:myself').search_messages() - - This returns a :class:`Messages` which internally contains a - reference to its parent :class:`Query` object. Otherwise the - Query() would be immediately freed, taking our *msgs* down with - it. - - In this case, the above Query() object will be automatically freed - whenever we delete all derived objects, ie in our case: - `del(msgs)` would also delete the parent Query. It would not - delete the parent Database() though, as that is still referenced - from the variable *db* in which it is stored. - - Pretty much the same is valid for all other objects in the - hierarchy, such as :class:`Query`, :class:`Messages`, - :class:`Message`, and :class:`Tags`. - -""" -from database import Database, Query -from message import Messages, Message -from thread import Threads, Thread -from tag import Tags -from cnotmuch.globals import nmlib, STATUS, NotmuchError -__LICENSE__="GPL v3+" -__VERSION__='0.2.2' -__AUTHOR__ ='Sebastian Spaeth ' diff --git a/cnotmuch/tag.py b/cnotmuch/tag.py deleted file mode 100644 index 00898ef9..00000000 --- a/cnotmuch/tag.py +++ /dev/null @@ -1,108 +0,0 @@ -from ctypes import c_char_p -from cnotmuch.globals import nmlib, STATUS, NotmuchError - -#------------------------------------------------------------------------------ -class Tags(object): - """Represents a list of notmuch tags - - This object provides an iterator over a list of notmuch tags. 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 tag in tags: print tag - - as well as:: - - number_of_tags = len(tags) - - and even a simple:: - - #str() iterates over all tags to construct a space separated list - print(str(tags)) - - will "exhaust" the Tags. If you need to re-iterate over a list of - tags you will need to retrieve a new :class:`Tags` object. - """ - - #notmuch_tags_get - _get = nmlib.notmuch_tags_get - _get.restype = c_char_p - - def __init__(self, tags_p, parent=None): - """ - :param tags_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 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 - reference to it, so we can automatically delete the db object - once all derived objects are dead. - :TODO: Make the iterator optionally work more than once by - cache the tags in the Python object(?) - """ - if tags_p is None: - NotmuchError(STATUS.NULL_POINTER) - - self._tags = tags_p - #save reference to parent object so we keep it alive - self._parent = parent - - def __iter__(self): - """ Make Tags an iterator """ - return self - - def next(self): - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_tags_valid(self._tags): - self._tags = None - raise StopIteration - - tag = Tags._get (self._tags) - nmlib.notmuch_tags_move_to_next(self._tags) - return tag - - def __len__(self): - """len(:class:`Tags`) returns the number of contained tags - - .. note:: 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. - """ - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i=0 - while nmlib.notmuch_tags_valid(self._msgs): - nmlib.notmuch_tags_move_to_next(self._msgs) - i += 1 - self._tags = None - return i - - def __str__(self): - """The str() representation of Tags() is a space separated list of tags - - .. note:: 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. - """ - return " ".join(self) - - def __del__(self): - """Close and free the notmuch tags""" - if self._tags is not None: - nmlib.notmuch_tags_destroy (self._tags) diff --git a/cnotmuch/thread.py b/cnotmuch/thread.py deleted file mode 100644 index 5a2505c5..00000000 --- a/cnotmuch/thread.py +++ /dev/null @@ -1,351 +0,0 @@ -from ctypes import c_char_p, c_void_p, c_long -from cnotmuch.globals import nmlib, STATUS, NotmuchError -from cnotmuch.message import Messages -from cnotmuch.tag import Tags -from datetime import date - -#------------------------------------------------------------------------------ -class Threads(object): - """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.restype = c_void_p - - 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: - 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 - - def next(self): - if self._threads is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_threads_valid(self._threads): - self._threads = None - raise StopIteration - - thread = Thread(Threads._get (self._threads), self) - nmlib.notmuch_threads_move_to_next(self._threads) - return thread - - 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 nmlib.notmuch_threads_valid(self._threads): - nmlib.notmuch_threads_move_to_next(self._threads) - i += 1 - # reset self._threads to mark as "exhausted" - self._threads = None - return i - - - - def __del__(self): - """Close and free the notmuch Threads""" - if self._threads is not None: - nmlib.notmuch_messages_destroy (self._threads) - -#------------------------------------------------------------------------------ -class Thread(object): - """Represents a single message thread.""" - - """notmuch_thread_get_thread_id""" - _get_thread_id = nmlib.notmuch_thread_get_thread_id - _get_thread_id.restype = c_char_p - - """notmuch_thread_get_authors""" - _get_authors = nmlib.notmuch_thread_get_authors - _get_authors.restype = c_char_p - - """notmuch_thread_get_subject""" - _get_subject = nmlib.notmuch_thread_get_subject - _get_subject.restype = c_char_p - - """notmuch_thread_get_toplevel_messages""" - _get_toplevel_messages = nmlib.notmuch_thread_get_toplevel_messages - _get_toplevel_messages.restype = c_void_p - - _get_newest_date = nmlib.notmuch_thread_get_newest_date - _get_newest_date.restype = c_long - - _get_oldest_date = nmlib.notmuch_thread_get_oldest_date - _get_oldest_date.restype = c_long - - """notmuch_thread_get_tags""" - _get_tags = nmlib.notmuch_thread_get_tags - _get_tags.restype = c_void_p - - def __init__(self, thread_p, parent=None): - """ - :param thread_p: A pointer to an internal notmuch_thread_t - Structure. These are not publically exposed, so a user - 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`. - - :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: - NotmuchError(STATUS.NULL_POINTER) - self._thread = thread_p - #keep reference to parent, so we keep it alive - self._parent = parent - - def get_thread_id(self): - """Get the thread ID of 'thread' - - The returned string belongs to 'thread' and will only be valid - for as long as the thread is valid. - - :returns: String with a message ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread - is not initialized. - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Thread._get_thread_id(self._thread) - - def get_total_messages(self): - """Get the total number of messages in 'thread' - - :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 - is not initialized. - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return nmlib.notmuch_thread_get_total_messages(self._thread) - - - def get_toplevel_messages(self): - """Returns a :class:`Messages` iterator for the top-level messages in - 'thread' - - This iterator will not necessarily iterate over all of the messages - in the thread. It will only iterate over the messages in the thread - which are not replies to other messages in the thread. - - To iterate over all messages in the thread, the caller will need to - iterate over the result of :meth:`Message.get_replies` for each - top-level message (and do that recursively for the resulting - messages, etc.). - - :returns: :class:`Messages` - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if query is not inited - * STATUS.NULL_POINTER if search_messages failed - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - msgs_p = Thread._get_toplevel_messages(self._thread) - - if msgs_p is None: - NotmuchError(STATUS.NULL_POINTER) - - return Messages(msgs_p,self) - - def get_matched_messages(self): - """Returns the number of messages in 'thread' that matched the query - - :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 - is not initialized. - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return nmlib.notmuch_thread_get_matched_messages(self._thread) - - def get_authors(self): - """Returns the authors of 'thread' - - The returned string is a comma-separated list of the names of the - authors of mail messages in the query results that belong to this - thread. - - 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) - return Thread._get_authors(self._thread) - - def get_subject(self): - """Returns the Subject of 'thread' - - 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) - return Thread._get_subject(self._thread) - - def get_newest_date(self): - """Returns time_t of the newest message date - - :returns: A time_t timestamp. - :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Thread._get_newest_date(self._thread) - - def get_oldest_date(self): - """Returns time_t of the oldest message date - - :returns: A time_t timestamp. - :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Thread._get_oldest_date(self._thread) - - def get_tags(self): - """ Returns the message tags - - In the Notmuch database, tags are stored on individual - messages, not on threads. So the tags returned here will be all - tags of the messages which matched the search and which belong to - this thread. - - The :class:`Tags` object is owned by the thread and as such, will only - be valid for as long as this :class:`Thread` is valid (e.g. until the - 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 - """ - if self._thread is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - tags_p = Thread._get_tags(self._thread) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def __str__(self): - """A str(Thread()) is represented by a 1-line summary""" - thread = {} - thread['id'] = self.get_thread_id() - - ###TODO: How do we find out the current sort order of Threads? - ###Add a "sort" attribute to the Threads() object? - #if (sort == NOTMUCH_SORT_OLDEST_FIRST) - # date = notmuch_thread_get_oldest_date (thread); - #else - # date = notmuch_thread_get_newest_date (thread); - thread['date'] = date.fromtimestamp(self.get_newest_date()) - thread['matched'] = self.get_matched_messages() - thread['total'] = self.get_total_messages() - thread['authors'] = self.get_authors() - thread['subject'] = self.get_subject() - thread['tags'] = self.get_tags() - - return "thread:%(id)s %(date)12s [%(matched)d/%(total)d] %(authors)s; %(subject)s (%(tags)s)" % (thread) - - def __del__(self): - """Close and free the notmuch Thread""" - if self._thread is not None: - nmlib.notmuch_thread_destroy (self._thread) diff --git a/notmuch b/notmuch deleted file mode 100755 index 73ee33b6..00000000 --- a/notmuch +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env python -"""This is a notmuch implementation in python. -It's goal is to allow running the test suite on the cnotmuch python bindings. - -This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's -notmuch configuration (e.g. the database path). - - (c) 2010 by Sebastian Spaeth - Jesse Rosenthal - This code is licensed under the GNU GPL v3+. -""" -import sys -import os - -import re -import stat -import email - -from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS -from ConfigParser import SafeConfigParser -from cStringIO import StringIO - -PREFIX = re.compile('(\w+):(.*$)') - -HELPTEXT = """The notmuch mail system. -Usage: notmuch [args...] - -Where and [args...] are as follows: - setup Interactively setup notmuch for first use. - new [--verbose] - Find and import new messages to the notmuch database. - search [options...] [...] - Search for messages matching the given search terms. - show [...] - Show all messages matching the search terms. - count [...] - Count messages matching the search terms. - reply [options...] [...] - Construct a reply template for a set of messages. - tag +|- [...] [--] [...] - Add/remove tags for all messages matching the search terms. - dump [] - Create a plain-text dump of the tags for each message. - restore - Restore the tags from the given dump file (see 'dump'). - search-tags [ [...] ] - List all tags found in the database or matching messages. - help [] - This message, or more detailed help for the named command. - -Use "notmuch help " for more details on each command. -And "notmuch help search-terms" for the common search-terms syntax. -""" - -USAGE = """Notmuch is configured and appears to have a database. Excellent! - -At this point you can start exploring the functionality of notmuch by -using commands such as: - notmuch search tag:inbox - notmuch search to:"%(fullname)s" - notmuch search from:"%(mailaddress)s" - notmuch search subject:"my favorite things" - -See "notmuch help search" for more details. - -You can also use "notmuch show" with any of the thread IDs resulting -from a search. Finally, you may want to explore using a more sophisticated -interface to notmuch such as the emacs interface implemented in notmuch.el -or any other interface described at http://notmuchmail.org - -And don't forget to run "notmuch new" whenever new mail arrives. - -Have fun, and may your inbox never have much mail. -""" - -#------------------------------------------------------------------------- -def quote_query_line(argv): - # mangle arguments wrapping terms with spaces in quotes - for (num, item) in enumerate(argv): - if item.find(' ') >= 0: - # if we use prefix:termWithSpaces, put quotes around term - match = PREFIX.match(item) - if match: - argv[num] = '%s:"%s"' %(match.group(1), match.group(2)) - else: - argv[num] = '"%s"' % item - return ' '.join(argv) - -#------------------------------------------------------------------------- - - -class Notmuch(object): - - def __init__(self, configpath="~/.notmuch-config)"): - self._config = None - self._configpath = os.getenv('NOTMUCH_CONFIG', - os.path.expanduser(configpath)) - - def cmd_usage(self): - """Print the usage text and exits""" - data={} - names = self.get_user_email_addresses() - data['fullname'] = names[0] if names[0] else 'My Name' - data['mailaddress'] = names[1] if names[1] else 'My@email.address' - print USAGE % data - - def cmd_new(self): - """Run 'notmuch new'""" - #get the database directory - db = Database(mode=Database.MODE.READ_WRITE) - path = db.get_path() - print self._add_new_files_recursively(path, db) - - def cmd_help(self, subcmd=None): - """Print help text for 'notmuch help'""" - if len(subcmd) > 1: - print "Help for specific commands not implemented" - return - print HELPTEXT - - def _get_user_notmuch_config(self): - """Returns the ConfigParser of the user's notmuch-config""" - # return the cached config parser if we read it already - if self._config: - return self._config - - config = SafeConfigParser() - config.read(self._configpath) - self._config = config - return config - - def _add_new_files_recursively(self, path, db): - """:returns: (added, moved, removed)""" - print "Enter add new files with path %s" % path - - try: - #get the Directory() object for this path - db_dir = db.get_directory(path) - added = moved = removed = 0 - except NotmuchError: - # Occurs if we have wrong absolute paths in the db, for example - return (0,0,0) - - - # for folder in subdirs: - - # TODO, retrieve dir mtime here and store it later - # as long as Filenames() does not allow multiple iteration, we need to - # use this kludgy way to get a sorted list of filenames - # db_files is a list of subdirectories and filenames in this folder - db_files = set() - db_folders = set() - for subdir in db_dir.get_child_directories(): - db_folders.add(subdir) -# file is a keyword (remove this ;)) - for mail in db_dir.get_child_files(): - db_files.add(mail) - - fs_files = set(os.listdir(db_dir.path)) - - # list of files (and folders) on the fs, but not the db - for fs_file in ((fs_files - db_files) - db_folders): - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - statinfo = os.stat(absfile) - - if stat.S_ISDIR(statinfo.st_mode): - # This is a directory - if fs_file in ['.notmuch','tmp','.']: - continue - print "%s %s" % (fs_file, db_folders) - print "Directory not in db yet. Descending into %s" % absfile - new = self._add_new_files_recursively(absfile, db) - added += new[0] - moved += new[1] - removed += new[2] - - elif stat.S_ISLNK(statinfo.st_mode): - print ("%s is a symbolic link (%d). FIXME!!!" % - (absfile, statinfo.st_mode)) - exit(1) - - else: - # This is a regular file, not in the db yet. Add it. - print "This file needs to be added %s" % (absfile) - (msg, status) = db.add_message(absfile) - # We increases 'added', even on dupe messages. If it is a moved - # message, we will deduct it later and increase 'moved' instead - added += 1 - - if status == STATUS.DUPLICATE_MESSAGE_ID: - print "Added msg was in the db" - else: - print "New message." - - # Finally a list of files (not folders) in the database, - # but not the filesystem - for db_file in (db_files - fs_files): - absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) - - # remove a mail message from the db - print ("%s is not on the fs anymore. Delete" % absfile) - status = db.remove_message(absfile) - - if status == STATUS.SUCCESS: - # we just deleted the last reference, so this was a remove - removed += 1 - sys.stderr.write("SUCCESS %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - elif status == STATUS.DUPLICATE_MESSAGE_ID: - # The filename exists already somewhere else, so this is a move - moved += 1 - added -= 1 - sys.stderr.write("DUPE %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - else: - # This should not occur - sys.stderr.write("This should not occur %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - - # list of folders in the filesystem. Just descend into dirs - for fs_file in fs_files: - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - if os.path.isdir(absfile): - # This is a directory. Remove it from the db_folder list. - # All remaining db_folders at the end will be not present - # on the file system. - db_folders.remove(fs_file) - if fs_file in ['.notmuch','tmp','.']: - continue - new = self._add_new_files_recursively(absfile, db) - added += new[0] - moved += new[0] - removed += new[0] - - # we are not interested in anything but directories here - #TODO: All remaining elements of db_folders are not in the filesystem - #delete those - - return added, moved, removed - #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. - - def get_user_email_addresses(self): - """ Reads a user's notmuch config and returns his email addresses as - list (name, primary_address, other_address1,...)""" - - #read the config file - config = self._get_user_notmuch_config() - - conf = {'name': '', 'primary_email': ''} - for entry in conf: - if config.has_option('user', entry): - conf[entry] = config.get('user', entry) - - if config.has_option('user','other_email'): - other = config.get('user','other_email') - other = [mail.strip() for mail in other.split(';') if mail] - else: - other = [] - # for being compatible. It would be nicer to return a dict. - return conf.keys() + other - - def quote_msg_body(self, oldbody ,date, from_address): - """Transform a mail body into a quoted text, - starting with On foo, bar wrote: - - :param body: a str with a mail body - :returns: The new payload of the email.message() - """ - - # we get handed a string, wrap it in a file-like object - oldbody = StringIO(oldbody) - newbody = StringIO() - - newbody.write("On %s, %s wrote:\n" % (date, from_address)) - - for line in oldbody: - newbody.write("> " + line) - - return newbody.getvalue() - - def format_reply(self, msgs): - """Gets handed Messages() and displays the reply to them - - This is pretty ugly and hacky. It tries to mimic the "real" - notmuch output as much as it can to pass the test suite. It - could deserve a healthy bit of love. It is also buggy because - it returns after the first message it has handled.""" - - for msg in msgs: - f = open(msg.get_filename(), "r") - reply = email.message_from_file(f) - - # handle the easy non-multipart case: - if not reply.is_multipart(): - reply.set_payload(self.quote_msg_body(reply.get_payload(), - reply['date'], reply['from'])) - else: - # handle the tricky multipart case - deleted = "" - """A string describing which nontext attachements - that have been deleted""" - delpayloads = [] - """A list of payload indices to be deleted""" - payloads = reply.get_payload() - - for (num, part) in enumerate(payloads): - mime_main = part.get_content_maintype() - if mime_main not in ['multipart', 'message', 'text']: - deleted += "Non-text part: %s\n" % (part.get_content_type()) - payloads[num].set_payload("Non-text part: %s" % - (part.get_content_type())) - payloads[num].set_type('text/plain') - delpayloads.append(num) - elif mime_main == 'text': - payloads[num].set_payload(self.quote_msg_body( - payloads[num].get_payload(), - reply['date'], reply['from'])) - else: - # TODO handle deeply nested multipart messages - sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") - # Delete those payloads that we don't need anymore - for item in reversed(sorted(delpayloads)): - del payloads[item] - - # Back to single- and multipart handling - my_addresses = self.get_user_email_addresses() - used_address = None - # filter our email addresses from all to: cc: and bcc: fields - # if we find one of "my" addresses being used, - # it is stored in used_address - for header in ['To', 'CC', 'Bcc']: - if not header in reply: - #only handle fields that exist - continue - addresses = email.utils.getaddresses(reply.get_all(header, [])) - purged_addr = [] - for (name, mail) in addresses: - if mail in my_addresses[1:]: - used_address = email.utils.formataddr( - (my_addresses[0], mail)) - else: - purged_addr.append(email.utils.formataddr((name, mail))) - - if purged_addr: - reply.replace_header(header, ", ".join(purged_addr)) - else: - # we deleted all addresses, delete the header - del reply[header] - - # Use our primary email address to the From - # (save original from line, we still need it) - new_to = reply['From'] - if used_address: - reply['From'] = used_address - else: - email.utils.formataddr((my_addresses[0], my_addresses[1])) - - reply['Subject'] = 'Re: ' + reply['Subject'] - - # Calculate our new To: field - # add all remaining original 'To' addresses - if 'To' in reply: - new_to += ", " + reply['To'] - reply.add_header('To', new_to) - - # Add our primary email address to the BCC - new_bcc = my_addresses[1] - if 'Bcc' in reply: - new_bcc += ', ' + reply['Bcc'] - reply['Bcc'] = new_bcc - - # Set replies 'In-Reply-To' header to original's Message-ID - if 'Message-ID' in reply: - reply['In-Reply-To'] = reply['Message-ID'] - - #Add original's Message-ID to replies 'References' header. - if 'References' in reply: - reply['References'] = ' '.join([reply['References'], reply['Message-ID']]) - else: - reply['References'] = reply['Message-ID'] - - # Delete the original Message-ID. - del(reply['Message-ID']) - - # filter all existing headers but a few and delete them from 'reply' - delheaders = filter(lambda x: x not in ['From', 'To', 'Subject', 'CC', - 'Bcc', 'In-Reply-To', - 'References', 'Content-Type'], - reply.keys()) - map(reply.__delitem__, delheaders) - - # TODO: OUCH, we return after the first msg we have handled rather than - # handle all of them - # return resulting message without Unixfrom - return reply.as_string(False) - - -def main(): - # Handle command line options - #------------------------------------ - # No option given, print USAGE and exit - if len(sys.argv) == 1: - Notmuch().cmd_usage() - #------------------------------------ - elif sys.argv[1] == 'setup': - """Interactively setup notmuch for first use.""" - exit("Not implemented.") - #------------------------------------- - elif sys.argv[1] == 'new': - """Check for new and removed messages.""" - Notmuch().cmd_new() - #------------------------------------- - elif sys.argv[1] == 'help': - """Print the help text""" - Notmuch().cmd_help(sys.argv[1:]) - #------------------------------------- - elif sys.argv[1] == 'part': - part() - #------------------------------------- - elif sys.argv[1] == 'search': - search() - #------------------------------------- - elif sys.argv[1] == 'show': - show() - #------------------------------------- - elif sys.argv[1] == 'reply': - db = Database() - if len(sys.argv) == 2: - # no search term. abort - exit("Error: notmuch reply requires at least one search term.") - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - msgs = Query(db, querystr).search_messages() - print Notmuch().format_reply(msgs) - #------------------------------------- - elif sys.argv[1] == 'count': - if len(sys.argv) == 2: - # no further search term, count all - querystr = '' - else: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - print Database().create_query(querystr).count_messages() - #------------------------------------- - elif sys.argv[1] == 'tag': - # build lists of tags to be added and removed - add = [] - remove = [] - while not sys.argv[2] == '--' and \ - (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): - if sys.argv[2].startswith('+'): - # append to add list without initial + - add.append(sys.argv.pop(2)[1:]) - else: - # append to remove list without initial - - remove.append(sys.argv.pop(2)[1:]) - # skip eventual '--' - if sys.argv[2] == '--': sys.argv.pop(2) - # the rest is search terms - querystr = quote_query_line(sys.argv[2:]) - db = Database(mode=Database.MODE.READ_WRITE) - msgs = Query(db, querystr).search_messages() - for msg in msgs: - # actually add and remove all tags - map(msg.add_tag, add) - map(msg.remove_tag, remove) - #------------------------------------- - elif sys.argv[1] == 'search-tags': - if len(sys.argv) == 2: - # no further search term - print "\n".join(Database().get_all_tags()) - else: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - db = Database() - msgs = Query(db, querystr).search_messages() - print "\n".join([t for t in msgs.collect_tags()]) - #------------------------------------- - elif sys.argv[1] == 'dump': - # TODO: implement "dump " - if len(sys.argv) == 2: - f = sys.stdout - else: - f = open(sys.argv[2], "w") - db = Database() - query = Query(db, '') - query.set_sort(Query.SORT.MESSAGE_ID) - msgs = query.search_messages() - for msg in msgs: - f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) - #------------------------------------- - elif sys.argv[1] == 'restore': - if len(sys.argv) == 2: - print("No filename given. Reading dump from stdin.") - f = sys.stdin - else: - f = open(sys.argv[2], "r") - - # split the msg id and the tags - MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") - db = Database(mode=Database.MODE.READ_WRITE) - - #read each line of the dump file - for line in f: - msgs = MSGID_TAGS.match(line) - if not msgs: - sys.stderr.write("Warning: Ignoring invalid input line: %s" % - line) - continue - # split line in components and fetch message - msg_id = msgs.group(1) - new_tags = set(msgs.group(2).split()) - msg = db.find_message(msg_id) - - if msg == None: - sys.stderr.write( - "Warning: Cannot apply tags to missing message: %s\n" % msg_id) - continue - - # do nothing if the old set of tags is the same as the new one - old_tags = set(msg.get_tags()) - if old_tags == new_tags: continue - - # set the new tags - msg.freeze() - # only remove tags if the new ones are not a superset anyway - if not (new_tags > old_tags): msg.remove_all_tags() - for tag in new_tags: msg.add_tag(tag) - msg.thaw() - #------------------------------------- - else: - # unknown command - exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]) - -def part(): - db = Database() - query_string = '' - part_num = 0 - first_search_term = 0 - for (num, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--part='): - part_num_str = arg.split("=")[1] - try: - part_num = int(part_num_str) - except ValueError: - # just emulating behavior - exit(1) - elif not arg.startswith('--'): - # save the position of the first sys.argv - # that is a search term - first_search_term = num + 1 - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - qry = Query(db,querystr) - msgs = [msg for msg in qry.search_messages()] - - if not msgs: - sys.exit(1) - elif len(msgs) > 1: - raise Exception("search term did not match precisely one message") - else: - msg = msgs[0] - print msg.get_part(part_num) - -def search(): - db = Database() - query_string = '' - sort_order = "newest-first" - first_search_term = 0 - for (num, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--sort='): - sort_order=arg.split("=")[1] - if not sort_order in ("oldest-first", "newest-first"): - raise Exception("unknown sort order") - elif not arg.startswith('--'): - # save the position of the first sys.argv that is a search term - first_search_term = num + 1 - - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - - qry = Query(db, querystr) - if sort_order == "oldest-first": - qry.set_sort(Query.SORT.OLDEST_FIRST) - else: - qry.set_sort(Query.SORT.NEWEST_FIRST) - threads = qry.search_threads() - - for thread in threads: - print thread - -def show(): - entire_thread = False - db = Database() - out_format = "text" - querystr = '' - first_search_term = None - - # ugly homegrown option parsing - # TODO: use OptionParser - for (num, arg) in enumerate(sys.argv[1:]): - if arg == '--entire-thread': - entire_thread = True - elif arg.startswith("--format="): - out_format = arg.split("=")[1] - if out_format == 'json': - # for compatibility use --entire-thread for json - entire_thread = True - if not out_format in ("json", "text"): - raise Exception("unknown format") - elif not arg.startswith('--'): - # save the position of the first sys.argv that is a search term - first_search_term = num + 1 - - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - - threads = Query(db, querystr).search_threads() - first_toplevel = True - if out_format == "json": - sys.stdout.write("[") - for thread in threads: - msgs = thread.get_toplevel_messages() - if not first_toplevel: - if out_format == "json": - sys.stdout.write(", ") - first_toplevel = False - msgs.print_messages(out_format, 0, entire_thread) - - if out_format == "json": - sys.stdout.write("]") - sys.stdout.write("\n") - -if __name__ == '__main__': - main() - -# TODO: implement -""" -setup (not?) -new (halfway there) -""" diff --git a/notmuch/__init__.py b/notmuch/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notmuch/database.py b/notmuch/database.py new file mode 100644 index 00000000..f02ec408 --- /dev/null +++ b/notmuch/database.py @@ -0,0 +1,841 @@ +""" +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 . + +Copyright 2010 Sebastian Spaeth ' +""" + +import os +from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref +from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum +from cnotmuch.thread import Threads +from cnotmuch.message import Messages, Message +from cnotmuch.tag import Tags + +class Database(object): + """Represents a notmuch database (wraps notmuch_database_t) + + .. note:: Do remember that as soon as we tear down 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""" + + MODE = Enum(['READ_ONLY','READ_WRITE']) + """Constants: Mode in which to open the database""" + + """notmuch_database_get_directory""" + _get_directory = nmlib.notmuch_database_get_directory + _get_directory.restype = c_void_p + + """notmuch_database_get_path""" + _get_path = nmlib.notmuch_database_get_path + _get_path.restype = c_char_p + + """notmuch_database_get_version""" + _get_version = nmlib.notmuch_database_get_version + _get_version.restype = c_uint + + """notmuch_database_open""" + _open = nmlib.notmuch_database_open + _open.restype = c_void_p + + """notmuch_database_upgrade""" + _upgrade = nmlib.notmuch_database_upgrade + _upgrade.argtypes = [c_void_p, c_void_p, c_void_p] + + """ notmuch_database_find_message""" + _find_message = nmlib.notmuch_database_find_message + _find_message.restype = c_void_p + + """notmuch_database_get_all_tags""" + _get_all_tags = nmlib.notmuch_database_get_all_tags + _get_all_tags.restype = c_void_p + + """notmuch_database_create""" + _create = nmlib.notmuch_database_create + _create.restype = c_void_p + + def __init__(self, path=None, create=False, mode= 0): + """If *path* is `None`, we will try to read a users notmuch + configuration and use his configured database. The location of the + configuration file can be specified through the environment variable + *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`. + + If *create* is `True`, the database will always be created in + :attr:`MODE`.READ_WRITE mode. Default mode for opening is READ_ONLY. + + :param path: Directory to open/create the database in (see + above for behavior if `None`) + :type path: `str` or `None` + :param create: Pass `False` to open an existing, `True` to create a new + database. + :type create: bool + :param mode: Mode to open a database in. Is always + :attr:`MODE`.READ_WRITE when creating a new one. + :type mode: :attr:`MODE` + :returns: Nothing + :exception: :exc:`NotmuchError` in case of failure. + """ + self._db = None + if path is None: + # no path specified. use a user's default database + if Database._std_db_path is None: + #the following line throws a NotmuchError if it fails + Database._std_db_path = self._get_user_default_db() + path = Database._std_db_path + + if create == False: + self.open(path, mode) + else: + self.create(path) + + def _verify_initialized_db(self): + """Raises a NotmuchError in case self._db is still None""" + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + def create(self, path): + """Creates a new notmuch database + + This function is used by __init__() and usually does not need + to be called directly. It wraps the underlying + *notmuch_database_create* function and creates a new notmuch + database at *path*. It will always return a database in :attr:`MODE` + .READ_WRITE mode as creating an empty database for + reading only does not make a great deal of sense. + + :param path: A directory in which we should create the database. + :type path: str + :returns: Nothing + :exception: :exc:`NotmuchError` in case of any failure + (after printing an error message on stderr). + """ + if self._db is not None: + raise NotmuchError( + message="Cannot create db, this Database() already has an open one.") + + res = Database._create(path, Database.MODE.READ_WRITE) + + if res is None: + raise NotmuchError( + message="Could not create the specified database") + self._db = res + + def open(self, path, mode= 0): + """Opens an existing database + + This function is used by __init__() and usually does not need + to be called directly. It wraps the underlying + *notmuch_database_open* function. + + :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 (after printing an error message on stderr). + """ + + res = Database._open(path, mode) + + if res is None: + raise NotmuchError( + message="Could not open the specified database") + self._db = res + + def get_path(self): + """Returns the file path of an open database + + Wraps *notmuch_database_get_path*.""" + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return Database._get_path(self._db) + + def get_version(self): + """Returns the database format version + + :returns: The database version as positive integer + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return Database._get_version (self._db) + + def needs_upgrade(self): + """Does this database need to be upgraded before writing to it? + + If this function returns `True` then no functions that modify the + database (:meth:`add_message`, + :meth:`Message.add_tag`, :meth:`Directory.set_mtime`, + etc.) will work unless :meth:`upgrade` is called successfully first. + + :returns: `True` or `False` + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return notmuch_database_needs_upgrade(self._db) + + def upgrade(self): + """Upgrades the current database + + After opening a database in read-write mode, the client should + check if an upgrade is needed (notmuch_database_needs_upgrade) and + if so, upgrade with this function before making any modifications. + + NOT IMPLEMENTED: The optional progress_notify callback can be + used by the caller to provide progress indication to the + user. If non-NULL it will be called periodically with + 'progress' as a floating-point value in the range of [0.0..1.0] + indicating the progress made so far in the upgrade process. + + :TODO: catch exceptions, document return values and etc... + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + status = Database._upgrade (self._db, None, None) + #TODO: catch exceptions, document return values and etc + return status + + 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 + Database.MODE.READ_WRITE mode. The underlying library will exit the + program if this method is used on a read-only database! + + :param path: A str 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` + + STATUS.NOT_INITIALIZED + If the database was not intitialized. + + STATUS.FILE_ERROR + If path is not relative database or absolute with initial + components same as database. + + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + # sanity checking if path is valid, and make path absolute + if 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.") + 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, path); + + # return the Directory, init it with the absolute path + return Directory(abs_dirpath, dir_p, self) + + def add_message(self, filename): + """Adds a new message to the database + + `filename` should be a path relative to the path of the open + database (see :meth:`get_path`), or else should be an absolute + filename with initial components that match the path of the + database. + + The file should be a single mail message (not a multi-message mbox) + that is expected to remain at its current location, since the + notmuch database will reference the filename, and will not copy the + entire contents of the file. + + :returns: On success, we return + + 1) a :class:`Message` object that can be used for things + such as adding tags to the just-added message. + 2) one of the following STATUS values: + + STATUS.SUCCESS + Message successfully added to database. + STATUS.DUPLICATE_MESSAGE_ID + Message has the same message ID as another message already + in the database. The new filename was successfully added + to the message in the database. + + :rtype: 2-tuple(:class:`Message`, STATUS) + + :exception: Raises a :exc:`NotmuchError` with the following meaning. + If such an exception occurs, nothing was added to the database. + + STATUS.FILE_ERROR + An error occurred trying to open the file, (such as + permission denied, or file not found, etc.). + STATUS.FILE_NOT_EMAIL + The contents of filename don't look like an email message. + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so no message can + be added. + STATUS.NOT_INITIALIZED + The database has not been initialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + msg_p = c_void_p() + status = nmlib.notmuch_database_add_message(self._db, + filename, + byref(msg_p)) + + if not status in [STATUS.SUCCESS,STATUS.DUPLICATE_MESSAGE_ID]: + raise NotmuchError(status) + + #construct Message() and return + msg = Message(msg_p, self) + return (msg, status) + + def remove_message(self, filename): + """Removes a message from the given notmuch database + + Note that only this particular filename association is removed from + the database. If the same message (as determined by the message ID) + is still available via other filenames, then the message will + persist in the database for those filenames. When the last filename + is removed for a particular message, the database content for that + message will be entirely removed. + + :returns: A STATUS value with the following meaning: + + STATUS.SUCCESS + The last filename was removed and the message was removed + from the database. + STATUS.DUPLICATE_MESSAGE_ID + This filename was removed but the message persists in the + database with at least one other filename. + + :exception: Raises a :exc:`NotmuchError` with the following meaning. + If such an exception occurs, nothing was removed from the database. + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so no message can be + removed. + STATUS.NOT_INITIALIZED + The database has not been initialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return nmlib.notmuch_database_remove_message(self._db, + filename) + + def find_message(self, msgid): + """Returns a :class:`Message` as identified by its message ID + + Wraps the underlying *notmuch_database_find_message* function. + + :param msgid: The message ID + :type msgid: string + :returns: :class:`Message` or `None` if no message is found or if an + out-of-memory situation occurs. + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + msg_p = Database._find_message(self._db, msgid) + if msg_p is None: + return None + return Message(msg_p, self) + + def get_all_tags(self): + """Returns :class:`Tags` with a list of all tags found in the database + + :returns: :class:`Tags` + :execption: :exc:`NotmuchError` with STATUS.NULL_POINTER on error + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + tags_p = Database._get_all_tags (self._db) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def create_query(self, querystring): + """Returns a :class:`Query` derived from this database + + This is a shorthand method for doing:: + + # short version + # Automatically frees the Database() when 'q' is deleted + + q = Database(dbpath).create_query('from:"Biene Maja"') + + # long version, which is functionally equivalent but will keep the + # Database in the 'db' variable around after we delete 'q': + + db = Database(dbpath) + q = Query(db,'from:"Biene Maja"') + + This function is a python extension and not in the underlying C API. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return Query(self, querystring) + + def __repr__(self): + return "'Notmuch DB " + self.get_path() + "'" + + def __del__(self): + """Close and free the notmuch database if needed""" + if self._db is not None: + nmlib.notmuch_database_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""" + from ConfigParser import SafeConfigParser + config = SafeConfigParser() + conf_f = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser('~/.notmuch-config')) + config.read(conf_f) + if not config.has_option('database','path'): + raise NotmuchError(message= + "No DB path specified and no user default found") + 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). + """ + 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. + + Query() provides an instance attribute :attr:`sort`, which + contains the sort order (if specified via :meth:`set_sort`) or + `None`. + + Technically, it wraps the underlying *notmuch_query_t* struct. + + .. 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']) + """Constants: Sort order in which to return results""" + + """notmuch_query_create""" + _create = nmlib.notmuch_query_create + _create.restype = c_void_p + + """notmuch_query_search_threads""" + _search_threads = nmlib.notmuch_query_search_threads + _search_threads.restype = c_void_p + + """notmuch_query_search_messages""" + _search_messages = nmlib.notmuch_query_search_messages + _search_messages.restype = c_void_p + + + """notmuch_query_count_messages""" + _count_messages = nmlib.notmuch_query_count_messages + _count_messages.restype = c_uint + + def __init__(self, db, querystr): + """ + :param db: An open database which we derive the Query from. + :type db: :class:`Database` + :param querystr: The query string for the message. + :type querystr: str + """ + self._db = None + self._query = None + self.sort = None + self.create(db, querystr) + + 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: str + :returns: Nothing + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if db is not inited + * STATUS.NULL_POINTER if the query creation failed + (too little memory) + """ + if db.db_p is None: + raise NotmuchError(STATUS.NOT_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, querystr) + if query_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._query = query_p + + def set_sort(self, sort): + """Set the sort order future results will be delivered in + + Wraps the underlying *notmuch_query_set_sort* function. + + :param sort: Sort order (see :attr:`Query.SORT`) + :returns: Nothing + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if query has not + been initialized. + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + self.sort = sort + nmlib.notmuch_query_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. + + Technically, it wraps the underlying + *notmuch_query_search_threads* function. + + :returns: :class:`Threads` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_threads failed + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + threads_p = Query._search_threads(self._query) + + if threads_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Threads(threads_p,self) + + def search_messages(self): + """Filter messages according to the query and return + :class:`Messages` in the defined sort order + + Technically, it wraps the underlying + *notmuch_query_search_messages* function. + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_messages failed + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Query._search_messages(self._query) + + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Messages(msgs_p,self) + + def count_messages(self): + """Estimate the number of messages matching the query + + This function performs a search and returns Xapian's best + guess as to the number of matching messages. It is much faster + than performing :meth:`search_messages` and counting the + result with `len()` (although it always returned the same + result in my tests). Technically, it wraps the underlying + *notmuch_query_count_messages* function. + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Query._count_messages(self._query) + + def __del__(self): + """Close and free the Query""" + if self._query is not None: + nmlib.notmuch_query_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.restype = c_long + + """notmuch_directory_set_mtime""" + _set_mtime = nmlib.notmuch_directory_set_mtime + _set_mtime.argtypes = [c_char_p, c_long] + + """notmuch_directory_get_child_files""" + _get_child_files = nmlib.notmuch_directory_get_child_files + _get_child_files.restype = c_void_p + + """notmuch_directory_get_child_directories""" + _get_child_directories = nmlib.notmuch_directory_get_child_directories + _get_child_directories.restype = c_void_p + + def _verify_dir_initialized(self): + """Raises a NotmuchError(STATUS.NOT_INITIALIZED) if the 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. + :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 + :returns: Nothing on success, raising an exception on failure. + :exception: :exc:`NotmuchError`: + + STATUS.XAPIAN_EXCEPTION + A Xapian exception occurred, mtime not stored. + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so directory + mtime cannot be modified. + STATUS.NOT_INITIALIZED + The directory has not been initialized + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if the dir_p is None + self._verify_dir_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`: + + STATUS.NOT_INITIALIZED + The directory has not been initialized + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self.dir_p is None + self._verify_dir_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. + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None + self._verify_dir_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. + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None + self._verify_dir_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 "" % self._path + + def __del__(self): + """Close and free the Directory""" + if self._dir_p is not None: + nmlib.notmuch_directory_destroy(self._dir_p) + +#------------------------------------------------------------------------------ +class Filenames(object): + """An iterator over File- or Directory names that are stored in the database + """ + + #notmuch_filenames_get + _get = nmlib.notmuch_filenames_get + _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 + + def next(self): + if self._files_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_filenames_valid(self._files_p): + self._files_p = None + raise StopIteration + + file = Filenames._get (self._files_p) + nmlib.notmuch_filenames_move_to_next(self._files_p) + return file + + 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(STATUS.NOT_INITIALIZED)!!! + for file in files: print file + """ + if self._files_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_filenames_valid(self._files_p): + nmlib.notmuch_filenames_move_to_next(self._files_p) + i += 1 + self._files_p = None + return i + + def __del__(self): + """Close and free Filenames""" + if self._files_p is not None: + nmlib.notmuch_filenames_destroy(self._files_p) diff --git a/notmuch/globals.py b/notmuch/globals.py new file mode 100644 index 00000000..8b0d8d0b --- /dev/null +++ b/notmuch/globals.py @@ -0,0 +1,80 @@ +""" +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 . + +Copyright 2010 Sebastian Spaeth ' +""" + +from ctypes import CDLL, c_char_p, c_int +from ctypes.util import find_library + +#----------------------------------------------------------------------------- +#package-global instance of the notmuch library +try: + nmlib = CDLL("libnotmuch.so.1") +except: + raise ImportError("Could not find shared 'notmuch' library.") + +#----------------------------------------------------------------------------- +class Enum(object): + """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" + def __init__(self, names): + for number, name in enumerate(names): + setattr(self, name, number) + +#----------------------------------------------------------------------------- +class Status(Enum): + """Enum with a string representation of a notmuch_status_t value.""" + __name__="foo" + _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 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 str(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', + 'NOT_INITIALIZED']) + + +class NotmuchError(Exception): + def __init__(self, status=None, message=None): + """Is initiated with a (notmuch.STATUS[,message=None])""" + super(NotmuchError, self).__init__(message, status) + + def __str__(self): + if self.args[0] is not None: return self.args[0] + else: return STATUS.status2str(self.args[1]) + diff --git a/notmuch/message.py b/notmuch/message.py new file mode 100644 index 00000000..f8d7d5ec --- /dev/null +++ b/notmuch/message.py @@ -0,0 +1,777 @@ +""" +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 . + +Copyright 2010 Sebastian Spaeth ' + Jesse Rosenthal +""" + + +from ctypes import c_char_p, c_void_p, c_long, c_uint +from datetime import date +from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum +from cnotmuch.tag import Tags +import sys +import email +import types +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. Also + note, that any function that uses iteration will also + exhaust the messages. So both:: + + for msg in msgs: print msg + + as well as:: + + number_of_msgs = len(msgs) + + will "exhaust" the Messages. If you need to re-iterate over a list of + messages you will need to retrieve a new :class:`Messages` object. + + Things are not as bad as it seems though, you can store and reuse + the single Message objects as often as you want as long as you + keep the parent Messages object around. (Recall that due to + hierarchical memory allocation, all derived Message objects will + be invalid when we delete the parent Messages() object, even if it + was already "exhausted".) So this works:: + + db = Database() + msgs = Query(db,'').search_messages() #get a Messages() object + msglist = [] + for m in msgs: + msglist.append(m) + + # msgs is "exhausted" now and even len(msgs) will raise an exception. + # However it will be kept around until all retrieved Message() objects are + # also deleted. If you did 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 Message objects. + + print (msglist[0].get_filename()) + print (msglist[1].get_filename()) + print (msglist[0].get_message_id()) + """ + + #notmuch_tags_get + _get = nmlib.notmuch_messages_get + _get.restype = c_void_p + + _collect_tags = nmlib.notmuch_messages_collect_tags + _collect_tags.restype = c_void_p + + 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: + 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 inited + + .. 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 + + def next(self): + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_messages_valid(self._msgs): + self._msgs = None + raise StopIteration + + msg = Message(Messages._get (self._msgs), self) + nmlib.notmuch_messages_move_to_next(self._msgs) + return msg + + def __len__(self): + """len(:class:`Messages`) returns the number of contained messages + + .. note:: As this iterates over the messages, we will not be able to + iterate over them again! So this will fail:: + + #THIS FAILS + msgs = Database().create_query('').search_message() + if len(msgs) > 0: #this 'exhausts' msgs + # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! + for msg in msgs: print msg + + Most of the time, using the + :meth:`Query.count_messages` is therefore more + appropriate (and much faster). While not guaranteeing + that it will return the exact same number than len(), + in my tests it effectively always did so. + """ + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_messages_valid(self._msgs): + nmlib.notmuch_messages_move_to_next(self._msgs) + i += 1 + self._msgs = None + return i + + def __del__(self): + """Close and free the notmuch Messages""" + if self._msgs is not None: + nmlib.notmuch_messages_destroy (self._msgs) + + def print_messages(self, format, indent=0, entire_thread=False): + """Outputs messages as needed for 'notmuch show' to sys.stdout + + :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. + """ + if format.lower() == "text": + set_start = "" + set_end = "" + set_sep = "" + elif format.lower() == "json": + set_start = "[" + set_end = "]" + set_sep = ", " + else: + raise Exception + + first_set = True + + sys.stdout.write(set_start) + + # iterate through all toplevel messages in this thread + for msg in self: + # if not msg: + # break + if not first_set: + sys.stdout.write(set_sep) + first_set = False + + sys.stdout.write(set_start) + match = msg.is_match() + next_indent = indent + + if (match or entire_thread): + if format.lower() == "text": + sys.stdout.write(msg.format_message_as_text(indent)) + elif format.lower() == "json": + sys.stdout.write(msg.format_message_as_json(indent)) + else: + raise NotmuchError + next_indent = indent + 1 + + # get replies and print them also out (if there are any) + replies = msg.get_replies() + if not replies is None: + sys.stdout.write(set_sep) + replies.print_messages(format, next_indent, entire_thread) + + sys.stdout.write(set_end) + sys.stdout.write(set_end) + +#------------------------------------------------------------------------------ +class Message(object): + """Represents a single Email message + + Technically, this wraps the underlying *notmuch_message_t* structure. + """ + + """notmuch_message_get_filename (notmuch_message_t *message)""" + _get_filename = nmlib.notmuch_message_get_filename + _get_filename.restype = c_char_p + + """notmuch_message_get_flag""" + _get_flag = nmlib.notmuch_message_get_flag + _get_flag.restype = c_uint + + """notmuch_message_get_message_id (notmuch_message_t *message)""" + _get_message_id = nmlib.notmuch_message_get_message_id + _get_message_id.restype = c_char_p + + """notmuch_message_get_thread_id""" + _get_thread_id = nmlib.notmuch_message_get_thread_id + _get_thread_id.restype = c_char_p + + """notmuch_message_get_replies""" + _get_replies = nmlib.notmuch_message_get_replies + _get_replies.restype = c_void_p + + """notmuch_message_get_tags (notmuch_message_t *message)""" + _get_tags = nmlib.notmuch_message_get_tags + _get_tags.restype = c_void_p + + _get_date = nmlib.notmuch_message_get_date + _get_date.restype = c_long + + _get_header = nmlib.notmuch_message_get_header + _get_header.restype = c_char_p + + #Constants: Flags that can be set/get with set_flag + FLAG = Enum(['MATCH']) + + 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. + :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: + NotmuchError(STATUS.NULL_POINTER) + self._msg = msg_p + #keep reference to parent, so we keep it alive + self._parent = parent + + + def get_message_id(self): + """Returns the message ID + + :returns: String with a message ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_message_id(self._msg) + + def get_thread_id(self): + """Returns the thread ID + + The returned string belongs to 'message' will only be valid for as + long as the message is valid. + + This function will not return None since Notmuch ensures that every + message belongs to a single thread. + + :returns: String with a thread ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Message._get_thread_id (self._msg); + + def get_replies(self): + """Gets all direct replies to this message as :class:`Messages` iterator + + .. note:: This call only makes sense if 'message' was + ultimately obtained from a :class:`Thread` object, (such as + by coming directly from the result of calling + :meth:`Thread.get_toplevel_messages` or by any number of + subsequent calls to :meth:`get_replies`). If this message was + obtained through some non-thread means, (such as by a call + to :meth:`Query.search_messages`), then this function will + return `None`. + + :returns: :class:`Messages` or `None` if there are no replies to + this message. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Message._get_replies(self._msg); + + if msgs_p is None: + return None + + return Messages(msgs_p,self) + + def get_date(self): + """Returns time_t of the message date + + For the original textual representation of the Date header from the + message call notmuch_message_get_header() with a header value of + "date". + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_date(self._msg) + + def get_header(self, header): + """Returns a message header + + This returns any message header that is stored in the notmuch database. + This is only a selected subset of headers, which is currently: + + TODO: add stored headers + + :param header: The name of the header to be retrieved. + It is not case-sensitive (TODO: confirm). + :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 no header was found + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + #Returns NULL if any error occurs. + header = Message._get_header (self._msg, header) + if header == None: + raise NotmuchError(STATUS.NULL_POINTER) + return header + + 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 + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_filename(self._msg) + + def get_flag(self, flag): + """Checks whether a specific flag is set for this message + + The method :meth:`Query.search_threads` sets + *Message.FLAG.MATCH* for those messages that match the + query. This method allows us to get the value of this flag. + + :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 + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_flag(self._msg, flag) + + def set_flag(self, flag, value): + """Sets/Unsets a specific flag for this message + + :param flag: One of the :attr:`Message.FLAG` values (currently only + *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 + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + nmlib.notmuch_message_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 + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + tags_p = Message._get_tags(self._msg) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def add_tag(self, tag): + """Adds a tag to the given message + + Adds a tag to the current message. The maximal tag length is defined in + the notmuch library and is currently 200 bytes. + + :param tag: String with a 'tag' to be added. + :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) + + status = nmlib.notmuch_message_add_tag (self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def remove_tag(self, tag): + """Removes a tag from the given message + + If the message has no such tag, this is a non-operation and + will report success anyway. + + :param tag: String with a 'tag' to be removed. + :returns: STATUS.SUCCESS if the tag was successfully removed 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. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_tag(self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def remove_all_tags(self): + """Removes all tags from the given message. + + See :meth:`freeze` for an example showing how to safely + replace tag values. + + :returns: STATUS.SUCCESS if the tags were successfully removed. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_all_tags(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def freeze(self): + """Freezes the current state of 'message' within the database + + This means that changes to the message state, (via :meth:`add_tag`, + :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be + committed to the database until the message is :meth:`thaw`ed. + + Multiple calls to freeze/thaw are valid and these calls will + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + The ability to do freeze/thaw allows for safe transactions to + change tag values. For example, explicitly setting a message to + have a given set of tags might look like this:: + + msg.freeze() + msg.remove_all_tags() + for tag in new_tags: + msg.add_tag(tag) + msg.thaw() + + With freeze/thaw used like this, the message in the database is + guaranteed to have either the full set of original tag values, or + the full set of new tag values, but nothing in between. + + Imagine the example above without freeze/thaw and the operation + somehow getting interrupted. This could result in the message being + left with no tags if the interruption happened after + :meth:`remove_all_tags` but before :meth:`add_tag`. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_freeze(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def thaw(self): + """Thaws the current 'message' + + Thaw the current 'message', synchronizing any changes that may have + occurred while 'message' was frozen into the notmuch database. + + See :meth:`freeze` for an example of how to use this + function to safely provide tag changes. + + Multiple calls to freeze/thaw are valid and these calls with + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.UNBALANCED_FREEZE_THAW + An attempt was made to thaw an unfrozen message. + That is, there have been an unbalanced number of calls + to :meth:`freeze` and :meth:`thaw`. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_thaw(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + + def is_match(self): + """(Not implemented)""" + return self.get_flag(Message.FLAG.MATCH) + + def __str__(self): + """A message() is represented by a 1-line summary""" + msg = {} + msg['from'] = self.get_header('from') + msg['tags'] = str(self.get_tags()) + msg['date'] = date.fromtimestamp(self.get_date()) + replies = self.get_replies() + msg['replies'] = len(replies) if replies is not None else -1 + return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) + + + def get_message_parts(self): + """Output like notmuch show""" + fp = open(self.get_filename()) + email_msg = email.message_from_file(fp) + fp.close() + + out = [] + for msg in email_msg.walk(): + if not msg.is_multipart(): + out.append(msg) + return out + + def get_part(self, num): + """Returns the nth message body part""" + parts = self.get_message_parts() + if (num <= 0 or num > len(parts)): + return "" + else: + 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(p): p["id"]) + for p in parts: + if not p.has_key("filename"): + output += "\n\fpart{ " + output += "ID: %d, Content-type: %s\n" % (p["id"], + p["content-type"]) + if p.has_key("content"): + 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 __del__(self): + """Close and free the notmuch Message""" + if self._msg is not None: + nmlib.notmuch_message_destroy (self._msg) diff --git a/notmuch/notmuch.py b/notmuch/notmuch.py new file mode 100644 index 00000000..513209d5 --- /dev/null +++ b/notmuch/notmuch.py @@ -0,0 +1,61 @@ +"""The :mod:`notmuch` module provides most of the functionality that a user is likely to need. + +.. note:: The underlying notmuch library is build on a hierarchical + memory allocator called talloc. All objects derive from a + top-level :class:`Database` object. + + This means that as soon as an object is deleted, all underlying + derived objects such as Queries, Messages, Message, and Tags will + be freed by the underlying library as well. Accessing these + objects will then lead to segfaults and other unexpected behavior. + + We implement reference counting, so that parent objects can be + automatically freed when they are not needed anymore. For + example:: + + db = Database('path',create=True) + msgs = Query(db,'from:myself').search_messages() + + This returns a :class:`Messages` which internally contains a + reference to its parent :class:`Query` object. Otherwise the + Query() would be immediately freed, taking our *msgs* down with + it. + + In this case, the above Query() object will be automatically freed + whenever we delete all derived objects, ie in our case: + `del(msgs)` would also delete the parent Query. It would not + delete the parent Database() though, as that is still referenced + from the variable *db* in which it is stored. + + Pretty much the same is valid for all other objects in the + hierarchy, such as :class:`Query`, :class:`Messages`, + :class:`Message`, and :class:`Tags`. + +""" + +""" +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 . + +Copyright 2010 Sebastian Spaeth ' +""" +from database import Database, Query +from message import Messages, Message +from thread import Threads, Thread +from tag import Tags +from cnotmuch.globals import nmlib, STATUS, NotmuchError +__LICENSE__="GPL v3+" +__VERSION__='0.2.2' +__AUTHOR__ ='Sebastian Spaeth ' diff --git a/notmuch/tag.py b/notmuch/tag.py new file mode 100644 index 00000000..c89e4feb --- /dev/null +++ b/notmuch/tag.py @@ -0,0 +1,126 @@ +""" +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 . + +Copyright 2010 Sebastian Spaeth ' +""" +from ctypes import c_char_p +from cnotmuch.globals import nmlib, STATUS, NotmuchError + +#------------------------------------------------------------------------------ +class Tags(object): + """Represents a list of notmuch tags + + This object provides an iterator over a list of notmuch tags. 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 tag in tags: print tag + + as well as:: + + number_of_tags = len(tags) + + and even a simple:: + + #str() iterates over all tags to construct a space separated list + print(str(tags)) + + will "exhaust" the Tags. If you need to re-iterate over a list of + tags you will need to retrieve a new :class:`Tags` object. + """ + + #notmuch_tags_get + _get = nmlib.notmuch_tags_get + _get.restype = c_char_p + + def __init__(self, tags_p, parent=None): + """ + :param tags_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 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 + reference to it, so we can automatically delete the db object + once all derived objects are dead. + :TODO: Make the iterator optionally work more than once by + cache the tags in the Python object(?) + """ + if tags_p is None: + NotmuchError(STATUS.NULL_POINTER) + + self._tags = tags_p + #save reference to parent object so we keep it alive + self._parent = parent + + def __iter__(self): + """ Make Tags an iterator """ + return self + + def next(self): + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_tags_valid(self._tags): + self._tags = None + raise StopIteration + + tag = Tags._get (self._tags) + nmlib.notmuch_tags_move_to_next(self._tags) + return tag + + def __len__(self): + """len(:class:`Tags`) returns the number of contained tags + + .. note:: 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. + """ + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_tags_valid(self._msgs): + nmlib.notmuch_tags_move_to_next(self._msgs) + i += 1 + self._tags = None + return i + + def __str__(self): + """The str() representation of Tags() is a space separated list of tags + + .. note:: 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. + """ + return " ".join(self) + + def __del__(self): + """Close and free the notmuch tags""" + if self._tags is not None: + nmlib.notmuch_tags_destroy (self._tags) diff --git a/notmuch/thread.py b/notmuch/thread.py new file mode 100644 index 00000000..19bef1a9 --- /dev/null +++ b/notmuch/thread.py @@ -0,0 +1,370 @@ +""" +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 . + +Copyright 2010 Sebastian Spaeth ' +""" + +from ctypes import c_char_p, c_void_p, c_long +from cnotmuch.globals import nmlib, STATUS, NotmuchError +from cnotmuch.message import Messages +from cnotmuch.tag import Tags +from datetime import date + +#------------------------------------------------------------------------------ +class Threads(object): + """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.restype = c_void_p + + 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: + 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 + + def next(self): + if self._threads is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_threads_valid(self._threads): + self._threads = None + raise StopIteration + + thread = Thread(Threads._get (self._threads), self) + nmlib.notmuch_threads_move_to_next(self._threads) + return thread + + 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 nmlib.notmuch_threads_valid(self._threads): + nmlib.notmuch_threads_move_to_next(self._threads) + i += 1 + # reset self._threads to mark as "exhausted" + self._threads = None + return i + + + + def __del__(self): + """Close and free the notmuch Threads""" + if self._threads is not None: + nmlib.notmuch_messages_destroy (self._threads) + +#------------------------------------------------------------------------------ +class Thread(object): + """Represents a single message thread.""" + + """notmuch_thread_get_thread_id""" + _get_thread_id = nmlib.notmuch_thread_get_thread_id + _get_thread_id.restype = c_char_p + + """notmuch_thread_get_authors""" + _get_authors = nmlib.notmuch_thread_get_authors + _get_authors.restype = c_char_p + + """notmuch_thread_get_subject""" + _get_subject = nmlib.notmuch_thread_get_subject + _get_subject.restype = c_char_p + + """notmuch_thread_get_toplevel_messages""" + _get_toplevel_messages = nmlib.notmuch_thread_get_toplevel_messages + _get_toplevel_messages.restype = c_void_p + + _get_newest_date = nmlib.notmuch_thread_get_newest_date + _get_newest_date.restype = c_long + + _get_oldest_date = nmlib.notmuch_thread_get_oldest_date + _get_oldest_date.restype = c_long + + """notmuch_thread_get_tags""" + _get_tags = nmlib.notmuch_thread_get_tags + _get_tags.restype = c_void_p + + def __init__(self, thread_p, parent=None): + """ + :param thread_p: A pointer to an internal notmuch_thread_t + Structure. These are not publically exposed, so a user + 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`. + + :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: + NotmuchError(STATUS.NULL_POINTER) + self._thread = thread_p + #keep reference to parent, so we keep it alive + self._parent = parent + + def get_thread_id(self): + """Get the thread ID of 'thread' + + The returned string belongs to 'thread' and will only be valid + for as long as the thread is valid. + + :returns: String with a message ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_thread_id(self._thread) + + def get_total_messages(self): + """Get the total number of messages in 'thread' + + :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 + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return nmlib.notmuch_thread_get_total_messages(self._thread) + + + def get_toplevel_messages(self): + """Returns a :class:`Messages` iterator for the top-level messages in + 'thread' + + This iterator will not necessarily iterate over all of the messages + in the thread. It will only iterate over the messages in the thread + which are not replies to other messages in the thread. + + To iterate over all messages in the thread, the caller will need to + iterate over the result of :meth:`Message.get_replies` for each + top-level message (and do that recursively for the resulting + messages, etc.). + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_messages failed + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Thread._get_toplevel_messages(self._thread) + + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Messages(msgs_p,self) + + def get_matched_messages(self): + """Returns the number of messages in 'thread' that matched the query + + :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 + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return nmlib.notmuch_thread_get_matched_messages(self._thread) + + def get_authors(self): + """Returns the authors of 'thread' + + The returned string is a comma-separated list of the names of the + authors of mail messages in the query results that belong to this + thread. + + 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) + return Thread._get_authors(self._thread) + + def get_subject(self): + """Returns the Subject of 'thread' + + 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) + return Thread._get_subject(self._thread) + + def get_newest_date(self): + """Returns time_t of the newest message date + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_newest_date(self._thread) + + def get_oldest_date(self): + """Returns time_t of the oldest message date + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_oldest_date(self._thread) + + def get_tags(self): + """ Returns the message tags + + In the Notmuch database, tags are stored on individual + messages, not on threads. So the tags returned here will be all + tags of the messages which matched the search and which belong to + this thread. + + The :class:`Tags` object is owned by the thread and as such, will only + be valid for as long as this :class:`Thread` is valid (e.g. until the + 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 + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + tags_p = Thread._get_tags(self._thread) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def __str__(self): + """A str(Thread()) is represented by a 1-line summary""" + thread = {} + thread['id'] = self.get_thread_id() + + ###TODO: How do we find out the current sort order of Threads? + ###Add a "sort" attribute to the Threads() object? + #if (sort == NOTMUCH_SORT_OLDEST_FIRST) + # date = notmuch_thread_get_oldest_date (thread); + #else + # date = notmuch_thread_get_newest_date (thread); + thread['date'] = date.fromtimestamp(self.get_newest_date()) + thread['matched'] = self.get_matched_messages() + thread['total'] = self.get_total_messages() + thread['authors'] = self.get_authors() + thread['subject'] = self.get_subject() + thread['tags'] = self.get_tags() + + return "thread:%(id)s %(date)12s [%(matched)d/%(total)d] %(authors)s; %(subject)s (%(tags)s)" % (thread) + + def __del__(self): + """Close and free the notmuch Thread""" + if self._thread is not None: + nmlib.notmuch_thread_destroy (self._thread) diff --git a/setup.py b/setup.py index 256fd4e5..7a4b43f5 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,20 @@ #!/usr/bin/env python from distutils.core import setup -from cnotmuch.notmuch import __VERSION__ -setup(name='cnotmuch', +from notmuch import __VERSION__ +setup(name='notmuch', version=__VERSION__, description='Python binding of the notmuch mail search and indexing library.', author='Sebastian Spaeth', author_email='Sebastian@SSpaeth.de', url='http://bitbucket.org/spaetz/cnotmuch/', download_url='http://bitbucket.org/spaetz/cnotmuch/get/v'+__VERSION__+'.tar.gz', - packages=['cnotmuch'], + packages=['notmuch'], keywords = ["library", "email"], long_description="""Overview ============== -The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. +The notmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. The documentation for the latest cnotmuch release can be `viewed online `_.