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