mirror of
https://git.notmuchmail.org/git/notmuch
synced 2024-11-21 18:38:08 +01:00
Introduce CFFI-based python bindings
This introduces CFFI-based Python3-only bindings. The bindings aim at: - Better performance on pypy - Easier to use Python-C interface - More "pythonic" - The API should not allow invalid operations - Use native object protocol where possible - Memory safety; whatever you do from python, it should not coredump.
This commit is contained in:
parent
5f9ea4d290
commit
83c2d15898
19 changed files with 3851 additions and 0 deletions
1
AUTHORS
1
AUTHORS
|
@ -28,3 +28,4 @@ ideas, inspiration, testing or feedback):
|
|||
Martin Krafft
|
||||
Keith Packard
|
||||
Jamey Sharp
|
||||
Google LLC
|
||||
|
|
2
bindings/python-cffi/MANIFEST.in
Normal file
2
bindings/python-cffi/MANIFEST.in
Normal file
|
@ -0,0 +1,2 @@
|
|||
include MANIFEST.in
|
||||
include tox.ini
|
62
bindings/python-cffi/notdb/__init__.py
Normal file
62
bindings/python-cffi/notdb/__init__.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""Pythonic API to the notmuch database.
|
||||
|
||||
Creating Objects
|
||||
================
|
||||
|
||||
Only the :class:`Database` object is meant to be created by the user.
|
||||
All other objects should be created from this initial object. Users
|
||||
should consider their signatures implementation details.
|
||||
|
||||
Errors
|
||||
======
|
||||
|
||||
All errors occuring due to errors from the underlying notmuch database
|
||||
are subclasses of the :exc:`NotmuchError`. Due to memory management
|
||||
it is possible to try and use an object after it has been freed. In
|
||||
this case a :exc:`ObjectDestoryedError` will be raised.
|
||||
|
||||
Memory Management
|
||||
=================
|
||||
|
||||
Libnotmuch uses a hierarchical memory allocator, this means all
|
||||
objects have a strict parent-child relationship and when the parent is
|
||||
freed all the children are freed as well. This has some implications
|
||||
for these Python bindings as parent objects need to be kept alive.
|
||||
This is normally schielded entirely from the user however and the
|
||||
Python objects automatically make sure the right references are kept
|
||||
alive. It is however the reason the :class:`BaseObject` exists as it
|
||||
defines the API all Python objects need to implement to work
|
||||
correctly.
|
||||
|
||||
Collections and Containers
|
||||
==========================
|
||||
|
||||
Libnotmuch exposes nearly all collections of things as iterators only.
|
||||
In these python bindings they have sometimes been exposed as
|
||||
:class:`collections.abc.Container` instances or subclasses of this
|
||||
like :class:`collections.abc.Set` or :class:`collections.abc.Mapping`
|
||||
etc. This gives a more natural API to work with, e.g. being able to
|
||||
treat tags as sets. However it does mean that the
|
||||
:meth:`__contains__`, :meth:`__len__` and frieds methods on these are
|
||||
usually more and essentially O(n) rather than O(1) as you might
|
||||
usually expect from Python containers.
|
||||
"""
|
||||
|
||||
from notdb import _capi
|
||||
from notdb._base import *
|
||||
from notdb._database import *
|
||||
from notdb._errors import *
|
||||
from notdb._message import *
|
||||
from notdb._tags import *
|
||||
from notdb._thread import *
|
||||
|
||||
|
||||
NOTMUCH_TAG_MAX = _capi.lib.NOTMUCH_TAG_MAX
|
||||
del _capi
|
||||
|
||||
|
||||
# Re-home all the objects to the package. This leaves __qualname__ intact.
|
||||
for x in locals().copy().values():
|
||||
if hasattr(x, '__module__'):
|
||||
x.__module__ = __name__
|
||||
del x
|
238
bindings/python-cffi/notdb/_base.py
Normal file
238
bindings/python-cffi/notdb/_base.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
import abc
|
||||
import collections.abc
|
||||
|
||||
from notdb import _capi as capi
|
||||
from notdb import _errors as errors
|
||||
|
||||
|
||||
__all__ = ['NotmuchObject', 'BinString']
|
||||
|
||||
|
||||
class NotmuchObject(metaclass=abc.ABCMeta):
|
||||
"""Base notmuch object syntax.
|
||||
|
||||
This base class exists to define the memory management handling
|
||||
required to use the notmuch library. It is meant as an interface
|
||||
definition rather than a base class, though you can use it as a
|
||||
base class to ensure you don't forget part of the interface. It
|
||||
only concerns you if you are implementing this package itself
|
||||
rather then using it.
|
||||
|
||||
libnotmuch uses a hierarchical memory allocator, where freeing the
|
||||
memory of a parent object also frees the memory of all child
|
||||
objects. To make this work seamlessly in Python this package
|
||||
keeps references to parent objects which makes them stay alive
|
||||
correctly under normal circumstances. When an object finally gets
|
||||
deleted the :meth:`__del__` method will be called to free the
|
||||
memory.
|
||||
|
||||
However during some peculiar situations, e.g. interpreter
|
||||
shutdown, it is possible for the :meth:`__del__` method to have
|
||||
been called, whele there are still references to an object. This
|
||||
could result in child objects asking their memeory to be freed
|
||||
after the parent has already freed the memory, making things
|
||||
rather unhappy as double frees are not taken lightly in C. To
|
||||
handle this case all objects need to follow the same protocol to
|
||||
destroy themselves, see :meth:`destroy`.
|
||||
|
||||
Once an object has been destroyed trying to use it should raise
|
||||
the :exc:`ObjectDestroyedError` exception. For this see also the
|
||||
convenience :class:`MemoryPointer` descriptor in this module which
|
||||
can be used as a pointer to libnotmuch memory.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, parent, *args, **kwargs):
|
||||
"""Create a new object.
|
||||
|
||||
Other then for the toplevel :class:`Database` object
|
||||
constructors are only ever called by internal code and not by
|
||||
the user. Per convention their signature always takes the
|
||||
parent object as first argument. Feel free to make the rest
|
||||
of the signature match the object's requirement. The object
|
||||
needs to keep a reference to the parent, so it can check the
|
||||
parent is still alive.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def alive(self):
|
||||
"""Whether the object is still alive.
|
||||
|
||||
This indicates whether the object is still alive. The first
|
||||
thing this needs to check is whether the parent object is
|
||||
still alive, if it is not then this object can not be alive
|
||||
either. If the parent is alive then it depends on whether the
|
||||
memory for this object has been freed yet or not.
|
||||
"""
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _destroy(self):
|
||||
"""Destroy the object, freeing all memory.
|
||||
|
||||
This method needs to destory the object on the
|
||||
libnotmuch-level. It must ensure it's not been destroyed by
|
||||
it's parent object yet before doing so. It also must be
|
||||
idempotent.
|
||||
"""
|
||||
|
||||
|
||||
class MemoryPointer:
|
||||
"""Data Descriptor to handle accessing libnotmuch pointers.
|
||||
|
||||
Most :class:`NotmuchObject` instances will have one or more CFFI
|
||||
pointers to C-objects. Once an object is destroyed this pointer
|
||||
should no longer be used and a :exc:`ObjectDestroyedError`
|
||||
exception should be raised on trying to access it. This
|
||||
descriptor simplifies implementing this, allowing the creation of
|
||||
an attribute which can be assigned to, but when accessed when the
|
||||
stored value is *None* it will raise the
|
||||
:exc:`ObjectDestroyedError` exception::
|
||||
|
||||
class SomeOjb:
|
||||
_ptr = MemoryPointer()
|
||||
|
||||
def __init__(self, ptr):
|
||||
self._ptr = ptr
|
||||
|
||||
def destroy(self):
|
||||
somehow_free(self._ptr)
|
||||
self._ptr = None
|
||||
|
||||
def do_something(self):
|
||||
return some_libnotmuch_call(self._ptr)
|
||||
"""
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
try:
|
||||
val = getattr(instance, self.attr_name, None)
|
||||
except AttributeError:
|
||||
# We're not on 3.6+ and self.attr_name does not exist
|
||||
self.__set_name__(instance, 'dummy')
|
||||
val = getattr(instance, self.attr_name, None)
|
||||
if val is None:
|
||||
raise errors.ObjectDestroyedError()
|
||||
return val
|
||||
|
||||
def __set__(self, instance, value):
|
||||
try:
|
||||
setattr(instance, self.attr_name, value)
|
||||
except AttributeError:
|
||||
# We're not on 3.6+ and self.attr_name does not exist
|
||||
self.__set_name__(instance, 'dummy')
|
||||
setattr(instance, self.attr_name, value)
|
||||
|
||||
def __set_name__(self, instance, name):
|
||||
self.attr_name = '_memptr_{}_{:x}'.format(name, id(instance))
|
||||
|
||||
|
||||
class BinString(str):
|
||||
"""A str subclass with binary data.
|
||||
|
||||
Most data in libnotmuch should be valid ASCII or valid UTF-8.
|
||||
However since it is a C library these are represented as
|
||||
bytestrings intead which means on an API level we can not
|
||||
guarantee that decoding this to UTF-8 will both succeed and be
|
||||
lossless. This string type converts bytes to unicode in a lossy
|
||||
way, but also makes the raw bytes available.
|
||||
|
||||
This object is a normal unicode string for most intents and
|
||||
purposes, but you can get the original bytestring back by calling
|
||||
``bytes()`` on it.
|
||||
"""
|
||||
|
||||
def __new__(cls, data, encoding='utf-8', errors='ignore'):
|
||||
if not isinstance(data, bytes):
|
||||
data = bytes(data, encoding=encoding)
|
||||
strdata = str(data, encoding=encoding, errors=errors)
|
||||
inst = super().__new__(cls, strdata)
|
||||
inst._bindata = data
|
||||
return inst
|
||||
|
||||
@classmethod
|
||||
def from_cffi(cls, cdata):
|
||||
"""Create a new string from a CFFI cdata pointer."""
|
||||
return cls(capi.ffi.string(cdata))
|
||||
|
||||
def __bytes__(self):
|
||||
return self._bindata
|
||||
|
||||
|
||||
class NotmuchIter(NotmuchObject, collections.abc.Iterator):
|
||||
"""An iterator for libnotmuch iterators.
|
||||
|
||||
It is tempting to use a generator function instead, but this would
|
||||
not correctly respect the :class:`NotmuchObject` memory handling
|
||||
protocol and in some unsuspecting cornercases cause memory
|
||||
trouble. You probably want to sublcass this in order to wrap the
|
||||
value returned by :meth:`__next__`.
|
||||
|
||||
:param parent: The parent object.
|
||||
:type parent: NotmuchObject
|
||||
:param iter_p: The CFFI pointer to the C iterator.
|
||||
:type iter_p: cffi.cdata
|
||||
:param fn_destory: The CFFI notmuch_*_destroy function.
|
||||
:param fn_valid: The CFFI notmuch_*_valid function.
|
||||
:param fn_get: The CFFI notmuch_*_get function.
|
||||
:param fn_next: The CFFI notmuch_*_move_to_next function.
|
||||
"""
|
||||
_iter_p = MemoryPointer()
|
||||
|
||||
def __init__(self, parent, iter_p,
|
||||
*, fn_destroy, fn_valid, fn_get, fn_next):
|
||||
self._parent = parent
|
||||
self._iter_p = iter_p
|
||||
self._fn_destroy = fn_destroy
|
||||
self._fn_valid = fn_valid
|
||||
self._fn_get = fn_get
|
||||
self._fn_next = fn_next
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._parent.alive:
|
||||
return False
|
||||
try:
|
||||
self._iter_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _destroy(self):
|
||||
if self.alive:
|
||||
try:
|
||||
self._fn_destroy(self._iter_p)
|
||||
except errors.ObjectDestroyedError:
|
||||
pass
|
||||
self._iter_p = None
|
||||
|
||||
def __iter__(self):
|
||||
"""Return the iterator itself.
|
||||
|
||||
Note that as this is an iterator and not a container this will
|
||||
not return a new iterator. Thus any elements already consumed
|
||||
will not be yielded by the :meth:`__next__` method anymore.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if not self._fn_valid(self._iter_p):
|
||||
self._destroy()
|
||||
raise StopIteration()
|
||||
obj_p = self._fn_get(self._iter_p)
|
||||
self._fn_next(self._iter_p)
|
||||
return obj_p
|
||||
|
||||
def __repr__(self):
|
||||
try:
|
||||
self._iter_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return '<NotmuchIter (exhausted)>'
|
||||
else:
|
||||
return '<NotmuchIter>'
|
302
bindings/python-cffi/notdb/_build.py
Normal file
302
bindings/python-cffi/notdb/_build.py
Normal file
|
@ -0,0 +1,302 @@
|
|||
import cffi
|
||||
|
||||
|
||||
ffibuilder = cffi.FFI()
|
||||
ffibuilder.set_source(
|
||||
'notdb._capi',
|
||||
r"""
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <notmuch.h>
|
||||
|
||||
#if LIBNOTMUCH_MAJOR_VERSION < 5
|
||||
#error libnotmuch version not supported by notdb
|
||||
#endif
|
||||
""",
|
||||
include_dirs=['../../lib'],
|
||||
library_dirs=['../../lib'],
|
||||
libraries=['notmuch'],
|
||||
)
|
||||
ffibuilder.cdef(
|
||||
r"""
|
||||
void free(void *ptr);
|
||||
typedef int... time_t;
|
||||
|
||||
#define LIBNOTMUCH_MAJOR_VERSION ...
|
||||
#define LIBNOTMUCH_MINOR_VERSION ...
|
||||
#define LIBNOTMUCH_MICRO_VERSION ...
|
||||
|
||||
#define NOTMUCH_TAG_MAX ...
|
||||
|
||||
typedef enum _notmuch_status {
|
||||
NOTMUCH_STATUS_SUCCESS = 0,
|
||||
NOTMUCH_STATUS_OUT_OF_MEMORY,
|
||||
NOTMUCH_STATUS_READ_ONLY_DATABASE,
|
||||
NOTMUCH_STATUS_XAPIAN_EXCEPTION,
|
||||
NOTMUCH_STATUS_FILE_ERROR,
|
||||
NOTMUCH_STATUS_FILE_NOT_EMAIL,
|
||||
NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID,
|
||||
NOTMUCH_STATUS_NULL_POINTER,
|
||||
NOTMUCH_STATUS_TAG_TOO_LONG,
|
||||
NOTMUCH_STATUS_UNBALANCED_FREEZE_THAW,
|
||||
NOTMUCH_STATUS_UNBALANCED_ATOMIC,
|
||||
NOTMUCH_STATUS_UNSUPPORTED_OPERATION,
|
||||
NOTMUCH_STATUS_UPGRADE_REQUIRED,
|
||||
NOTMUCH_STATUS_PATH_ERROR,
|
||||
NOTMUCH_STATUS_ILLEGAL_ARGUMENT,
|
||||
NOTMUCH_STATUS_LAST_STATUS
|
||||
} notmuch_status_t;
|
||||
typedef enum {
|
||||
NOTMUCH_DATABASE_MODE_READ_ONLY = 0,
|
||||
NOTMUCH_DATABASE_MODE_READ_WRITE
|
||||
} notmuch_database_mode_t;
|
||||
typedef int notmuch_bool_t;
|
||||
typedef enum _notmuch_message_flag {
|
||||
NOTMUCH_MESSAGE_FLAG_MATCH,
|
||||
NOTMUCH_MESSAGE_FLAG_EXCLUDED,
|
||||
NOTMUCH_MESSAGE_FLAG_GHOST,
|
||||
} notmuch_message_flag_t;
|
||||
typedef enum {
|
||||
NOTMUCH_SORT_OLDEST_FIRST,
|
||||
NOTMUCH_SORT_NEWEST_FIRST,
|
||||
NOTMUCH_SORT_MESSAGE_ID,
|
||||
NOTMUCH_SORT_UNSORTED
|
||||
} notmuch_sort_t;
|
||||
typedef enum {
|
||||
NOTMUCH_EXCLUDE_FLAG,
|
||||
NOTMUCH_EXCLUDE_TRUE,
|
||||
NOTMUCH_EXCLUDE_FALSE,
|
||||
NOTMUCH_EXCLUDE_ALL
|
||||
} notmuch_exclude_t;
|
||||
|
||||
// These are fully opaque types for us, we only ever use pointers.
|
||||
typedef struct _notmuch_database notmuch_database_t;
|
||||
typedef struct _notmuch_query notmuch_query_t;
|
||||
typedef struct _notmuch_threads notmuch_threads_t;
|
||||
typedef struct _notmuch_thread notmuch_thread_t;
|
||||
typedef struct _notmuch_messages notmuch_messages_t;
|
||||
typedef struct _notmuch_message notmuch_message_t;
|
||||
typedef struct _notmuch_tags notmuch_tags_t;
|
||||
typedef struct _notmuch_string_map_iterator notmuch_message_properties_t;
|
||||
typedef struct _notmuch_directory notmuch_directory_t;
|
||||
typedef struct _notmuch_filenames notmuch_filenames_t;
|
||||
typedef struct _notmuch_config_list notmuch_config_list_t;
|
||||
|
||||
const char *
|
||||
notmuch_status_to_string (notmuch_status_t status);
|
||||
|
||||
notmuch_status_t
|
||||
notmuch_database_create_verbose (const char *path,
|
||||
notmuch_database_t **database,
|
||||
char **error_message);
|
||||
notmuch_status_t
|
||||
notmuch_database_create (const char *path, notmuch_database_t **database);
|
||||
notmuch_status_t
|
||||
notmuch_database_open_verbose (const char *path,
|
||||
notmuch_database_mode_t mode,
|
||||
notmuch_database_t **database,
|
||||
char **error_message);
|
||||
notmuch_status_t
|
||||
notmuch_database_open (const char *path,
|
||||
notmuch_database_mode_t mode,
|
||||
notmuch_database_t **database);
|
||||
notmuch_status_t
|
||||
notmuch_database_close (notmuch_database_t *database);
|
||||
notmuch_status_t
|
||||
notmuch_database_destroy (notmuch_database_t *database);
|
||||
const char *
|
||||
notmuch_database_get_path (notmuch_database_t *database);
|
||||
unsigned int
|
||||
notmuch_database_get_version (notmuch_database_t *database);
|
||||
notmuch_bool_t
|
||||
notmuch_database_needs_upgrade (notmuch_database_t *database);
|
||||
notmuch_status_t
|
||||
notmuch_database_begin_atomic (notmuch_database_t *notmuch);
|
||||
notmuch_status_t
|
||||
notmuch_database_end_atomic (notmuch_database_t *notmuch);
|
||||
unsigned long
|
||||
notmuch_database_get_revision (notmuch_database_t *notmuch,
|
||||
const char **uuid);
|
||||
notmuch_status_t
|
||||
notmuch_database_add_message (notmuch_database_t *database,
|
||||
const char *filename,
|
||||
notmuch_message_t **message);
|
||||
notmuch_status_t
|
||||
notmuch_database_remove_message (notmuch_database_t *database,
|
||||
const char *filename);
|
||||
notmuch_status_t
|
||||
notmuch_database_find_message (notmuch_database_t *database,
|
||||
const char *message_id,
|
||||
notmuch_message_t **message);
|
||||
notmuch_status_t
|
||||
notmuch_database_find_message_by_filename (notmuch_database_t *notmuch,
|
||||
const char *filename,
|
||||
notmuch_message_t **message);
|
||||
notmuch_tags_t *
|
||||
notmuch_database_get_all_tags (notmuch_database_t *db);
|
||||
|
||||
notmuch_query_t *
|
||||
notmuch_query_create (notmuch_database_t *database,
|
||||
const char *query_string);
|
||||
const char *
|
||||
notmuch_query_get_query_string (const notmuch_query_t *query);
|
||||
notmuch_database_t *
|
||||
notmuch_query_get_database (const notmuch_query_t *query);
|
||||
void
|
||||
notmuch_query_set_omit_excluded (notmuch_query_t *query,
|
||||
notmuch_exclude_t omit_excluded);
|
||||
void
|
||||
notmuch_query_set_sort (notmuch_query_t *query, notmuch_sort_t sort);
|
||||
notmuch_sort_t
|
||||
notmuch_query_get_sort (const notmuch_query_t *query);
|
||||
notmuch_status_t
|
||||
notmuch_query_add_tag_exclude (notmuch_query_t *query, const char *tag);
|
||||
notmuch_status_t
|
||||
notmuch_query_search_threads (notmuch_query_t *query,
|
||||
notmuch_threads_t **out);
|
||||
notmuch_status_t
|
||||
notmuch_query_search_messages (notmuch_query_t *query,
|
||||
notmuch_messages_t **out);
|
||||
notmuch_status_t
|
||||
notmuch_query_count_messages (notmuch_query_t *query, unsigned int *count);
|
||||
notmuch_status_t
|
||||
notmuch_query_count_threads (notmuch_query_t *query, unsigned *count);
|
||||
void
|
||||
notmuch_query_destroy (notmuch_query_t *query);
|
||||
|
||||
notmuch_bool_t
|
||||
notmuch_threads_valid (notmuch_threads_t *threads);
|
||||
notmuch_thread_t *
|
||||
notmuch_threads_get (notmuch_threads_t *threads);
|
||||
void
|
||||
notmuch_threads_move_to_next (notmuch_threads_t *threads);
|
||||
void
|
||||
notmuch_threads_destroy (notmuch_threads_t *threads);
|
||||
|
||||
const char *
|
||||
notmuch_thread_get_thread_id (notmuch_thread_t *thread);
|
||||
notmuch_messages_t *
|
||||
notmuch_message_get_replies (notmuch_message_t *message);
|
||||
int
|
||||
notmuch_thread_get_total_messages (notmuch_thread_t *thread);
|
||||
notmuch_messages_t *
|
||||
notmuch_thread_get_toplevel_messages (notmuch_thread_t *thread);
|
||||
notmuch_messages_t *
|
||||
notmuch_thread_get_messages (notmuch_thread_t *thread);
|
||||
int
|
||||
notmuch_thread_get_matched_messages (notmuch_thread_t *thread);
|
||||
const char *
|
||||
notmuch_thread_get_authors (notmuch_thread_t *thread);
|
||||
const char *
|
||||
notmuch_thread_get_subject (notmuch_thread_t *thread);
|
||||
time_t
|
||||
notmuch_thread_get_oldest_date (notmuch_thread_t *thread);
|
||||
time_t
|
||||
notmuch_thread_get_newest_date (notmuch_thread_t *thread);
|
||||
notmuch_tags_t *
|
||||
notmuch_thread_get_tags (notmuch_thread_t *thread);
|
||||
void
|
||||
notmuch_thread_destroy (notmuch_thread_t *thread);
|
||||
|
||||
notmuch_bool_t
|
||||
notmuch_messages_valid (notmuch_messages_t *messages);
|
||||
notmuch_message_t *
|
||||
notmuch_messages_get (notmuch_messages_t *messages);
|
||||
void
|
||||
notmuch_messages_move_to_next (notmuch_messages_t *messages);
|
||||
void
|
||||
notmuch_messages_destroy (notmuch_messages_t *messages);
|
||||
notmuch_tags_t *
|
||||
notmuch_messages_collect_tags (notmuch_messages_t *messages);
|
||||
|
||||
const char *
|
||||
notmuch_message_get_message_id (notmuch_message_t *message);
|
||||
const char *
|
||||
notmuch_message_get_thread_id (notmuch_message_t *message);
|
||||
const char *
|
||||
notmuch_message_get_filename (notmuch_message_t *message);
|
||||
notmuch_filenames_t *
|
||||
notmuch_message_get_filenames (notmuch_message_t *message);
|
||||
notmuch_bool_t
|
||||
notmuch_message_get_flag (notmuch_message_t *message,
|
||||
notmuch_message_flag_t flag);
|
||||
void
|
||||
notmuch_message_set_flag (notmuch_message_t *message,
|
||||
notmuch_message_flag_t flag,
|
||||
notmuch_bool_t value);
|
||||
time_t
|
||||
notmuch_message_get_date (notmuch_message_t *message);
|
||||
const char *
|
||||
notmuch_message_get_header (notmuch_message_t *message,
|
||||
const char *header);
|
||||
notmuch_tags_t *
|
||||
notmuch_message_get_tags (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_add_tag (notmuch_message_t *message, const char *tag);
|
||||
notmuch_status_t
|
||||
notmuch_message_remove_tag (notmuch_message_t *message, const char *tag);
|
||||
notmuch_status_t
|
||||
notmuch_message_remove_all_tags (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_maildir_flags_to_tags (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_tags_to_maildir_flags (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_freeze (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_thaw (notmuch_message_t *message);
|
||||
notmuch_status_t
|
||||
notmuch_message_get_property (notmuch_message_t *message,
|
||||
const char *key, const char **value);
|
||||
notmuch_status_t
|
||||
notmuch_message_add_property (notmuch_message_t *message,
|
||||
const char *key, const char *value);
|
||||
notmuch_status_t
|
||||
notmuch_message_remove_property (notmuch_message_t *message,
|
||||
const char *key, const char *value);
|
||||
notmuch_status_t
|
||||
notmuch_message_remove_all_properties (notmuch_message_t *message,
|
||||
const char *key);
|
||||
notmuch_message_properties_t *
|
||||
notmuch_message_get_properties (notmuch_message_t *message,
|
||||
const char *key, notmuch_bool_t exact);
|
||||
notmuch_bool_t
|
||||
notmuch_message_properties_valid (notmuch_message_properties_t
|
||||
*properties);
|
||||
void
|
||||
notmuch_message_properties_move_to_next (notmuch_message_properties_t
|
||||
*properties);
|
||||
const char *
|
||||
notmuch_message_properties_key (notmuch_message_properties_t *properties);
|
||||
const char *
|
||||
notmuch_message_properties_value (notmuch_message_properties_t
|
||||
*properties);
|
||||
void
|
||||
notmuch_message_properties_destroy (notmuch_message_properties_t
|
||||
*properties);
|
||||
void
|
||||
notmuch_message_destroy (notmuch_message_t *message);
|
||||
|
||||
notmuch_bool_t
|
||||
notmuch_tags_valid (notmuch_tags_t *tags);
|
||||
const char *
|
||||
notmuch_tags_get (notmuch_tags_t *tags);
|
||||
void
|
||||
notmuch_tags_move_to_next (notmuch_tags_t *tags);
|
||||
void
|
||||
notmuch_tags_destroy (notmuch_tags_t *tags);
|
||||
|
||||
notmuch_bool_t
|
||||
notmuch_filenames_valid (notmuch_filenames_t *filenames);
|
||||
const char *
|
||||
notmuch_filenames_get (notmuch_filenames_t *filenames);
|
||||
void
|
||||
notmuch_filenames_move_to_next (notmuch_filenames_t *filenames);
|
||||
void
|
||||
notmuch_filenames_destroy (notmuch_filenames_t *filenames);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ffibuilder.compile(verbose=True)
|
705
bindings/python-cffi/notdb/_database.py
Normal file
705
bindings/python-cffi/notdb/_database.py
Normal file
|
@ -0,0 +1,705 @@
|
|||
import collections
|
||||
import configparser
|
||||
import enum
|
||||
import functools
|
||||
import os
|
||||
import pathlib
|
||||
import weakref
|
||||
|
||||
import notdb._base as base
|
||||
import notdb._capi as capi
|
||||
import notdb._errors as errors
|
||||
import notdb._message as message
|
||||
import notdb._query as querymod
|
||||
import notdb._tags as tags
|
||||
|
||||
|
||||
__all__ = ['Database', 'AtomicContext', 'DbRevision']
|
||||
|
||||
|
||||
def _config_pathname():
|
||||
"""Return the path of the configuration file.
|
||||
|
||||
:rtype: pathlib.Path
|
||||
"""
|
||||
cfgfname = os.getenv('NOTMUCH_CONFIG', '~/.notmuch-config')
|
||||
return pathlib.Path(os.path.expanduser(cfgfname))
|
||||
|
||||
|
||||
class Mode(enum.Enum):
|
||||
READ_ONLY = capi.lib.NOTMUCH_DATABASE_MODE_READ_ONLY
|
||||
READ_WRITE = capi.lib.NOTMUCH_DATABASE_MODE_READ_WRITE
|
||||
|
||||
|
||||
class QuerySortOrder(enum.Enum):
|
||||
OLDEST_FIRST = capi.lib.NOTMUCH_SORT_OLDEST_FIRST
|
||||
NEWEST_FIRST = capi.lib.NOTMUCH_SORT_NEWEST_FIRST
|
||||
MESSAGE_ID = capi.lib.NOTMUCH_SORT_MESSAGE_ID
|
||||
UNSORTED = capi.lib.NOTMUCH_SORT_UNSORTED
|
||||
|
||||
|
||||
class QueryExclude(enum.Enum):
|
||||
TRUE = capi.lib.NOTMUCH_EXCLUDE_TRUE
|
||||
FLAG = capi.lib.NOTMUCH_EXCLUDE_FLAG
|
||||
FALSE = capi.lib.NOTMUCH_EXCLUDE_FALSE
|
||||
ALL = capi.lib.NOTMUCH_EXCLUDE_ALL
|
||||
|
||||
|
||||
class Database(base.NotmuchObject):
|
||||
"""Toplevel access to notmuch.
|
||||
|
||||
A :class:`Database` can be opened read-only or read-write.
|
||||
Modifications are not atomic by default, use :meth:`begin_atomic`
|
||||
for atomic updates. If the underlying database has been modified
|
||||
outside of this class a :exc:`XapianError` will be raised and the
|
||||
instance must be closed and a new one created.
|
||||
|
||||
You can use an instance of this class as a context-manager.
|
||||
|
||||
:cvar MODE: The mode a database can be opened with, an enumeration
|
||||
of ``READ_ONLY`` and ``READ_WRITE``
|
||||
:cvar SORT: The sort order for search results, ``OLDEST_FIRST``,
|
||||
``NEWEST_FIRST``, ``MESSAGE_ID`` or ``UNSORTED``.
|
||||
:cvar EXCLUDE: Which messages to exclude from queries, ``TRUE``,
|
||||
``FLAG``, ``FALSE`` or ``ALL``. See the query documentation
|
||||
for details.
|
||||
:cvar AddedMessage: A namedtuple ``(msg, dup)`` used by
|
||||
:meth:`add` as return value.
|
||||
:cvar STR_MODE_MAP: A map mapping strings to :attr:`MODE` items.
|
||||
This is used to implement the ``ro`` and ``rw`` string
|
||||
variants.
|
||||
|
||||
:ivar closed: Boolean indicating if the database is closed or
|
||||
still open.
|
||||
|
||||
:param path: The directory of where the database is stored. If
|
||||
``None`` the location will be read from the user's
|
||||
configuration file, respecting the ``NOTMUCH_CONFIG``
|
||||
environment variable if set.
|
||||
:type path: str, bytes, os.PathLike or pathlib.Path
|
||||
:param mode: The mode to open the database in. One of
|
||||
:attr:`MODE.READ_ONLY` OR :attr:`MODE.READ_WRITE`. For
|
||||
convenience you can also use the strings ``ro`` for
|
||||
:attr:`MODE.READ_ONLY` and ``rw`` for :attr:`MODE.READ_WRITE`.
|
||||
:type mode: :attr:`MODE` or str.
|
||||
|
||||
:raises KeyError: if an unknown mode string is used.
|
||||
:raises OSError: or subclasses if the configuration file can not
|
||||
be opened.
|
||||
:raises configparser.Error: or subclasses if the configuration
|
||||
file can not be parsed.
|
||||
:raises NotmuchError: or subclasses for other failures.
|
||||
"""
|
||||
|
||||
MODE = Mode
|
||||
SORT = QuerySortOrder
|
||||
EXCLUDE = QueryExclude
|
||||
AddedMessage = collections.namedtuple('AddedMessage', ['msg', 'dup'])
|
||||
_db_p = base.MemoryPointer()
|
||||
STR_MODE_MAP = {
|
||||
'ro': MODE.READ_ONLY,
|
||||
'rw': MODE.READ_WRITE,
|
||||
}
|
||||
|
||||
def __init__(self, path=None, mode=MODE.READ_ONLY):
|
||||
if isinstance(mode, str):
|
||||
mode = self.STR_MODE_MAP[mode]
|
||||
self.mode = mode
|
||||
if path is None:
|
||||
path = self.default_path()
|
||||
if not hasattr(os, 'PathLike') and isinstance(path, pathlib.Path):
|
||||
path = bytes(path)
|
||||
db_pp = capi.ffi.new('notmuch_database_t **')
|
||||
cmsg = capi.ffi.new('char**')
|
||||
ret = capi.lib.notmuch_database_open_verbose(os.fsencode(path),
|
||||
mode.value, db_pp, cmsg)
|
||||
if cmsg[0]:
|
||||
msg = capi.ffi.string(cmsg[0]).decode(errors='replace')
|
||||
capi.lib.free(cmsg[0])
|
||||
else:
|
||||
msg = None
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret, msg)
|
||||
self._db_p = db_pp[0]
|
||||
self.closed = False
|
||||
|
||||
@classmethod
|
||||
def create(cls, path=None):
|
||||
"""Create and open database in READ_WRITE mode.
|
||||
|
||||
This is creates a new notmuch database and returns an opened
|
||||
instance in :attr:`MODE.READ_WRITE` mode.
|
||||
|
||||
:param path: The directory of where the database is stored. If
|
||||
``None`` the location will be read from the user's
|
||||
configuration file, respecting the ``NOTMUCH_CONFIG``
|
||||
environment variable if set.
|
||||
:type path: str, bytes or os.PathLike
|
||||
|
||||
:raises OSError: or subclasses if the configuration file can not
|
||||
be opened.
|
||||
:raises configparser.Error: or subclasses if the configuration
|
||||
file can not be parsed.
|
||||
:raises NotmuchError: if the config file does not have the
|
||||
database.path setting.
|
||||
:raises FileError: if the database already exists.
|
||||
|
||||
:returns: The newly created instance.
|
||||
"""
|
||||
if path is None:
|
||||
path = cls.default_path()
|
||||
if not hasattr(os, 'PathLike') and isinstance(path, pathlib.Path):
|
||||
path = bytes(path)
|
||||
db_pp = capi.ffi.new('notmuch_database_t **')
|
||||
cmsg = capi.ffi.new('char**')
|
||||
ret = capi.lib.notmuch_database_create_verbose(os.fsencode(path),
|
||||
db_pp, cmsg)
|
||||
if cmsg[0]:
|
||||
msg = capi.ffi.string(cmsg[0]).decode(errors='replace')
|
||||
capi.lib.free(cmsg[0])
|
||||
else:
|
||||
msg = None
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret, msg)
|
||||
|
||||
# Now close the db and let __init__ open it. Inefficient but
|
||||
# creating is not a hot loop while this allows us to have a
|
||||
# clean API.
|
||||
ret = capi.lib.notmuch_database_destroy(db_pp[0])
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return cls(path, cls.MODE.READ_WRITE)
|
||||
|
||||
@staticmethod
|
||||
def default_path(cfg_path=None):
|
||||
"""Return the path of the user's default database.
|
||||
|
||||
This reads the user's configuration file and returns the
|
||||
default path of the database.
|
||||
|
||||
:param cfg_path: The pathname of the notmuch configuration file.
|
||||
If not specified tries to use the pathname provided in the
|
||||
:env:`NOTMUCH_CONFIG` environment variable and falls back
|
||||
to :file:`~/.notmuch-config.
|
||||
:type cfg_path: str, bytes, os.PathLike or pathlib.Path.
|
||||
|
||||
:returns: The path of the database, which does not necessarily
|
||||
exists.
|
||||
:rtype: pathlib.Path
|
||||
:raises OSError: or subclasses if the configuration file can not
|
||||
be opened.
|
||||
:raises configparser.Error: or subclasses if the configuration
|
||||
file can not be parsed.
|
||||
:raises NotmuchError if the config file does not have the
|
||||
database.path setting.
|
||||
"""
|
||||
if not cfg_path:
|
||||
cfg_path = _config_pathname()
|
||||
if not hasattr(os, 'PathLike') and isinstance(cfg_path, pathlib.Path):
|
||||
cfg_path = bytes(cfg_path)
|
||||
parser = configparser.ConfigParser()
|
||||
with open(cfg_path) as fp:
|
||||
parser.read_file(fp)
|
||||
try:
|
||||
return pathlib.Path(parser.get('database', 'path'))
|
||||
except configparser.Error:
|
||||
raise errors.NotmuchError(
|
||||
'No database.path setting in {}'.format(cfg_path))
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
try:
|
||||
self._db_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _destroy(self):
|
||||
try:
|
||||
ret = capi.lib.notmuch_database_destroy(self._db_p)
|
||||
except errors.ObjectDestroyedError:
|
||||
ret = capi.lib.NOTMUCH_STATUS_SUCCESS
|
||||
else:
|
||||
self._db_p = None
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def close(self):
|
||||
"""Close the notmuch database.
|
||||
|
||||
Once closed most operations will fail. This can still be
|
||||
useful however to explicitly close a database which is opened
|
||||
read-write as this would otherwise stop other processes from
|
||||
reading the database while it is open.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destroyed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_database_close(self._db_p)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""The pathname of the notmuch database.
|
||||
|
||||
This is returned as a :class:`pathlib.Path` instance.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
try:
|
||||
return self._cache_path
|
||||
except AttributeError:
|
||||
ret = capi.lib.notmuch_database_get_path(self._db_p)
|
||||
self._cache_path = pathlib.Path(os.fsdecode(capi.ffi.string(ret)))
|
||||
return self._cache_path
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""The database format version.
|
||||
|
||||
This is a positive integer.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
try:
|
||||
return self._cache_version
|
||||
except AttributeError:
|
||||
ret = capi.lib.notmuch_database_get_version(self._db_p)
|
||||
self._cache_version = ret
|
||||
return ret
|
||||
|
||||
@property
|
||||
def needs_upgrade(self):
|
||||
"""Whether the database should be upgraded.
|
||||
|
||||
If *True* the database can be upgraded using :meth:`upgrade`.
|
||||
Not doing so may result in some operations raising
|
||||
:exc:`UpgradeRequiredError`.
|
||||
|
||||
A read-only database will never be upgradable.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_database_needs_upgrade(self._db_p)
|
||||
return bool(ret)
|
||||
|
||||
def upgrade(self, progress_cb=None):
|
||||
"""Upgrade the database to the latest version.
|
||||
|
||||
Upgrade the database, optionally with a progress callback
|
||||
which should be a callable which will be called with a
|
||||
floating point number in the range of [0.0 .. 1.0].
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def atomic(self):
|
||||
"""Return a context manager to perform atomic operations.
|
||||
|
||||
The returned context manager can be used to perform atomic
|
||||
operations on the database.
|
||||
|
||||
.. note:: Unlinke a traditional RDBMS transaction this does
|
||||
not imply durability, it only ensures the changes are
|
||||
performed atomically.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ctx = AtomicContext(self, '_db_p')
|
||||
return ctx
|
||||
|
||||
def revision(self):
|
||||
"""The currently committed revision in the database.
|
||||
|
||||
Returned as a ``(revision, uuid)`` namedtuple.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
raw_uuid = capi.ffi.new('char**')
|
||||
rev = capi.lib.notmuch_database_get_revision(self._db_p, raw_uuid)
|
||||
return DbRevision(rev, capi.ffi.string(raw_uuid[0]))
|
||||
|
||||
def get_directory(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def add(self, filename, *, sync_flags=False):
|
||||
"""Add a message to the database.
|
||||
|
||||
Add a new message to the notmuch database. The message is
|
||||
referred to by the pathname of the maildir file. If the
|
||||
message ID of the new message already exists in the database,
|
||||
this adds ``pathname`` to the list of list of files for the
|
||||
existing message.
|
||||
|
||||
:param filename: The path of the file containing the message.
|
||||
:type filename: str, bytes, os.PathLike or pathlib.Path.
|
||||
:param sync_flags: Whether to sync the known maildir flags to
|
||||
notmuch tags. See :meth:`Message.flags_to_tags` for
|
||||
details.
|
||||
|
||||
:returns: A tuple where the first item is the newly inserted
|
||||
messages as a :class:`Message` instance, and the second
|
||||
item is a boolean indicating if the message inserted was a
|
||||
duplicate. This is the namedtuple ``AddedMessage(msg,
|
||||
dup)``.
|
||||
:rtype: Database.AddedMessage
|
||||
|
||||
If an exception is raised, no message was added.
|
||||
|
||||
:raises XapianError: A Xapian exception occurred.
|
||||
:raises FileError: The file referred to by ``pathname`` could
|
||||
not be opened.
|
||||
:raises FileNotEmailError: The file referreed to by
|
||||
``pathname`` is not recognised as an email message.
|
||||
:raises ReadOnlyDatabaseError: The database is opened in
|
||||
READ_ONLY mode.
|
||||
:raises UpgradeRequiredError: The database must be upgraded
|
||||
first.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
|
||||
filename = bytes(filename)
|
||||
msg_pp = capi.ffi.new('notmuch_message_t **')
|
||||
ret = capi.lib.notmuch_database_add_message(self._db_p,
|
||||
os.fsencode(filename),
|
||||
msg_pp)
|
||||
ok = [capi.lib.NOTMUCH_STATUS_SUCCESS,
|
||||
capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID]
|
||||
if ret not in ok:
|
||||
raise errors.NotmuchError(ret)
|
||||
msg = message.Message(self, msg_pp[0], db=self)
|
||||
if sync_flags:
|
||||
msg.tags.from_maildir_flags()
|
||||
return self.AddedMessage(
|
||||
msg, ret == capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID)
|
||||
|
||||
def remove(self, filename):
|
||||
"""Remove a message from the notmuch database.
|
||||
|
||||
Removing a message which is not in the database is just a
|
||||
silent nop-operation.
|
||||
|
||||
:param filename: The pathname of the file containing the
|
||||
message to be removed.
|
||||
:type filename: str, bytes, os.PathLike or pathlib.Path.
|
||||
|
||||
:returns: True if the message is still in the database. This
|
||||
can happen when multiple files contain the same message ID.
|
||||
The true/false distinction is fairly arbitrary, but think
|
||||
of it as ``dup = db.remove_message(name); if dup: ...``.
|
||||
:rtype: bool
|
||||
|
||||
:raises XapianError: A Xapian exception ocurred.
|
||||
:raises ReadOnlyDatabaseError: The database is opened in
|
||||
READ_ONLY mode.
|
||||
:raises UpgradeRequiredError: The database must be upgraded
|
||||
first.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
|
||||
filename = bytes(filename)
|
||||
ret = capi.lib.notmuch_database_remove_message(self._db_p,
|
||||
os.fsencode(filename))
|
||||
ok = [capi.lib.NOTMUCH_STATUS_SUCCESS,
|
||||
capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID]
|
||||
if ret not in ok:
|
||||
raise errors.NotmuchError(ret)
|
||||
if ret == capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def find(self, msgid):
|
||||
"""Return the message matching the given message ID.
|
||||
|
||||
If a message with the given message ID is found a
|
||||
:class:`Message` instance is returned. Otherwise a
|
||||
:exc:`LookupError` is raised.
|
||||
|
||||
:param msgid: The message ID to look for.
|
||||
:type msgid: str
|
||||
|
||||
:returns: The message instance.
|
||||
:rtype: Message
|
||||
|
||||
:raises LookupError: If no message was found.
|
||||
:raises OutOfMemoryError: When there is no memory to allocate
|
||||
the message instance.
|
||||
:raises XapianError: A Xapian exception ocurred.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
msg_pp = capi.ffi.new('notmuch_message_t **')
|
||||
ret = capi.lib.notmuch_database_find_message(self._db_p,
|
||||
msgid.encode(), msg_pp)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
msg_p = msg_pp[0]
|
||||
if msg_p == capi.ffi.NULL:
|
||||
raise LookupError
|
||||
msg = message.Message(self, msg_p, db=self)
|
||||
return msg
|
||||
|
||||
def get(self, filename):
|
||||
"""Return the :class:`Message` given a pathname.
|
||||
|
||||
If a message with the given pathname exists in the database
|
||||
return the :class:`Message` instance for the message.
|
||||
Otherwise raise a :exc:`LookupError` exception.
|
||||
|
||||
:param filename: The pathname of the message.
|
||||
:type filename: str, bytes, os.PathLike or pathlib.Path
|
||||
|
||||
:returns: The message instance.
|
||||
:rtype: Message
|
||||
|
||||
:raises LookupError: If no message was found. This is also
|
||||
a subclass of :exc:`KeyError`.
|
||||
:raises OutOfMemoryError: When there is no memory to allocate
|
||||
the message instance.
|
||||
:raises XapianError: A Xapian exception ocurred.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
|
||||
filename = bytes(filename)
|
||||
msg_pp = capi.ffi.new('notmuch_message_t **')
|
||||
ret = capi.lib.notmuch_database_find_message_by_filename(
|
||||
self._db_p, os.fsencode(filename), msg_pp)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
msg_p = msg_pp[0]
|
||||
if msg_p == capi.ffi.NULL:
|
||||
raise LookupError
|
||||
msg = message.Message(self, msg_p, db=self)
|
||||
return msg
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""Return an immutable set with all tags used in this database.
|
||||
|
||||
This returns an immutable set-like object implementing the
|
||||
collections.abc.Set Abstract Base Class. Due to the
|
||||
underlying libnotmuch implementation some operations have
|
||||
different performance characteristics then plain set objects.
|
||||
Mainly any lookup operation is O(n) rather then O(1).
|
||||
|
||||
Normal usage treats tags as UTF-8 encoded unicode strings so
|
||||
they are exposed to Python as normal unicode string objects.
|
||||
If you need to handle tags stored in libnotmuch which are not
|
||||
valid unicode do check the :class:`ImmutableTagSet` docs for
|
||||
how to handle this.
|
||||
|
||||
:rtype: ImmutableTagSet
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
try:
|
||||
ref = self._cached_tagset
|
||||
except AttributeError:
|
||||
tagset = None
|
||||
else:
|
||||
tagset = ref()
|
||||
if tagset is None:
|
||||
tagset = tags.ImmutableTagSet(
|
||||
self, '_db_p', capi.lib.notmuch_database_get_all_tags)
|
||||
self._cached_tagset = weakref.ref(tagset)
|
||||
return tagset
|
||||
|
||||
def _create_query(self, query, *,
|
||||
omit_excluded=EXCLUDE.TRUE,
|
||||
sort=SORT.UNSORTED, # Check this default
|
||||
exclude_tags=None):
|
||||
"""Create an internal query object.
|
||||
|
||||
:raises OutOfMemoryError: if no memory is available to
|
||||
allocate the query.
|
||||
"""
|
||||
if isinstance(query, str):
|
||||
query = query.encode('utf-8')
|
||||
query_p = capi.lib.notmuch_query_create(self._db_p, query)
|
||||
if query_p == capi.ffi.NULL:
|
||||
raise errors.OutOfMemoryError()
|
||||
capi.lib.notmuch_query_set_omit_excluded(query_p, omit_excluded.value)
|
||||
capi.lib.notmuch_query_set_sort(query_p, sort.value)
|
||||
if exclude_tags is not None:
|
||||
for tag in exclude_tags:
|
||||
if isinstance(tag, str):
|
||||
tag = str.encode('utf-8')
|
||||
capi.lib.notmuch_query_add_tag_exclude(query_p, tag)
|
||||
return querymod.Query(self, query_p)
|
||||
|
||||
def messages(self, query, *,
|
||||
omit_excluded=EXCLUDE.TRUE,
|
||||
sort=SORT.UNSORTED, # Check this default
|
||||
exclude_tags=None):
|
||||
"""Search the database for messages.
|
||||
|
||||
:returns: An iterator over the messages found.
|
||||
:rtype: MessageIter
|
||||
|
||||
:raises OutOfMemoryError: if no memory is available to
|
||||
allocate the query.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
query = self._create_query(query,
|
||||
omit_excluded=omit_excluded,
|
||||
sort=sort,
|
||||
exclude_tags=exclude_tags)
|
||||
return query.messages()
|
||||
|
||||
def count_messages(self, query, *,
|
||||
omit_excluded=EXCLUDE.TRUE,
|
||||
sort=SORT.UNSORTED, # Check this default
|
||||
exclude_tags=None):
|
||||
"""Search the database for messages.
|
||||
|
||||
:returns: An iterator over the messages found.
|
||||
:rtype: MessageIter
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
query = self._create_query(query,
|
||||
omit_excluded=omit_excluded,
|
||||
sort=sort,
|
||||
exclude_tags=exclude_tags)
|
||||
return query.count_messages()
|
||||
|
||||
def threads(self, query, *,
|
||||
omit_excluded=EXCLUDE.TRUE,
|
||||
sort=SORT.UNSORTED, # Check this default
|
||||
exclude_tags=None):
|
||||
query = self._create_query(query,
|
||||
omit_excluded=omit_excluded,
|
||||
sort=sort,
|
||||
exclude_tags=exclude_tags)
|
||||
return query.threads()
|
||||
|
||||
def count_threads(self, query, *,
|
||||
omit_excluded=EXCLUDE.TRUE,
|
||||
sort=SORT.UNSORTED, # Check this default
|
||||
exclude_tags=None):
|
||||
query = self._create_query(query,
|
||||
omit_excluded=omit_excluded,
|
||||
sort=sort,
|
||||
exclude_tags=exclude_tags)
|
||||
return query.count_threads()
|
||||
|
||||
def status_string(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self):
|
||||
return 'Database(path={self.path}, mode={self.mode})'.format(self=self)
|
||||
|
||||
|
||||
class AtomicContext:
|
||||
"""Context manager for atomic support.
|
||||
|
||||
This supports the notmuch_database_begin_atomic and
|
||||
notmuch_database_end_atomic API calls. The object can not be
|
||||
directly instantiated by the user, only via ``Database.atomic``.
|
||||
It does keep a reference to the :class:`Database` instance to keep
|
||||
the C memory alive.
|
||||
|
||||
:raises XapianError: When this is raised at enter time the atomic
|
||||
section is not active. When it is raised at exit time the
|
||||
atomic section is still active and you may need to try using
|
||||
:meth:`force_end`.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
|
||||
def __init__(self, db, ptr_name):
|
||||
self._db = db
|
||||
self._ptr = lambda: getattr(db, ptr_name)
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
return self.parent.alive
|
||||
|
||||
def _destroy(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
ret = capi.lib.notmuch_database_begin_atomic(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
ret = capi.lib.notmuch_database_end_atomic(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def force_end(self):
|
||||
"""Force ending the atomic section.
|
||||
|
||||
This can only be called once __exit__ has been called. It
|
||||
will attept to close the atomic section (again). This is
|
||||
useful if the original exit raised an exception and the atomic
|
||||
section is still open. But things are pretty ugly by now.
|
||||
|
||||
:raises XapianError: If exiting fails, the atomic section is
|
||||
not ended.
|
||||
:raises UnbalancedAtomicError: If the database was currently
|
||||
not in an atomic section.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_database_end_atomic(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class DbRevision:
|
||||
"""A database revision.
|
||||
|
||||
The database revision number increases monotonically with each
|
||||
commit to the database. Which means user-visible changes can be
|
||||
ordered. This object is sortable with other revisions. It
|
||||
carries the UUID of the database to ensure it is only ever
|
||||
compared with revisions from the same database.
|
||||
"""
|
||||
|
||||
def __init__(self, rev, uuid):
|
||||
self._rev = rev
|
||||
self._uuid = uuid
|
||||
|
||||
@property
|
||||
def rev(self):
|
||||
"""The revision number, a positive integer."""
|
||||
return self._rev
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
"""The UUID of the database, consider this opaque."""
|
||||
return self._uuid
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
if self.uuid != other.uuid:
|
||||
return False
|
||||
return self.rev == other.rev
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
def __lt__(self, other):
|
||||
if self.__class__ is other.__class__:
|
||||
if self.uuid != other.uuid:
|
||||
return False
|
||||
return self.rev < other.rev
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
def __repr__(self):
|
||||
return 'DbRevision(rev={self.rev}, uuid={self.uuid})'.format(self=self)
|
112
bindings/python-cffi/notdb/_errors.py
Normal file
112
bindings/python-cffi/notdb/_errors.py
Normal file
|
@ -0,0 +1,112 @@
|
|||
from notdb import _capi as capi
|
||||
|
||||
|
||||
class NotmuchError(Exception):
|
||||
"""Base exception for errors originating from the notmuch library.
|
||||
|
||||
Usually this will have two attributes:
|
||||
|
||||
:status: This is a numeric status code corresponding to the error
|
||||
code in the notmuch library. This is normally fairly
|
||||
meaningless, it can also often be ``None``. This exists mostly
|
||||
to easily create new errors from notmuch status codes and
|
||||
should not normally be used by users.
|
||||
|
||||
:message: A user-facing message for the error. This can
|
||||
occasionally also be ``None``. Usually you'll want to call
|
||||
``str()`` on the error object instead to get a sensible
|
||||
message.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def exc_type(cls, status):
|
||||
"""Return correct exception type for notmuch status."""
|
||||
types = {
|
||||
capi.lib.NOTMUCH_STATUS_OUT_OF_MEMORY:
|
||||
OutOfMemoryError,
|
||||
capi.lib.NOTMUCH_STATUS_READ_ONLY_DATABASE:
|
||||
ReadOnlyDatabaseError,
|
||||
capi.lib.NOTMUCH_STATUS_XAPIAN_EXCEPTION:
|
||||
XapianError,
|
||||
capi.lib.NOTMUCH_STATUS_FILE_ERROR:
|
||||
FileError,
|
||||
capi.lib.NOTMUCH_STATUS_FILE_NOT_EMAIL:
|
||||
FileNotEmailError,
|
||||
capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID:
|
||||
DuplicateMessageIdError,
|
||||
capi.lib.NOTMUCH_STATUS_NULL_POINTER:
|
||||
NullPointerError,
|
||||
capi.lib.NOTMUCH_STATUS_TAG_TOO_LONG:
|
||||
TagTooLongError,
|
||||
capi.lib.NOTMUCH_STATUS_UNBALANCED_FREEZE_THAW:
|
||||
UnbalancedFreezeThawError,
|
||||
capi.lib.NOTMUCH_STATUS_UNBALANCED_ATOMIC:
|
||||
UnbalancedAtomicError,
|
||||
capi.lib.NOTMUCH_STATUS_UNSUPPORTED_OPERATION:
|
||||
UnsupportedOperationError,
|
||||
capi.lib.NOTMUCH_STATUS_UPGRADE_REQUIRED:
|
||||
UpgradeRequiredError,
|
||||
capi.lib.NOTMUCH_STATUS_PATH_ERROR:
|
||||
PathError,
|
||||
capi.lib.NOTMUCH_STATUS_ILLEGAL_ARGUMENT:
|
||||
IllegalArgumentError,
|
||||
}
|
||||
return types[status]
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""Return the correct subclass based on status."""
|
||||
# This is simplistic, but the actual __init__ will fail if the
|
||||
# signature is wrong anyway.
|
||||
if args:
|
||||
status = args[0]
|
||||
else:
|
||||
status = kwargs.get('status', None)
|
||||
if status and cls == NotmuchError:
|
||||
exc = cls.exc_type(status)
|
||||
return exc.__new__(exc, *args, **kwargs)
|
||||
else:
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, status=None, message=None):
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
if self.message:
|
||||
return self.message
|
||||
elif self.status:
|
||||
return capi.lib.notmuch_status_to_string(self.status)
|
||||
else:
|
||||
return 'Unknown error'
|
||||
|
||||
|
||||
class OutOfMemoryError(NotmuchError): pass
|
||||
class ReadOnlyDatabaseError(NotmuchError): pass
|
||||
class XapianError(NotmuchError): pass
|
||||
class FileError(NotmuchError): pass
|
||||
class FileNotEmailError(NotmuchError): pass
|
||||
class DuplicateMessageIdError(NotmuchError): pass
|
||||
class NullPointerError(NotmuchError): pass
|
||||
class TagTooLongError(NotmuchError): pass
|
||||
class UnbalancedFreezeThawError(NotmuchError): pass
|
||||
class UnbalancedAtomicError(NotmuchError): pass
|
||||
class UnsupportedOperationError(NotmuchError): pass
|
||||
class UpgradeRequiredError(NotmuchError): pass
|
||||
class PathError(NotmuchError): pass
|
||||
class IllegalArgumentError(NotmuchError): pass
|
||||
|
||||
|
||||
class ObjectDestroyedError(NotmuchError):
|
||||
"""The object has already been destoryed and it's memory freed.
|
||||
|
||||
This occurs when :meth:`destroy` has been called on the object but
|
||||
you still happen to have access to the object. This should not
|
||||
normally occur since you should never call :meth:`destroy` by
|
||||
hand.
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
if self.message:
|
||||
return self.message
|
||||
else:
|
||||
return 'Memory already freed'
|
691
bindings/python-cffi/notdb/_message.py
Normal file
691
bindings/python-cffi/notdb/_message.py
Normal file
|
@ -0,0 +1,691 @@
|
|||
import collections
|
||||
import contextlib
|
||||
import os
|
||||
import pathlib
|
||||
import weakref
|
||||
|
||||
import notdb._base as base
|
||||
import notdb._capi as capi
|
||||
import notdb._errors as errors
|
||||
import notdb._tags as tags
|
||||
|
||||
|
||||
__all__ = ['Message']
|
||||
|
||||
|
||||
class Message(base.NotmuchObject):
|
||||
"""An email message stored in the notmuch database.
|
||||
|
||||
This should not be directly created, instead it will be returned
|
||||
by calling methods on :class:`Database`. A message keeps a
|
||||
reference to the database object since the database object can not
|
||||
be released while the message is in use.
|
||||
|
||||
Note that this represents a message in the notmuch database. For
|
||||
full email functionality you may want to use the :mod:`email`
|
||||
package from Python's standard library. You could e.g. create
|
||||
this as such::
|
||||
|
||||
notmuch_msg = db.get_message(msgid) # or from a query
|
||||
parser = email.parser.BytesParser(policy=email.policy.default)
|
||||
with notmuch_msg.path.open('rb) as fp:
|
||||
email_msg = parser.parse(fp)
|
||||
|
||||
Most commonly the functionality provided by notmuch is sufficient
|
||||
to read email however.
|
||||
|
||||
Messages are considered equal when they have the same message ID.
|
||||
This is how libnotmuch treats messages as well, the
|
||||
:meth:`pathnames` function returns multiple results for
|
||||
duplicates.
|
||||
|
||||
:param parent: The parent object. This is probably one off a
|
||||
:class:`Database`, :class:`Thread` or :class:`Query`.
|
||||
:type parent: NotmuchObject
|
||||
:param db: The database instance this message is associated with.
|
||||
This could be the same as the parent.
|
||||
:type db: Database
|
||||
:param msg_p: The C pointer to the ``notmuch_message_t``.
|
||||
:type msg_p: <cdata>
|
||||
|
||||
:param dup: Whether the message was a duplicate on insertion.
|
||||
|
||||
:type dup: None or bool
|
||||
"""
|
||||
_msg_p = base.MemoryPointer()
|
||||
|
||||
def __init__(self, parent, msg_p, *, db):
|
||||
self._parent = parent
|
||||
self._msg_p = msg_p
|
||||
self._db = db
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._parent.alive:
|
||||
return False
|
||||
try:
|
||||
self._msg_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
def _destroy(self):
|
||||
if self.alive:
|
||||
capi.lib.notmuch_message_destroy(self._msg_p)
|
||||
self._msg_p = None
|
||||
|
||||
@property
|
||||
def messageid(self):
|
||||
"""The message ID as a string.
|
||||
|
||||
The message ID is decoded with the ignore error handler. This
|
||||
is fine as long as the message ID is well formed. If it is
|
||||
not valid ASCII then this will be lossy. So if you need to be
|
||||
able to write the exact same message ID back you should use
|
||||
:attr:`messageidb`.
|
||||
|
||||
Note that notmuch will decode the message ID value and thus
|
||||
strip off the surrounding ``<`` and ``>`` characters. This is
|
||||
different from Python's :mod:`email` package behaviour which
|
||||
leaves these characters in place.
|
||||
|
||||
:returns: The message ID.
|
||||
:rtype: :class:`BinString`, this is a normal str but calling
|
||||
bytes() on it will return the original bytes used to create
|
||||
it.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_message_id(self._msg_p)
|
||||
return base.BinString(capi.ffi.string(ret))
|
||||
|
||||
@property
|
||||
def threadid(self):
|
||||
"""The thread ID.
|
||||
|
||||
The thread ID is decoded with the surrogateescape error
|
||||
handler so that it is possible to reconstruct the original
|
||||
thread ID if it is not valid UTF-8.
|
||||
|
||||
:returns: The thread ID.
|
||||
:rtype: :class:`BinString`, this is a normal str but calling
|
||||
bytes() on it will return the original bytes used to create
|
||||
it.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_thread_id(self._msg_p)
|
||||
return base.BinString(capi.ffi.string(ret))
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""A pathname of the message as a pathlib.Path instance.
|
||||
|
||||
If multiple files in the database contain the same message ID
|
||||
this will be just one of the files, chosen at random.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_filename(self._msg_p)
|
||||
return pathlib.Path(os.fsdecode(capi.ffi.string(ret)))
|
||||
|
||||
@property
|
||||
def pathb(self):
|
||||
"""A pathname of the message as a bytes object.
|
||||
|
||||
See :attr:`path` for details, this is the same but does return
|
||||
the path as a bytes object which is faster but less convenient.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_filename(self._msg_p)
|
||||
return capi.ffi.string(ret)
|
||||
|
||||
def filenames(self):
|
||||
"""Return an iterator of all files for this message.
|
||||
|
||||
If multiple files contained the same message ID they will all
|
||||
be returned here. The files are returned as intances of
|
||||
:class:`pathlib.Path`.
|
||||
|
||||
:returns: Iterator yielding :class:`pathlib.Path` instances.
|
||||
:rtype: iter
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
fnames_p = capi.lib.notmuch_message_get_filenames(self._msg_p)
|
||||
return PathIter(self, fnames_p)
|
||||
|
||||
def filenamesb(self):
|
||||
"""Return an iterator of all files for this message.
|
||||
|
||||
This is like :meth:`pathnames` but the files are returned as
|
||||
byte objects instead.
|
||||
|
||||
:returns: Iterator yielding :class:`bytes` instances.
|
||||
:rtype: iter
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
fnames_p = capi.lib.notmuch_message_get_filenames(self._msg_p)
|
||||
return FilenamesIter(self, fnames_p)
|
||||
|
||||
@property
|
||||
def ghost(self):
|
||||
"""Indicates whether this message is a ghost message.
|
||||
|
||||
A ghost message if a message which we know exists, but it has
|
||||
no files or content associated with it. This can happen if
|
||||
it was referenced by some other message. Only the
|
||||
:attr:`messageid` and :attr:`threadid` attributes are valid
|
||||
for it.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_flag(
|
||||
self._msg_p, capi.lib.NOTMUCH_MESSAGE_FLAG_GHOST)
|
||||
return bool(ret)
|
||||
|
||||
@property
|
||||
def excluded(self):
|
||||
"""Indicates whether this message was excluded from the query.
|
||||
|
||||
When a message is created from a search, sometimes messages
|
||||
that where excluded by the search query could still be
|
||||
returned by it, e.g. because they are part of a thread
|
||||
matching the query. the :meth:`Database.query` method allows
|
||||
these messages to be flagged, which results in this property
|
||||
being set to *True*.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_get_flag(
|
||||
self._msg_p, capi.lib.NOTMUCH_MESSAGE_FLAG_EXCLUDED)
|
||||
return bool(ret)
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
"""The message date as an integer.
|
||||
|
||||
The time the message was sent as an integer number of seconds
|
||||
since the *epoch*, 1 Jan 1970. This is derived from the
|
||||
message's header, you can get the original header value with
|
||||
:meth:`header`.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
return capi.lib.notmuch_message_get_date(self._msg_p)
|
||||
|
||||
def header(self, name):
|
||||
"""Return the value of the named header.
|
||||
|
||||
Returns the header from notmuch, some common headers are
|
||||
stored in the database, others are read from the file.
|
||||
Headers are returned with their newlines stripped and
|
||||
collapsed concatenated together if they occur multiple times.
|
||||
You may be better off using the standard library email
|
||||
package's ``email.message_from_file(msg.path.open())`` if that
|
||||
is not sufficient for you.
|
||||
|
||||
:param header: Case-insensitive header name to retrieve.
|
||||
:type header: str or bytes
|
||||
|
||||
:returns: The header value, an empty string if the header is
|
||||
not present.
|
||||
:rtype: str
|
||||
|
||||
:raises LookupError: if the header is not present.
|
||||
:raises NullPointerError: For unexpected notmuch errors.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
# The returned is supposedly guaranteed to be UTF-8. Header
|
||||
# names must be ASCII as per RFC x822.
|
||||
if isinstance(name, str):
|
||||
name = name.encode('ascii')
|
||||
ret = capi.lib.notmuch_message_get_header(self._msg_p, name)
|
||||
if ret == capi.ffi.NULL:
|
||||
raise errors.NullPointerError()
|
||||
hdr = capi.ffi.string(ret)
|
||||
if not hdr:
|
||||
raise LookupError
|
||||
return hdr.decode(encoding='utf-8')
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""The tags associated with the message.
|
||||
|
||||
This behaves as a set. But removing and adding items to the
|
||||
set removes and adds them to the message in the database.
|
||||
|
||||
:raises ReadOnlyDatabaseError: When manipulating tags on a
|
||||
database opened in read-only mode.
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
try:
|
||||
ref = self._cached_tagset
|
||||
except AttributeError:
|
||||
tagset = None
|
||||
else:
|
||||
tagset = ref()
|
||||
if tagset is None:
|
||||
tagset = tags.MutableTagSet(
|
||||
self, '_msg_p', capi.lib.notmuch_message_get_tags)
|
||||
self._cached_tagset = weakref.ref(tagset)
|
||||
return tagset
|
||||
|
||||
@contextlib.contextmanager
|
||||
def frozen(self):
|
||||
"""Context manager to freeze the message state.
|
||||
|
||||
This allows you to perform atomic tag updates::
|
||||
|
||||
with msg.frozen():
|
||||
msg.tags.clear()
|
||||
msg.tags.add('foo')
|
||||
|
||||
Using This would ensure the message never ends up with no tags
|
||||
applied at all.
|
||||
|
||||
It is safe to nest calls to this context manager.
|
||||
|
||||
:raises ReadOnlyDatabaseError: if the database is opened in
|
||||
read-only mode.
|
||||
:raises UnbalancedFreezeThawError: if you somehow managed to
|
||||
call __exit__ of this context manager more than once. Why
|
||||
did you do that?
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_freeze(self._msg_p)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
self._frozen = True
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
# Only way to "rollback" these changes is to destroy
|
||||
# ourselves and re-create. Behold.
|
||||
msgid = self.messageid
|
||||
self._destroy()
|
||||
with contextlib.suppress(Exception):
|
||||
new = self._db.find(msgid)
|
||||
self._msg_p = new._msg_p
|
||||
new._msg_p = None
|
||||
del new
|
||||
raise
|
||||
else:
|
||||
ret = capi.lib.notmuch_message_thaw(self._msg_p)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
self._frozen = False
|
||||
|
||||
@property
|
||||
def properties(self):
|
||||
"""A map of arbitrary key-value pairs associated with the message.
|
||||
|
||||
Be aware that properties may be used by other extensions to
|
||||
store state in. So delete or modify with care.
|
||||
|
||||
The properties map is somewhat special. It is essentially a
|
||||
multimap-like structure where each key can have multiple
|
||||
values. Therefore accessing a single item using
|
||||
:meth:`PropertiesMap.get` or :meth:`PropertiesMap.__getitem__`
|
||||
will only return you the *first* item if there are multiple
|
||||
and thus are only recommended if you know there to be only one
|
||||
value.
|
||||
|
||||
Instead the map has an additional :meth:`PropertiesMap.all`
|
||||
method which can be used to retrieve all properties of a given
|
||||
key. This method also allows iterating of a a subset of the
|
||||
keys starting with a given prefix.
|
||||
"""
|
||||
try:
|
||||
ref = self._cached_props
|
||||
except AttributeError:
|
||||
props = None
|
||||
else:
|
||||
props = ref()
|
||||
if props is None:
|
||||
props = PropertiesMap(self, '_msg_p')
|
||||
self._cached_props = weakref.ref(props)
|
||||
return props
|
||||
|
||||
def replies(self):
|
||||
"""Return an iterator of all replies to this message.
|
||||
|
||||
This method will only work if the message was created from a
|
||||
thread. Otherwise it will yield no results.
|
||||
|
||||
:returns: An iterator yielding :class:`Message` instances.
|
||||
:rtype: MessageIter
|
||||
"""
|
||||
# The notmuch_messages_valid call accepts NULL and this will
|
||||
# become an empty iterator, raising StopIteration immediately.
|
||||
# Hence no return value checking here.
|
||||
msgs_p = capi.lib.notmuch_message_get_replies(self._msg_p)
|
||||
return MessageIter(self, msgs_p, db=self._db)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.messageid)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return self.messageid == other.messageid
|
||||
|
||||
|
||||
class FilenamesIter(base.NotmuchIter):
|
||||
"""Iterator for binary filenames objects."""
|
||||
|
||||
def __init__(self, parent, iter_p):
|
||||
super().__init__(parent, iter_p,
|
||||
fn_destroy=capi.lib.notmuch_filenames_destroy,
|
||||
fn_valid=capi.lib.notmuch_filenames_valid,
|
||||
fn_get=capi.lib.notmuch_filenames_get,
|
||||
fn_next=capi.lib.notmuch_filenames_move_to_next)
|
||||
|
||||
def __next__(self):
|
||||
fname = super().__next__()
|
||||
return capi.ffi.string(fname)
|
||||
|
||||
|
||||
class PathIter(FilenamesIter):
|
||||
"""Iterator for pathlib.Path objects."""
|
||||
|
||||
def __next__(self):
|
||||
fname = super().__next__()
|
||||
return pathlib.Path(os.fsdecode(fname))
|
||||
|
||||
|
||||
class PropertiesMap(base.NotmuchObject, collections.abc.MutableMapping):
|
||||
"""A mutable mapping to manage properties.
|
||||
|
||||
Both keys and values of properties are supposed to be UTF-8
|
||||
strings in libnotmuch. However since the uderlying API uses
|
||||
bytestrings you can use either str or bytes to represent keys and
|
||||
all returned keys and values use :class:`BinString`.
|
||||
|
||||
Also be aware that ``iter(this_map)`` will return duplicate keys,
|
||||
while the :class:`collections.abc.KeysView` returned by
|
||||
:meth:`keys` is a :class:`collections.abc.Set` subclass. This
|
||||
means the former will yield duplicate keys while the latter won't.
|
||||
It also means ``len(list(iter(this_map)))`` could be different
|
||||
than ``len(this_map.keys())``. ``len(this_map)`` will correspond
|
||||
with the lenght of the default iterator.
|
||||
|
||||
Be aware that libnotmuch exposes all of this as iterators, so
|
||||
quite a few operations have O(n) performance instead of the usual
|
||||
O(1).
|
||||
"""
|
||||
Property = collections.namedtuple('Property', ['key', 'value'])
|
||||
_marker = object()
|
||||
|
||||
def __init__(self, msg, ptr_name):
|
||||
self._msg = msg
|
||||
self._ptr = lambda: getattr(msg, ptr_name)
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._msg.alive:
|
||||
return False
|
||||
try:
|
||||
self._ptr
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _destroy(self):
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
"""Return an iterator which iterates over the keys.
|
||||
|
||||
Be aware that a single key may have multiple values associated
|
||||
with it, if so it will appear multiple times here.
|
||||
"""
|
||||
iter_p = capi.lib.notmuch_message_get_properties(self._ptr(), b'', 0)
|
||||
return PropertiesKeyIter(self, iter_p)
|
||||
|
||||
def __len__(self):
|
||||
iter_p = capi.lib.notmuch_message_get_properties(self._ptr(), b'', 0)
|
||||
it = base.NotmuchIter(
|
||||
self, iter_p,
|
||||
fn_destroy=capi.lib.notmuch_message_properties_destroy,
|
||||
fn_valid=capi.lib.notmuch_message_properties_valid,
|
||||
fn_get=capi.lib.notmuch_message_properties_key,
|
||||
fn_next=capi.lib.notmuch_message_properties_move_to_next,
|
||||
)
|
||||
return len(list(it))
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Return **the first** peroperty associated with a key."""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('utf-8')
|
||||
value_pp = capi.ffi.new('char**')
|
||||
ret = capi.lib.notmuch_message_get_property(self._ptr(), key, value_pp)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
if value_pp[0] == capi.ffi.NULL:
|
||||
raise KeyError
|
||||
return base.BinString.from_cffi(value_pp[0])
|
||||
|
||||
def keys(self):
|
||||
"""Return a :class:`collections.abc.KeysView` for this map.
|
||||
|
||||
Even when keys occur multiple times this is a subset of set()
|
||||
so will only contain them once.
|
||||
"""
|
||||
return collections.abc.KeysView({k: None for k in self})
|
||||
|
||||
def items(self):
|
||||
"""Return a :class:`collections.abc.ItemsView` for this map.
|
||||
|
||||
The ItemsView treats a ``(key, value)`` pair as unique, so
|
||||
dupcliate ``(key, value)`` pairs will be merged together.
|
||||
However duplicate keys with different values will be returned.
|
||||
"""
|
||||
items = set()
|
||||
props_p = capi.lib.notmuch_message_get_properties(self._ptr(), b'', 0)
|
||||
while capi.lib.notmuch_message_properties_valid(props_p):
|
||||
key = capi.lib.notmuch_message_properties_key(props_p)
|
||||
value = capi.lib.notmuch_message_properties_value(props_p)
|
||||
items.add((base.BinString.from_cffi(key),
|
||||
base.BinString.from_cffi(value)))
|
||||
capi.lib.notmuch_message_properties_move_to_next(props_p)
|
||||
capi.lib.notmuch_message_properties_destroy(props_p)
|
||||
return PropertiesItemsView(items)
|
||||
|
||||
def values(self):
|
||||
"""Return a :class:`collecions.abc.ValuesView` for this map.
|
||||
|
||||
All unique property values are included in the view.
|
||||
"""
|
||||
values = set()
|
||||
props_p = capi.lib.notmuch_message_get_properties(self._ptr(), b'', 0)
|
||||
while capi.lib.notmuch_message_properties_valid(props_p):
|
||||
value = capi.lib.notmuch_message_properties_value(props_p)
|
||||
values.add(base.BinString.from_cffi(value))
|
||||
capi.lib.notmuch_message_properties_move_to_next(props_p)
|
||||
capi.lib.notmuch_message_properties_destroy(props_p)
|
||||
return PropertiesValuesView(values)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Add a key-value pair to the properties.
|
||||
|
||||
You may prefer to use :meth:`add` for clarity since this
|
||||
method usually implies implicit overwriting of an existing key
|
||||
if it exists, while for properties this is not the case.
|
||||
"""
|
||||
self.add(key, value)
|
||||
|
||||
def add(self, key, value):
|
||||
"""Add a key-value pair to the properties."""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('utf-8')
|
||||
if isinstance(value, str):
|
||||
value = value.encode('utf-8')
|
||||
ret = capi.lib.notmuch_message_add_property(self._ptr(), key, value)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Remove all properties with this key."""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('utf-8')
|
||||
ret = capi.lib.notmuch_message_remove_all_properties(self._ptr(), key)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def remove(self, key, value):
|
||||
"""Remove a key-value pair from the properties."""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('utf-8')
|
||||
if isinstance(value, str):
|
||||
value = value.encode('utf-8')
|
||||
ret = capi.lib.notmuch_message_remove_property(self._ptr(), key, value)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def pop(self, key, default=_marker):
|
||||
try:
|
||||
value = self[key]
|
||||
except KeyError:
|
||||
if default is self._marker:
|
||||
raise
|
||||
else:
|
||||
return default
|
||||
else:
|
||||
self.remove(key, value)
|
||||
return value
|
||||
|
||||
def popitem(self):
|
||||
try:
|
||||
key = next(iter(self))
|
||||
except StopIteration:
|
||||
raise KeyError
|
||||
value = self.pop(key)
|
||||
return (key, value)
|
||||
|
||||
def clear(self):
|
||||
ret = capi.lib.notmuch_message_remove_all_properties(self._ptr(),
|
||||
capi.ffi.NULL)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def getall(self, prefix='', *, exact=False):
|
||||
"""Return an iterator yielding all properties for a given key prefix.
|
||||
|
||||
The returned iterator yields all peroperties which start with
|
||||
a given key prefix as ``(key, value)`` namedtuples. If called
|
||||
with ``exact=True`` then only properties which exactly match
|
||||
the prefix are returned, those a key longer than the prefix
|
||||
will not be included.
|
||||
|
||||
:param prefix: The prefix of the key.
|
||||
"""
|
||||
if isinstance(prefix, str):
|
||||
prefix = prefix.encode('utf-8')
|
||||
props_p = capi.lib.notmuch_message_get_properties(self._ptr(),
|
||||
prefix, exact)
|
||||
return PropertiesIter(self, props_p)
|
||||
|
||||
|
||||
class PropertiesKeyIter(base.NotmuchIter):
|
||||
|
||||
def __init__(self, parent, iter_p):
|
||||
super().__init__(
|
||||
parent,
|
||||
iter_p,
|
||||
fn_destroy=capi.lib.notmuch_message_properties_destroy,
|
||||
fn_valid=capi.lib.notmuch_message_properties_valid,
|
||||
fn_get=capi.lib.notmuch_message_properties_key,
|
||||
fn_next=capi.lib.notmuch_message_properties_move_to_next)
|
||||
|
||||
def __next__(self):
|
||||
item = super().__next__()
|
||||
return base.BinString.from_cffi(item)
|
||||
|
||||
|
||||
class PropertiesIter(base.NotmuchIter):
|
||||
|
||||
def __init__(self, parent, iter_p):
|
||||
super().__init__(
|
||||
parent,
|
||||
iter_p,
|
||||
fn_destroy=capi.lib.notmuch_message_properties_destroy,
|
||||
fn_valid=capi.lib.notmuch_message_properties_valid,
|
||||
fn_get=capi.lib.notmuch_message_properties_key,
|
||||
fn_next=capi.lib.notmuch_message_properties_move_to_next,
|
||||
)
|
||||
|
||||
def __next__(self):
|
||||
if not self._fn_valid(self._iter_p):
|
||||
self._destroy()
|
||||
raise StopIteration
|
||||
key = capi.lib.notmuch_message_properties_key(self._iter_p)
|
||||
value = capi.lib.notmuch_message_properties_value(self._iter_p)
|
||||
capi.lib.notmuch_message_properties_move_to_next(self._iter_p)
|
||||
return PropertiesMap.Property(base.BinString.from_cffi(key),
|
||||
base.BinString.from_cffi(value))
|
||||
|
||||
|
||||
class PropertiesItemsView(collections.abc.Set):
|
||||
|
||||
__slots__ = ('_items',)
|
||||
|
||||
def __init__(self, items):
|
||||
self._items = items
|
||||
|
||||
@classmethod
|
||||
def _from_iterable(self, it):
|
||||
return set(it)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self._items
|
||||
|
||||
def __iter__(self):
|
||||
yield from self._items
|
||||
|
||||
|
||||
collections.abc.ItemsView.register(PropertiesItemsView)
|
||||
|
||||
|
||||
class PropertiesValuesView(collections.abc.Set):
|
||||
|
||||
__slots__ = ('_values',)
|
||||
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def __len__(self):
|
||||
return len(self._values)
|
||||
|
||||
def __contains__(self, value):
|
||||
return value in self._values
|
||||
|
||||
def __iter__(self):
|
||||
yield from self._values
|
||||
|
||||
|
||||
collections.abc.ValuesView.register(PropertiesValuesView)
|
||||
|
||||
|
||||
class MessageIter(base.NotmuchIter):
|
||||
|
||||
def __init__(self, parent, msgs_p, *, db):
|
||||
self._db = db
|
||||
super().__init__(parent, msgs_p,
|
||||
fn_destroy=capi.lib.notmuch_messages_destroy,
|
||||
fn_valid=capi.lib.notmuch_messages_valid,
|
||||
fn_get=capi.lib.notmuch_messages_get,
|
||||
fn_next=capi.lib.notmuch_messages_move_to_next)
|
||||
|
||||
def __next__(self):
|
||||
msg_p = super().__next__()
|
||||
return Message(self, msg_p, db=self._db)
|
83
bindings/python-cffi/notdb/_query.py
Normal file
83
bindings/python-cffi/notdb/_query.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
from notdb import _base as base
|
||||
from notdb import _capi as capi
|
||||
from notdb import _errors as errors
|
||||
from notdb import _message as message
|
||||
from notdb import _thread as thread
|
||||
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
class Query(base.NotmuchObject):
|
||||
"""Private, minimal query object.
|
||||
|
||||
This is not meant for users and is not a full implementation of
|
||||
the query API. It is only an intermediate used internally to
|
||||
match libnotmuch's memory management.
|
||||
"""
|
||||
_query_p = base.MemoryPointer()
|
||||
|
||||
def __init__(self, db, query_p):
|
||||
self._db = db
|
||||
self._query_p = query_p
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._db.alive:
|
||||
return False
|
||||
try:
|
||||
self._query_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
def _destroy(self):
|
||||
if self.alive:
|
||||
capi.lib.notmuch_query_destroy(self._query_p)
|
||||
self._query_p = None
|
||||
|
||||
@property
|
||||
def query(self):
|
||||
"""The query string as seen by libnotmuch."""
|
||||
q = capi.lib.notmuch_query_get_query_string(self._query_p)
|
||||
return base.BinString.from_cffi(q)
|
||||
|
||||
def messages(self):
|
||||
"""Return an iterator over all the messages found by the query.
|
||||
|
||||
This executes the query and returns an iterator over the
|
||||
:class:`Message` objects found.
|
||||
"""
|
||||
msgs_pp = capi.ffi.new('notmuch_messages_t**')
|
||||
ret = capi.lib.notmuch_query_search_messages(self._query_p, msgs_pp)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return message.MessageIter(self, msgs_pp[0], db=self._db)
|
||||
|
||||
def count_messages(self):
|
||||
"""Return the number of messages matching this query."""
|
||||
count_p = capi.ffi.new('unsigned int *')
|
||||
ret = capi.lib.notmuch_query_count_messages(self._query_p, count_p)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return count_p[0]
|
||||
|
||||
def threads(self):
|
||||
"""Return an iterator over all the threads found by the query."""
|
||||
threads_pp = capi.ffi.new('notmuch_threads_t **')
|
||||
ret = capi.lib.notmuch_query_search_threads(self._query_p, threads_pp)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return thread.ThreadIter(self, threads_pp[0], db=self._db)
|
||||
|
||||
def count_threads(self):
|
||||
"""Return the number of threads matching this query."""
|
||||
count_p = capi.ffi.new('unsigned int *')
|
||||
ret = capi.lib.notmuch_query_count_threads(self._query_p, count_p)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
return count_p[0]
|
338
bindings/python-cffi/notdb/_tags.py
Normal file
338
bindings/python-cffi/notdb/_tags.py
Normal file
|
@ -0,0 +1,338 @@
|
|||
import collections.abc
|
||||
|
||||
import notdb._base as base
|
||||
import notdb._capi as capi
|
||||
import notdb._errors as errors
|
||||
|
||||
|
||||
__all__ = ['ImmutableTagSet', 'MutableTagSet', 'TagsIter']
|
||||
|
||||
|
||||
class ImmutableTagSet(base.NotmuchObject, collections.abc.Set):
|
||||
"""The tags associated with a message thread or whole database.
|
||||
|
||||
Both a thread as well as the database expose the union of all tags
|
||||
in messages associated with them. This exposes these as a
|
||||
:class:`collections.abc.Set` object.
|
||||
|
||||
Note that due to the underlying notmuch API the performance of the
|
||||
implementation is not the same as you would expect from normal
|
||||
sets. E.g. the :meth:`__contains__` and :meth:`__len__` are O(n)
|
||||
rather then O(1).
|
||||
|
||||
Tags are internally stored as bytestrings but normally exposed as
|
||||
unicode strings using the UTF-8 encoding and the *ignore* decoder
|
||||
error handler. However the :meth:`iter` method can be used to
|
||||
return tags as bytestrings or using a different error handler.
|
||||
|
||||
Note that when doing arithmetic operations on tags, this class
|
||||
will return a plain normal set as it is no longer associated with
|
||||
the message.
|
||||
|
||||
:param parent: the parent object
|
||||
:param ptr_name: the name of the attribute on the parent which will
|
||||
return the memory pointer. This allows this object to
|
||||
access the pointer via the parent's descriptor and thus
|
||||
trigger :class:`MemoryPointer`'s memory safety.
|
||||
:param cffi_fn: the callable CFFI wrapper to retrieve the tags
|
||||
iter. This can be one of notmuch_database_get_all_tags,
|
||||
notmuch_thread_get_tags or notmuch_message_get_tags.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, ptr_name, cffi_fn):
|
||||
self._parent = parent
|
||||
self._ptr = lambda: getattr(parent, ptr_name)
|
||||
self._cffi_fn = cffi_fn
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
return self._parent.alive
|
||||
|
||||
def _destroy(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _from_iterable(cls, it):
|
||||
return set(it)
|
||||
|
||||
def __iter__(self):
|
||||
"""Return an iterator over the tags.
|
||||
|
||||
Tags are yielded as unicode strings, decoded using the
|
||||
"ignore" error handler.
|
||||
|
||||
:raises NullPointerError: If the iterator can not be created.
|
||||
"""
|
||||
return self.iter(encoding='utf-8', errors='ignore')
|
||||
|
||||
def iter(self, *, encoding=None, errors='strict'):
|
||||
"""Aternate iterator constructor controlling string decoding.
|
||||
|
||||
Tags are stored as bytes in the notmuch database, in Python
|
||||
it's easier to work with unicode strings and thus is what the
|
||||
normal iterator returns. However this method allows you to
|
||||
specify how you would like to get the tags, defaulting to the
|
||||
bytestring representation instead of unicode strings.
|
||||
|
||||
:param encoding: Which codec to use. The default *None* does not
|
||||
decode at all and will return the unmodified bytes.
|
||||
Otherwise this is passed on to :func:`str.decode`.
|
||||
:param errors: If using a codec, this is the error handler.
|
||||
See :func:`str.decode` to which this is passed on.
|
||||
|
||||
:raises NullPointerError: When things do not go as planned.
|
||||
"""
|
||||
# self._cffi_fn should point either to
|
||||
# notmuch_database_get_all_tags, notmuch_thread_get_tags or
|
||||
# notmuch_message_get_tags. nothmuch.h suggests these never
|
||||
# fail, let's handle NULL anyway.
|
||||
tags_p = self._cffi_fn(self._ptr())
|
||||
if tags_p == capi.ffi.NULL:
|
||||
raise errors.NullPointerError()
|
||||
tags = TagsIter(self, tags_p, encoding=encoding, errors=errors)
|
||||
return tags
|
||||
|
||||
def __len__(self):
|
||||
return sum(1 for t in self)
|
||||
|
||||
def __contains__(self, tag):
|
||||
if isinstance(tag, str):
|
||||
tag = tag.encode()
|
||||
for msg_tag in self.iter():
|
||||
if tag == msg_tag:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __eq__(self, other):
|
||||
return tuple(sorted(self.iter())) == tuple(sorted(other.iter()))
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(self.iter()))
|
||||
|
||||
def __repr__(self):
|
||||
return '<{name} object at 0x{addr:x} tags={{{tags}}}>'.format(
|
||||
name=self.__class__.__name__,
|
||||
addr=id(self),
|
||||
tags=', '.join(repr(t) for t in self))
|
||||
|
||||
|
||||
class MutableTagSet(ImmutableTagSet, collections.abc.MutableSet):
|
||||
"""The tags associated with a message.
|
||||
|
||||
This is a :class:`collections.abc.MutableSet` object which can be
|
||||
used to manipulate the tags of a message.
|
||||
|
||||
Note that due to the underlying notmuch API the performance of the
|
||||
implementation is not the same as you would expect from normal
|
||||
sets. E.g. the ``in`` operator and variants are O(n) rather then
|
||||
O(1).
|
||||
|
||||
Tags are bytestrings and calling ``iter()`` will return an
|
||||
iterator yielding bytestrings. However the :meth:`iter` method
|
||||
can be used to return tags as unicode strings, while all other
|
||||
operations accept either byestrings or unicode strings. In case
|
||||
unicode strings are used they will be encoded using utf-8 before
|
||||
being passed to notmuch.
|
||||
"""
|
||||
|
||||
# Since we subclass ImmutableTagSet we inherit a __hash__. But we
|
||||
# are mutable, setting it to None will make the Python machinary
|
||||
# recognise us as unhashable.
|
||||
__hash__ = None
|
||||
|
||||
def add(self, tag):
|
||||
"""Add a tag to the message.
|
||||
|
||||
:param tag: The tag to add.
|
||||
:type tag: str or bytes. A str will be encoded using UTF-8.
|
||||
|
||||
:param sync_flags: Whether to sync the maildir flags with the
|
||||
new set of tags. Leaving this as *None* respects the
|
||||
configuration set in the database, while *True* will always
|
||||
sync and *False* will never sync.
|
||||
:param sync_flags: NoneType or bool
|
||||
|
||||
:raises TypeError: If the tag is not a valid type.
|
||||
:raises TagTooLongError: If the added tag exceeds the maximum
|
||||
lenght, see ``notmuch_cffi.NOTMUCH_TAG_MAX``.
|
||||
:raises ReadOnlyDatabaseError: If the database is opened in
|
||||
read-only mode.
|
||||
"""
|
||||
if isinstance(tag, str):
|
||||
tag = tag.encode()
|
||||
if not isinstance(tag, bytes):
|
||||
raise TypeError('Not a valid type for a tag: {}'.format(type(tag)))
|
||||
ret = capi.lib.notmuch_message_add_tag(self._ptr(), tag)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def discard(self, tag):
|
||||
"""Remove a tag from the message.
|
||||
|
||||
:param tag: The tag to remove.
|
||||
:type tag: str of bytes. A str will be encoded using UTF-8.
|
||||
:param sync_flags: Whether to sync the maildir flags with the
|
||||
new set of tags. Leaving this as *None* respects the
|
||||
configuration set in the database, while *True* will always
|
||||
sync and *False* will never sync.
|
||||
:param sync_flags: NoneType or bool
|
||||
|
||||
:raises TypeError: If the tag is not a valid type.
|
||||
:raises TagTooLongError: If the tag exceeds the maximum
|
||||
lenght, see ``notmuch_cffi.NOTMUCH_TAG_MAX``.
|
||||
:raises ReadOnlyDatabaseError: If the database is opened in
|
||||
read-only mode.
|
||||
"""
|
||||
if isinstance(tag, str):
|
||||
tag = tag.encode()
|
||||
if not isinstance(tag, bytes):
|
||||
raise TypeError('Not a valid type for a tag: {}'.format(type(tag)))
|
||||
ret = capi.lib.notmuch_message_remove_tag(self._ptr(), tag)
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def clear(self):
|
||||
"""Remove all tags from the message.
|
||||
|
||||
:raises ReadOnlyDatabaseError: If the database is opened in
|
||||
read-only mode.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_remove_all_tags(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def from_maildir_flags(self):
|
||||
"""Update the tags based on the state in the message's maildir flags.
|
||||
|
||||
This function examines the filenames of 'message' for maildir
|
||||
flags, and adds or removes tags on 'message' as follows when
|
||||
these flags are present:
|
||||
|
||||
Flag Action if present
|
||||
---- -----------------
|
||||
'D' Adds the "draft" tag to the message
|
||||
'F' Adds the "flagged" tag to the message
|
||||
'P' Adds the "passed" tag to the message
|
||||
'R' Adds the "replied" tag to the message
|
||||
'S' Removes the "unread" tag from the message
|
||||
|
||||
For each flag that is not present, the opposite action
|
||||
(add/remove) is performed for the corresponding tags.
|
||||
|
||||
Flags are identified as trailing components of the filename
|
||||
after a sequence of ":2,".
|
||||
|
||||
If there are multiple filenames associated with this message,
|
||||
the flag is considered present if it appears in one or more
|
||||
filenames. (That is, the flags from the multiple filenames are
|
||||
combined with the logical OR operator.)
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_maildir_flags_to_tags(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
def to_maildir_flags(self):
|
||||
"""Update the message's maildir flags based on the notmuch tags.
|
||||
|
||||
If the message's filename is in a maildir directory, that is a
|
||||
directory named ``new`` or ``cur``, and has a valid maildir
|
||||
filename then the flags will be added as such:
|
||||
|
||||
'D' if the message has the "draft" tag
|
||||
'F' if the message has the "flagged" tag
|
||||
'P' if the message has the "passed" tag
|
||||
'R' if the message has the "replied" tag
|
||||
'S' if the message does not have the "unread" tag
|
||||
|
||||
Any existing flags unmentioned in the list above will be
|
||||
preserved in the renaming.
|
||||
|
||||
Also, if this filename is in a directory named "new", rename it to
|
||||
be within the neighboring directory named "cur".
|
||||
|
||||
In case there are multiple files associated with the message
|
||||
all filenames will get the same logic applied.
|
||||
"""
|
||||
ret = capi.lib.notmuch_message_tags_to_maildir_flags(self._ptr())
|
||||
if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
|
||||
raise errors.NotmuchError(ret)
|
||||
|
||||
|
||||
class TagsIter(base.NotmuchObject, collections.abc.Iterator):
|
||||
"""Iterator over tags.
|
||||
|
||||
This is only an interator, not a container so calling
|
||||
:meth:`__iter__` does not return a new, replenished iterator but
|
||||
only itself.
|
||||
|
||||
:param parent: The parent object to keep alive.
|
||||
:param tags_p: The CFFI pointer to the C-level tags iterator.
|
||||
:param encoding: Which codec to use. The default *None* does not
|
||||
decode at all and will return the unmodified bytes.
|
||||
Otherwise this is passed on to :func:`str.decode`.
|
||||
:param errors: If using a codec, this is the error handler.
|
||||
See :func:`str.decode` to which this is passed on.
|
||||
|
||||
:raises ObjectDestoryedError: if used after destroyed.
|
||||
"""
|
||||
_tags_p = base.MemoryPointer()
|
||||
|
||||
def __init__(self, parent, tags_p, *, encoding=None, errors='strict'):
|
||||
self._parent = parent
|
||||
self._tags_p = tags_p
|
||||
self._encoding = encoding
|
||||
self._errors = errors
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._parent.alive:
|
||||
return False
|
||||
try:
|
||||
self._tags_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def _destroy(self):
|
||||
if self.alive:
|
||||
try:
|
||||
capi.lib.notmuch_tags_destroy(self._tags_p)
|
||||
except errors.ObjectDestroyedError:
|
||||
pass
|
||||
self._tags_p = None
|
||||
|
||||
def __iter__(self):
|
||||
"""Return the iterator itself.
|
||||
|
||||
Note that as this is an iterator and not a container this will
|
||||
not return a new iterator. Thus any elements already consumed
|
||||
will not be yielded by the :meth:`__next__` method anymore.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if not capi.lib.notmuch_tags_valid(self._tags_p):
|
||||
self._destroy()
|
||||
raise StopIteration()
|
||||
tag_p = capi.lib.notmuch_tags_get(self._tags_p)
|
||||
tag = capi.ffi.string(tag_p)
|
||||
if self._encoding:
|
||||
tag = tag.decode(encoding=self._encoding, errors=self._errors)
|
||||
capi.lib.notmuch_tags_move_to_next(self._tags_p)
|
||||
return tag
|
||||
|
||||
def __repr__(self):
|
||||
try:
|
||||
self._tags_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return '<TagsIter (exhausted)>'
|
||||
else:
|
||||
return '<TagsIter>'
|
190
bindings/python-cffi/notdb/_thread.py
Normal file
190
bindings/python-cffi/notdb/_thread.py
Normal file
|
@ -0,0 +1,190 @@
|
|||
import collections.abc
|
||||
import weakref
|
||||
|
||||
from notdb import _base as base
|
||||
from notdb import _capi as capi
|
||||
from notdb import _errors as errors
|
||||
from notdb import _message as message
|
||||
from notdb import _tags as tags
|
||||
|
||||
|
||||
__all__ = ['Thread']
|
||||
|
||||
|
||||
class Thread(base.NotmuchObject, collections.abc.Iterable):
|
||||
_thread_p = base.MemoryPointer()
|
||||
|
||||
def __init__(self, parent, thread_p, *, db):
|
||||
self._parent = parent
|
||||
self._thread_p = thread_p
|
||||
self._db = db
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
if not self._parent.alive:
|
||||
return False
|
||||
try:
|
||||
self._thread_p
|
||||
except errors.ObjectDestroyedError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
self._destroy()
|
||||
|
||||
def _destroy(self):
|
||||
if self.alive:
|
||||
capi.lib.notmuch_thread_destroy(self._thread_p)
|
||||
self._thread_p = None
|
||||
|
||||
@property
|
||||
def threadid(self):
|
||||
"""The thread ID as a :class:`BinString`.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_thread_get_thread_id(self._thread_p)
|
||||
return base.BinString.from_cffi(ret)
|
||||
|
||||
def __len__(self):
|
||||
"""Return the number of messages in the thread.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
return capi.lib.notmuch_thread_get_total_messages(self._thread_p)
|
||||
|
||||
def toplevel(self):
|
||||
"""Return an iterator of the toplevel messages.
|
||||
|
||||
:returns: An iterator yielding :class:`Message` instances.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
msgs_p = capi.lib.notmuch_thread_get_toplevel_messages(self._thread_p)
|
||||
return message.MessageIter(self, msgs_p, db=self._db)
|
||||
|
||||
def __iter__(self):
|
||||
"""Return an iterator over all the messages in the thread.
|
||||
|
||||
:returns: An iterator yielding :class:`Message` instances.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
msgs_p = capi.lib.notmuch_thread_get_messages(self._thread_p)
|
||||
return message.MessageIter(self, msgs_p, db=self._db)
|
||||
|
||||
@property
|
||||
def matched(self):
|
||||
"""The number of messages in this thread which matched the query.
|
||||
|
||||
Of the messages in the thread this gives the count of messages
|
||||
which did directly match the search query which this thread
|
||||
originates from.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
return capi.lib.notmuch_thread_get_matched_messages(self._thread_p)
|
||||
|
||||
@property
|
||||
def authors(self):
|
||||
"""A comma-separated string of all authors in the thread.
|
||||
|
||||
Authors of messages which matched the query the thread was
|
||||
retrieved from will be at the head of the string, ordered by
|
||||
date of their messages. Following this will be the authors of
|
||||
the other messages in the thread, also ordered by date of
|
||||
their messages. Both groups of authors are separated by the
|
||||
``|`` character.
|
||||
|
||||
:returns: The stringified list of authors.
|
||||
:rtype: BinString
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_thread_get_authors(self._thread_p)
|
||||
return base.BinString.from_cffi(ret)
|
||||
|
||||
@property
|
||||
def subject(self):
|
||||
"""The subject of the thread, taken from the first message.
|
||||
|
||||
The thread's subject is taken to be the subject of the first
|
||||
message according to query sort order.
|
||||
|
||||
:returns: The thread's subject.
|
||||
:rtype: BinString
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
ret = capi.lib.notmuch_thread_get_subject(self._thread_p)
|
||||
return base.BinString.from_cffi(ret)
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
"""Return the date of the oldest message in the thread.
|
||||
|
||||
The time the first message was sent as an integer number of
|
||||
seconds since the *epoch*, 1 Jan 1970.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
return capi.lib.notmuch_thread_get_oldest_date(self._thread_p)
|
||||
|
||||
@property
|
||||
def last(self):
|
||||
"""Return the date of the newest message in the thread.
|
||||
|
||||
The time the last message was sent as an integer number of
|
||||
seconds since the *epoch*, 1 Jan 1970.
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
return capi.lib.notmuch_thread_get_newest_date(self._thread_p)
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""Return an immutable set with all tags used in this thread.
|
||||
|
||||
This returns an immutable set-like object implementing the
|
||||
collections.abc.Set Abstract Base Class. Due to the
|
||||
underlying libnotmuch implementation some operations have
|
||||
different performance characteristics then plain set objects.
|
||||
Mainly any lookup operation is O(n) rather then O(1).
|
||||
|
||||
Normal usage treats tags as UTF-8 encoded unicode strings so
|
||||
they are exposed to Python as normal unicode string objects.
|
||||
If you need to handle tags stored in libnotmuch which are not
|
||||
valid unicode do check the :class:`ImmutableTagSet` docs for
|
||||
how to handle this.
|
||||
|
||||
:rtype: ImmutableTagSet
|
||||
|
||||
:raises ObjectDestroyedError: if used after destoryed.
|
||||
"""
|
||||
try:
|
||||
ref = self._cached_tagset
|
||||
except AttributeError:
|
||||
tagset = None
|
||||
else:
|
||||
tagset = ref()
|
||||
if tagset is None:
|
||||
tagset = tags.ImmutableTagSet(
|
||||
self, '_thread_p', capi.lib.notmuch_thread_get_tags)
|
||||
self._cached_tagset = weakref.ref(tagset)
|
||||
return tagset
|
||||
|
||||
|
||||
class ThreadIter(base.NotmuchIter):
|
||||
|
||||
def __init__(self, parent, threads_p, *, db):
|
||||
self._db = db
|
||||
super().__init__(parent, threads_p,
|
||||
fn_destroy=capi.lib.notmuch_threads_destroy,
|
||||
fn_valid=capi.lib.notmuch_threads_valid,
|
||||
fn_get=capi.lib.notmuch_threads_get,
|
||||
fn_next=capi.lib.notmuch_threads_move_to_next)
|
||||
|
||||
def __next__(self):
|
||||
thread_p = super().__next__()
|
||||
return Thread(self, thread_p, db=self._db)
|
22
bindings/python-cffi/setup.py
Normal file
22
bindings/python-cffi/setup.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
import setuptools
|
||||
|
||||
|
||||
setuptools.setup(
|
||||
name='notdb',
|
||||
version='0.1',
|
||||
description='Pythonic bindings for the notmuch mail database using CFFI',
|
||||
author='Floris Bruynooghe',
|
||||
author_email='flub@devork.be',
|
||||
setup_requires=['cffi>=1.0.0'],
|
||||
install_requires=['cffi>=1.0.0'],
|
||||
packages=setuptools.find_packages(exclude=['tests']),
|
||||
cffi_modules=['notdb/_build.py:ffibuilder'],
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: GNU General Public License (GPL)',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Topic :: Communications :: Email',
|
||||
'Topic :: Software Development :: Libraries',
|
||||
],
|
||||
)
|
142
bindings/python-cffi/tests/conftest.py
Normal file
142
bindings/python-cffi/tests/conftest.py
Normal file
|
@ -0,0 +1,142 @@
|
|||
import email.message
|
||||
import mailbox
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def tmppath(tmpdir):
|
||||
"""The tmpdir fixture wrapped in pathlib.Path."""
|
||||
return pathlib.Path(str(tmpdir))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notmuch(maildir):
|
||||
"""Return a function which runs notmuch commands on our test maildir.
|
||||
|
||||
This uses the notmuch-config file created by the ``maildir``
|
||||
fixture.
|
||||
"""
|
||||
def run(*args):
|
||||
"""Run a notmuch comand.
|
||||
|
||||
This function runs with a timeout error as many notmuch
|
||||
commands may block if multiple processes are trying to open
|
||||
the database in write-mode. It is all too easy to
|
||||
accidentally do this in the unittests.
|
||||
"""
|
||||
cfg_fname = maildir.path / 'notmuch-config'
|
||||
cmd = ['notmuch'] + list(args)
|
||||
print('Invoking: {}'.format(' '.join(cmd)))
|
||||
proc = subprocess.run(cmd,
|
||||
timeout=5,
|
||||
env={'NOTMUCH_CONFIG': str(cfg_fname)})
|
||||
proc.check_returncode()
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildir(tmppath):
|
||||
"""A basic test interface to a valid maildir directory.
|
||||
|
||||
This creates a valid maildir and provides a simple mechanism to
|
||||
deliver test emails to it. It also writes a notmuch-config file
|
||||
in the top of the maildir.
|
||||
"""
|
||||
cur = tmppath / 'cur'
|
||||
cur.mkdir()
|
||||
new = tmppath / 'new'
|
||||
new.mkdir()
|
||||
tmp = tmppath / 'tmp'
|
||||
tmp.mkdir()
|
||||
cfg_fname = tmppath/'notmuch-config'
|
||||
with cfg_fname.open('w') as fp:
|
||||
fp.write(textwrap.dedent("""\
|
||||
[database]
|
||||
path={tmppath!s}
|
||||
[user]
|
||||
name=Some Hacker
|
||||
primary_email=dst@example.com
|
||||
[new]
|
||||
tags=unread;inbox;
|
||||
ignore=
|
||||
[search]
|
||||
exclude_tags=deleted;spam;
|
||||
[maildir]
|
||||
synchronize_flags=true
|
||||
[crypto]
|
||||
gpg_path=gpg
|
||||
""".format(tmppath=tmppath)))
|
||||
return MailDir(tmppath)
|
||||
|
||||
|
||||
class MailDir:
|
||||
"""An interface around a correct maildir."""
|
||||
|
||||
def __init__(self, path):
|
||||
self._path = pathlib.Path(path)
|
||||
self.mailbox = mailbox.Maildir(str(path))
|
||||
self._idcount = 0
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""The pathname of the maildir."""
|
||||
return self._path
|
||||
|
||||
def _next_msgid(self):
|
||||
"""Return a new unique message ID."""
|
||||
msgid = '{}@{}'.format(self._idcount, socket.getfqdn())
|
||||
self._idcount += 1
|
||||
return msgid
|
||||
|
||||
def deliver(self,
|
||||
subject='Test mail',
|
||||
body='This is a test mail',
|
||||
to='dst@example.com',
|
||||
frm='src@example.com',
|
||||
headers=None,
|
||||
new=False, # Move to new dir or cur dir?
|
||||
keywords=None, # List of keywords or labels
|
||||
seen=False, # Seen flag (cur dir only)
|
||||
replied=False, # Replied flag (cur dir only)
|
||||
flagged=False): # Flagged flag (cur dir only)
|
||||
"""Deliver a new mail message in the mbox.
|
||||
|
||||
This does only adds the message to maildir, does not insert it
|
||||
into the notmuch database.
|
||||
|
||||
:returns: A tuple of (msgid, pathname).
|
||||
"""
|
||||
msgid = self._next_msgid()
|
||||
when = time.time()
|
||||
msg = email.message.EmailMessage()
|
||||
msg.add_header('Received', 'by MailDir; {}'.format(time.ctime(when)))
|
||||
msg.add_header('Message-ID', '<{}>'.format(msgid))
|
||||
msg.add_header('Date', time.ctime(when))
|
||||
msg.add_header('From', frm)
|
||||
msg.add_header('To', to)
|
||||
msg.add_header('Subject', subject)
|
||||
if headers:
|
||||
for h, v in headers:
|
||||
msg.add_header(h, v)
|
||||
msg.set_content(body)
|
||||
mdmsg = mailbox.MaildirMessage(msg)
|
||||
if not new:
|
||||
mdmsg.set_subdir('cur')
|
||||
if flagged:
|
||||
mdmsg.add_flag('F')
|
||||
if replied:
|
||||
mdmsg.add_flag('R')
|
||||
if seen:
|
||||
mdmsg.add_flag('S')
|
||||
boxid = self.mailbox.add(mdmsg)
|
||||
basename = boxid
|
||||
if mdmsg.get_info():
|
||||
basename += mailbox.Maildir.colon + mdmsg.get_info()
|
||||
msgpath = self.path / mdmsg.get_subdir() / basename
|
||||
return (msgid, msgpath)
|
116
bindings/python-cffi/tests/test_base.py
Normal file
116
bindings/python-cffi/tests/test_base.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
import pytest
|
||||
|
||||
from notdb import _base as base
|
||||
from notdb import _errors as errors
|
||||
|
||||
|
||||
class TestNotmuchObject:
|
||||
|
||||
def test_no_impl_methods(self):
|
||||
class Object(base.NotmuchObject):
|
||||
pass
|
||||
with pytest.raises(TypeError):
|
||||
Object()
|
||||
|
||||
def test_impl_methods(self):
|
||||
|
||||
class Object(base.NotmuchObject):
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
pass
|
||||
|
||||
def _destroy(self, parent=False):
|
||||
pass
|
||||
|
||||
Object()
|
||||
|
||||
def test_del(self):
|
||||
destroyed = False
|
||||
|
||||
class Object(base.NotmuchObject):
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
pass
|
||||
|
||||
def _destroy(self, parent=False):
|
||||
nonlocal destroyed
|
||||
destroyed = True
|
||||
|
||||
o = Object()
|
||||
o.__del__()
|
||||
assert destroyed
|
||||
|
||||
|
||||
class TestMemoryPointer:
|
||||
|
||||
@pytest.fixture
|
||||
def obj(self):
|
||||
class Cls:
|
||||
ptr = base.MemoryPointer()
|
||||
return Cls()
|
||||
|
||||
def test_unset(self, obj):
|
||||
with pytest.raises(errors.ObjectDestroyedError):
|
||||
obj.ptr
|
||||
|
||||
def test_set(self, obj):
|
||||
obj.ptr = 'some'
|
||||
assert obj.ptr == 'some'
|
||||
|
||||
def test_cleared(self, obj):
|
||||
obj.ptr = 'some'
|
||||
obj.ptr
|
||||
obj.ptr = None
|
||||
with pytest.raises(errors.ObjectDestroyedError):
|
||||
obj.ptr
|
||||
|
||||
def test_two_instances(self, obj):
|
||||
obj2 = obj.__class__()
|
||||
obj.ptr = 'foo'
|
||||
obj2.ptr = 'bar'
|
||||
assert obj.ptr != obj2.ptr
|
||||
|
||||
|
||||
class TestBinString:
|
||||
|
||||
def test_type(self):
|
||||
s = base.BinString(b'foo')
|
||||
assert isinstance(s, str)
|
||||
|
||||
def test_init_bytes(self):
|
||||
s = base.BinString(b'foo')
|
||||
assert s == 'foo'
|
||||
|
||||
def test_init_str(self):
|
||||
s = base.BinString('foo')
|
||||
assert s == 'foo'
|
||||
|
||||
def test_bytes(self):
|
||||
s = base.BinString(b'foo')
|
||||
assert bytes(s) == b'foo'
|
||||
|
||||
def test_invalid_utf8(self):
|
||||
s = base.BinString(b'\x80foo')
|
||||
assert s == 'foo'
|
||||
assert bytes(s) == b'\x80foo'
|
||||
|
||||
def test_errors(self):
|
||||
s = base.BinString(b'\x80foo', errors='replace')
|
||||
assert s == '<EFBFBD>foo'
|
||||
assert bytes(s) == b'\x80foo'
|
||||
|
||||
def test_encoding(self):
|
||||
# pound sign: '£' == '\u00a3' latin-1: b'\xa3', utf-8: b'\xc2\xa3'
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
base.BinString(b'\xa3', errors='strict')
|
||||
s = base.BinString(b'\xa3', encoding='latin-1', errors='strict')
|
||||
assert s == '£'
|
||||
assert bytes(s) == b'\xa3'
|
326
bindings/python-cffi/tests/test_database.py
Normal file
326
bindings/python-cffi/tests/test_database.py
Normal file
|
@ -0,0 +1,326 @@
|
|||
import collections
|
||||
import configparser
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import notdb
|
||||
import notdb._errors as errors
|
||||
import notdb._database as dbmod
|
||||
import notdb._message as message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(maildir):
|
||||
with dbmod.Database.create(maildir.path) as db:
|
||||
yield db
|
||||
|
||||
|
||||
class TestDefaultDb:
|
||||
"""Tests for reading the default database.
|
||||
|
||||
The error cases are fairly undefined, some relevant Python error
|
||||
will come out if you give it a bad filename or if the file does
|
||||
not parse correctly. So we're not testing this too deeply.
|
||||
"""
|
||||
|
||||
def test_config_pathname_default(self, monkeypatch):
|
||||
monkeypatch.delenv('NOTMUCH_CONFIG', raising=False)
|
||||
user = pathlib.Path('~/.notmuch-config').expanduser()
|
||||
assert dbmod._config_pathname() == user
|
||||
|
||||
def test_config_pathname_env(self, monkeypatch):
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', '/some/random/path')
|
||||
assert dbmod._config_pathname() == pathlib.Path('/some/random/path')
|
||||
|
||||
def test_default_path_nocfg(self, monkeypatch, tmppath):
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', str(tmppath/'foo'))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
dbmod.Database.default_path()
|
||||
|
||||
def test_default_path_cfg_is_dir(self, monkeypatch, tmppath):
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', str(tmppath))
|
||||
with pytest.raises(IsADirectoryError):
|
||||
dbmod.Database.default_path()
|
||||
|
||||
def test_default_path_parseerr(self, monkeypatch, tmppath):
|
||||
cfg = tmppath / 'notmuch-config'
|
||||
with cfg.open('w') as fp:
|
||||
fp.write('invalid')
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', str(cfg))
|
||||
with pytest.raises(configparser.Error):
|
||||
dbmod.Database.default_path()
|
||||
|
||||
def test_default_path_parse(self, monkeypatch, tmppath):
|
||||
cfg = tmppath / 'notmuch-config'
|
||||
with cfg.open('w') as fp:
|
||||
fp.write('[database]\n')
|
||||
fp.write('path={!s}'.format(tmppath))
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', str(cfg))
|
||||
assert dbmod.Database.default_path() == tmppath
|
||||
|
||||
def test_default_path_param(self, monkeypatch, tmppath):
|
||||
cfg_dummy = tmppath / 'dummy'
|
||||
monkeypatch.setenv('NOTMUCH_CONFIG', str(cfg_dummy))
|
||||
cfg_real = tmppath / 'notmuch_config'
|
||||
with cfg_real.open('w') as fp:
|
||||
fp.write('[database]\n')
|
||||
fp.write('path={!s}'.format(cfg_real/'mail'))
|
||||
assert dbmod.Database.default_path(cfg_real) == cfg_real/'mail'
|
||||
|
||||
|
||||
class TestCreate:
|
||||
|
||||
def test_create(self, tmppath, db):
|
||||
assert tmppath.joinpath('.notmuch/xapian/').exists()
|
||||
|
||||
def test_create_already_open(self, tmppath, db):
|
||||
with pytest.raises(errors.NotmuchError):
|
||||
db.create(tmppath)
|
||||
|
||||
def test_create_existing(self, tmppath, db):
|
||||
with pytest.raises(errors.FileError):
|
||||
dbmod.Database.create(path=tmppath)
|
||||
|
||||
def test_close(self, db):
|
||||
db.close()
|
||||
|
||||
def test_del_noclose(self, db):
|
||||
del db
|
||||
|
||||
def test_close_del(self, db):
|
||||
db.close()
|
||||
del db
|
||||
|
||||
def test_closed_attr(self, db):
|
||||
assert not db.closed
|
||||
db.close()
|
||||
assert db.closed
|
||||
|
||||
def test_ctx(self, db):
|
||||
with db as ctx:
|
||||
assert ctx is db
|
||||
assert not db.closed
|
||||
assert db.closed
|
||||
|
||||
def test_path(self, db, tmppath):
|
||||
assert db.path == tmppath
|
||||
|
||||
def test_version(self, db):
|
||||
assert db.version > 0
|
||||
|
||||
def test_needs_upgrade(self, db):
|
||||
assert db.needs_upgrade in (True, False)
|
||||
|
||||
|
||||
class TestAtomic:
|
||||
|
||||
def test_exit_early(self, db):
|
||||
with pytest.raises(errors.UnbalancedAtomicError):
|
||||
with db.atomic() as ctx:
|
||||
ctx.force_end()
|
||||
|
||||
def test_exit_late(self, db):
|
||||
with db.atomic() as ctx:
|
||||
pass
|
||||
with pytest.raises(errors.UnbalancedAtomicError):
|
||||
ctx.force_end()
|
||||
|
||||
|
||||
class TestRevision:
|
||||
|
||||
def test_single_rev(self, db):
|
||||
r = db.revision()
|
||||
assert isinstance(r, dbmod.DbRevision)
|
||||
assert isinstance(r.rev, int)
|
||||
assert isinstance(r.uuid, bytes)
|
||||
assert r is r
|
||||
assert r == r
|
||||
assert r <= r
|
||||
assert r >= r
|
||||
assert not r < r
|
||||
assert not r > r
|
||||
|
||||
def test_diff_db(self, tmppath):
|
||||
dbpath0 = tmppath.joinpath('db0')
|
||||
dbpath0.mkdir()
|
||||
dbpath1 = tmppath.joinpath('db1')
|
||||
dbpath1.mkdir()
|
||||
db0 = dbmod.Database.create(path=dbpath0)
|
||||
db1 = dbmod.Database.create(path=dbpath1)
|
||||
r_db0 = db0.revision()
|
||||
r_db1 = db1.revision()
|
||||
assert r_db0 != r_db1
|
||||
assert r_db0.uuid != r_db1.uuid
|
||||
|
||||
def test_cmp(self, db, maildir):
|
||||
rev0 = db.revision()
|
||||
_, pathname = maildir.deliver()
|
||||
db.add(pathname, sync_flags=False)
|
||||
rev1 = db.revision()
|
||||
assert rev0 < rev1
|
||||
assert rev0 <= rev1
|
||||
assert not rev0 > rev1
|
||||
assert not rev0 >= rev1
|
||||
assert not rev0 == rev1
|
||||
assert rev0 != rev1
|
||||
|
||||
# XXX add tests for revisions comparisons
|
||||
|
||||
class TestMessages:
|
||||
|
||||
def test_add_message(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(pathname, sync_flags=False)
|
||||
assert isinstance(msg, message.Message)
|
||||
assert msg.path == pathname
|
||||
assert msg.messageid == msgid
|
||||
|
||||
def test_add_message_str(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(str(pathname), sync_flags=False)
|
||||
|
||||
def test_add_message_bytes(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(os.fsencode(bytes(pathname)), sync_flags=False)
|
||||
|
||||
def test_remove_message(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(pathname, sync_flags=False)
|
||||
assert db.find(msgid)
|
||||
dup = db.remove(pathname)
|
||||
with pytest.raises(LookupError):
|
||||
db.find(msgid)
|
||||
|
||||
def test_remove_message_str(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(pathname, sync_flags=False)
|
||||
assert db.find(msgid)
|
||||
dup = db.remove(str(pathname))
|
||||
with pytest.raises(LookupError):
|
||||
db.find(msgid)
|
||||
|
||||
def test_remove_message_bytes(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg, dup = db.add(pathname, sync_flags=False)
|
||||
assert db.find(msgid)
|
||||
dup = db.remove(os.fsencode(bytes(pathname)))
|
||||
with pytest.raises(LookupError):
|
||||
db.find(msgid)
|
||||
|
||||
def test_find_message(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg0, dup = db.add(pathname, sync_flags=False)
|
||||
msg1 = db.find(msgid)
|
||||
assert isinstance(msg1, message.Message)
|
||||
assert msg1.messageid == msgid == msg0.messageid
|
||||
assert msg1.path == pathname == msg0.path
|
||||
|
||||
def test_find_message_notfound(self, db):
|
||||
with pytest.raises(LookupError):
|
||||
db.find('foo')
|
||||
|
||||
def test_get_message(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
msg0, _ = db.add(pathname, sync_flags=False)
|
||||
msg1 = db.get(pathname)
|
||||
assert isinstance(msg1, message.Message)
|
||||
assert msg1.messageid == msgid == msg0.messageid
|
||||
assert msg1.path == pathname == msg0.path
|
||||
|
||||
def test_get_message_str(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
db.add(pathname, sync_flags=False)
|
||||
msg = db.get(str(pathname))
|
||||
assert msg.messageid == msgid
|
||||
|
||||
def test_get_message_bytes(self, db, maildir):
|
||||
msgid, pathname = maildir.deliver()
|
||||
db.add(pathname, sync_flags=False)
|
||||
msg = db.get(os.fsencode(bytes(pathname)))
|
||||
assert msg.messageid == msgid
|
||||
|
||||
|
||||
class TestTags:
|
||||
# We just want to test this behaves like a set at a hight level.
|
||||
# The set semantics are tested in detail in the test_tags module.
|
||||
|
||||
def test_type(self, db):
|
||||
assert isinstance(db.tags, collections.abc.Set)
|
||||
|
||||
def test_none(self, db):
|
||||
itags = iter(db.tags)
|
||||
with pytest.raises(StopIteration):
|
||||
next(itags)
|
||||
assert len(db.tags) == 0
|
||||
assert not db.tags
|
||||
|
||||
def test_some(self, db, maildir):
|
||||
_, pathname = maildir.deliver()
|
||||
msg, _ = db.add(pathname, sync_flags=False)
|
||||
msg.tags.add('hello')
|
||||
itags = iter(db.tags)
|
||||
assert next(itags) == 'hello'
|
||||
with pytest.raises(StopIteration):
|
||||
next(itags)
|
||||
assert 'hello' in msg.tags
|
||||
|
||||
def test_cache(self, db):
|
||||
assert db.tags is db.tags
|
||||
|
||||
def test_iters(self, db):
|
||||
i1 = iter(db.tags)
|
||||
i2 = iter(db.tags)
|
||||
assert i1 is not i2
|
||||
|
||||
|
||||
class TestQuery:
|
||||
|
||||
@pytest.fixture
|
||||
def db(self, maildir, notmuch):
|
||||
"""Return a read-only notdb.Database.
|
||||
|
||||
The database will have 3 messages, 2 threads.
|
||||
"""
|
||||
msgid, _ = maildir.deliver(body='foo')
|
||||
maildir.deliver(body='bar')
|
||||
maildir.deliver(body='baz',
|
||||
headers=[('In-Reply-To', '<{}>'.format(msgid))])
|
||||
notmuch('new')
|
||||
with dbmod.Database(maildir.path, 'rw') as db:
|
||||
yield db
|
||||
|
||||
def test_count_messages(self, db):
|
||||
assert db.count_messages('*') == 3
|
||||
|
||||
def test_messages_type(self, db):
|
||||
msgs = db.messages('*')
|
||||
assert isinstance(msgs, collections.abc.Iterator)
|
||||
|
||||
def test_message_no_results(self, db):
|
||||
msgs = db.messages('not_a_matching_query')
|
||||
with pytest.raises(StopIteration):
|
||||
next(msgs)
|
||||
|
||||
def test_message_match(self, db):
|
||||
msgs = db.messages('*')
|
||||
msg = next(msgs)
|
||||
assert isinstance(msg, notdb.Message)
|
||||
|
||||
def test_count_threads(self, db):
|
||||
assert db.count_threads('*') == 2
|
||||
|
||||
def test_threads_type(self, db):
|
||||
threads = db.threads('*')
|
||||
assert isinstance(threads, collections.abc.Iterator)
|
||||
|
||||
def test_threads_no_match(self, db):
|
||||
threads = db.threads('not_a_matching_query')
|
||||
with pytest.raises(StopIteration):
|
||||
next(threads)
|
||||
|
||||
def test_threads_match(self, db):
|
||||
threads = db.threads('*')
|
||||
thread = next(threads)
|
||||
assert isinstance(thread, notdb.Thread)
|
226
bindings/python-cffi/tests/test_message.py
Normal file
226
bindings/python-cffi/tests/test_message.py
Normal file
|
@ -0,0 +1,226 @@
|
|||
import collections.abc
|
||||
import time
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import notdb
|
||||
|
||||
|
||||
class TestMessage:
|
||||
MaildirMsg = collections.namedtuple('MaildirMsg', ['msgid', 'path'])
|
||||
|
||||
@pytest.fixture
|
||||
def maildir_msg(self, maildir):
|
||||
msgid, path = maildir.deliver()
|
||||
return self.MaildirMsg(msgid, path)
|
||||
|
||||
@pytest.fixture
|
||||
def db(self, maildir):
|
||||
with notdb.Database.create(maildir.path) as db:
|
||||
yield db
|
||||
|
||||
@pytest.fixture
|
||||
def msg(self, db, maildir_msg):
|
||||
msg, dup = db.add(maildir_msg.path, sync_flags=False)
|
||||
yield msg
|
||||
|
||||
def test_type(self, msg):
|
||||
assert isinstance(msg, notdb.NotmuchObject)
|
||||
assert isinstance(msg, notdb.Message)
|
||||
|
||||
def test_alive(self, msg):
|
||||
assert msg.alive
|
||||
|
||||
def test_hash(self, msg):
|
||||
assert hash(msg)
|
||||
|
||||
def test_eq(self, db, msg):
|
||||
copy = db.get(msg.path)
|
||||
assert msg == copy
|
||||
|
||||
def test_messageid_type(self, msg):
|
||||
assert isinstance(msg.messageid, str)
|
||||
assert isinstance(msg.messageid, notdb.BinString)
|
||||
assert isinstance(bytes(msg.messageid), bytes)
|
||||
|
||||
def test_messageid(self, msg, maildir_msg):
|
||||
assert msg.messageid == maildir_msg.msgid
|
||||
|
||||
def test_messageid_find(self, db, msg):
|
||||
copy = db.find(msg.messageid)
|
||||
assert msg.messageid == copy.messageid
|
||||
|
||||
def test_threadid_type(self, msg):
|
||||
assert isinstance(msg.threadid, str)
|
||||
assert isinstance(msg.threadid, notdb.BinString)
|
||||
assert isinstance(bytes(msg.threadid), bytes)
|
||||
|
||||
def test_path_type(self, msg):
|
||||
assert isinstance(msg.path, pathlib.Path)
|
||||
|
||||
def test_path(self, msg, maildir_msg):
|
||||
assert msg.path == maildir_msg.path
|
||||
|
||||
def test_pathb_type(self, msg):
|
||||
assert isinstance(msg.pathb, bytes)
|
||||
|
||||
def test_pathb(self, msg, maildir_msg):
|
||||
assert msg.path == maildir_msg.path
|
||||
|
||||
def test_filenames_type(self, msg):
|
||||
ifn = msg.filenames()
|
||||
assert isinstance(ifn, collections.abc.Iterator)
|
||||
|
||||
def test_filenames(self, msg):
|
||||
ifn = msg.filenames()
|
||||
fn = next(ifn)
|
||||
assert fn == msg.path
|
||||
assert isinstance(fn, pathlib.Path)
|
||||
with pytest.raises(StopIteration):
|
||||
next(ifn)
|
||||
assert list(msg.filenames()) == [msg.path]
|
||||
|
||||
def test_filenamesb_type(self, msg):
|
||||
ifn = msg.filenamesb()
|
||||
assert isinstance(ifn, collections.abc.Iterator)
|
||||
|
||||
def test_filenamesb(self, msg):
|
||||
ifn = msg.filenamesb()
|
||||
fn = next(ifn)
|
||||
assert fn == msg.pathb
|
||||
assert isinstance(fn, bytes)
|
||||
with pytest.raises(StopIteration):
|
||||
next(ifn)
|
||||
assert list(msg.filenamesb()) == [msg.pathb]
|
||||
|
||||
def test_ghost_no(self, msg):
|
||||
assert not msg.ghost
|
||||
|
||||
def test_date(self, msg):
|
||||
# XXX Someone seems to treat things as local time instead of
|
||||
# UTC or the other way around.
|
||||
now = int(time.time())
|
||||
assert abs(now - msg.date) < 3600*24
|
||||
|
||||
def test_header(self, msg):
|
||||
assert msg.header('from') == 'src@example.com'
|
||||
|
||||
def test_header_not_present(self, msg):
|
||||
with pytest.raises(LookupError):
|
||||
msg.header('foo')
|
||||
|
||||
def test_freeze(self, msg):
|
||||
with msg.frozen():
|
||||
msg.tags.add('foo')
|
||||
msg.tags.add('bar')
|
||||
msg.tags.discard('foo')
|
||||
assert 'foo' not in msg.tags
|
||||
assert 'bar' in msg.tags
|
||||
|
||||
def test_freeze_err(self, msg):
|
||||
msg.tags.add('foo')
|
||||
try:
|
||||
with msg.frozen():
|
||||
msg.tags.clear()
|
||||
raise Exception('oops')
|
||||
except Exception:
|
||||
assert 'foo' in msg.tags
|
||||
else:
|
||||
pytest.fail('Context manager did not raise')
|
||||
|
||||
def test_replies_type(self, msg):
|
||||
assert isinstance(msg.replies(), collections.abc.Iterator)
|
||||
|
||||
def test_replies(self, msg):
|
||||
with pytest.raises(StopIteration):
|
||||
next(msg.replies())
|
||||
|
||||
|
||||
class TestProperties:
|
||||
|
||||
@pytest.fixture
|
||||
def props(self, maildir):
|
||||
msgid, path = maildir.deliver()
|
||||
with notdb.Database.create(maildir.path) as db:
|
||||
msg, dup = db.add(path, sync_flags=False)
|
||||
yield msg.properties
|
||||
|
||||
def test_type(self, props):
|
||||
assert isinstance(props, collections.abc.MutableMapping)
|
||||
|
||||
def test_add_single(self, props):
|
||||
props['foo'] = 'bar'
|
||||
assert props['foo'] == 'bar'
|
||||
props.add('bar', 'baz')
|
||||
assert props['bar'] == 'baz'
|
||||
|
||||
def test_add_dup(self, props):
|
||||
props.add('foo', 'bar')
|
||||
props.add('foo', 'baz')
|
||||
assert props['foo'] == 'bar'
|
||||
assert (set(props.getall('foo', exact=True))
|
||||
== {('foo', 'bar'), ('foo', 'baz')})
|
||||
|
||||
def test_len(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foo', 'b')
|
||||
props.add('bar', 'a')
|
||||
assert len(props) == 3
|
||||
assert len(props.keys()) == 2
|
||||
assert len(props.values()) == 2
|
||||
assert len(props.items()) == 3
|
||||
|
||||
def test_del(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foo', 'b')
|
||||
del props['foo']
|
||||
with pytest.raises(KeyError):
|
||||
props['foo']
|
||||
|
||||
def test_remove(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foo', 'b')
|
||||
props.remove('foo', 'a')
|
||||
assert props['foo'] == 'b'
|
||||
|
||||
def test_view_abcs(self, props):
|
||||
assert isinstance(props.keys(), collections.abc.KeysView)
|
||||
assert isinstance(props.values(), collections.abc.ValuesView)
|
||||
assert isinstance(props.items(), collections.abc.ItemsView)
|
||||
|
||||
def test_pop(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foo', 'b')
|
||||
val = props.pop('foo')
|
||||
assert val == 'a'
|
||||
|
||||
def test_pop_default(self, props):
|
||||
with pytest.raises(KeyError):
|
||||
props.pop('foo')
|
||||
assert props.pop('foo', 'default') == 'default'
|
||||
|
||||
def test_popitem(self, props):
|
||||
props.add('foo', 'a')
|
||||
assert props.popitem() == ('foo', 'a')
|
||||
with pytest.raises(KeyError):
|
||||
props.popitem()
|
||||
|
||||
def test_clear(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.clear()
|
||||
assert len(props) == 0
|
||||
|
||||
def test_getall(self, props):
|
||||
props.add('foo', 'a')
|
||||
assert set(props.getall('foo')) == {('foo', 'a')}
|
||||
|
||||
def test_getall_prefix(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foobar', 'b')
|
||||
assert set(props.getall('foo')) == {('foo', 'a'), ('foobar', 'b')}
|
||||
|
||||
def test_getall_exact(self, props):
|
||||
props.add('foo', 'a')
|
||||
props.add('foobar', 'b')
|
||||
assert set(props.getall('foo', exact=True)) == {('foo', 'a')}
|
177
bindings/python-cffi/tests/test_tags.py
Normal file
177
bindings/python-cffi/tests/test_tags.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
"""Tests for the behaviour of immutable and mutable tagsets.
|
||||
|
||||
This module tests the Pythonic behaviour of the sets.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from notdb import _database as database
|
||||
from notdb import _tags as tags
|
||||
|
||||
|
||||
class TestImmutable:
|
||||
|
||||
@pytest.fixture
|
||||
def tagset(self, maildir, notmuch):
|
||||
"""An non-empty immutable tagset.
|
||||
|
||||
This will have the default new mail tags: inbox, unread.
|
||||
"""
|
||||
maildir.deliver()
|
||||
notmuch('new')
|
||||
with database.Database(maildir.path) as db:
|
||||
yield db.tags
|
||||
|
||||
def test_type(self, tagset):
|
||||
assert isinstance(tagset, tags.ImmutableTagSet)
|
||||
assert isinstance(tagset, collections.abc.Set)
|
||||
|
||||
def test_hash(self, tagset, maildir, notmuch):
|
||||
h0 = hash(tagset)
|
||||
notmuch('tag', '+foo', '*')
|
||||
with database.Database(maildir.path) as db:
|
||||
h1 = hash(db.tags)
|
||||
assert h0 != h1
|
||||
|
||||
def test_eq(self, tagset):
|
||||
assert tagset == tagset
|
||||
|
||||
def test_neq(self, tagset, maildir, notmuch):
|
||||
notmuch('tag', '+foo', '*')
|
||||
with database.Database(maildir.path) as db:
|
||||
assert tagset != db.tags
|
||||
|
||||
def test_contains(self, tagset):
|
||||
print(tuple(tagset))
|
||||
assert 'unread' in tagset
|
||||
assert 'foo' not in tagset
|
||||
|
||||
def test_iter(self, tagset):
|
||||
expected = sorted(['unread', 'inbox'])
|
||||
found = []
|
||||
for tag in tagset:
|
||||
assert isinstance(tag, str)
|
||||
found.append(tag)
|
||||
assert expected == sorted(found)
|
||||
|
||||
def test_special_iter(self, tagset):
|
||||
expected = sorted([b'unread', b'inbox'])
|
||||
found = []
|
||||
for tag in tagset.iter():
|
||||
assert isinstance(tag, bytes)
|
||||
found.append(tag)
|
||||
assert expected == sorted(found)
|
||||
|
||||
def test_special_iter_codec(self, tagset):
|
||||
for tag in tagset.iter(encoding='ascii', errors='surrogateescape'):
|
||||
assert isinstance(tag, str)
|
||||
|
||||
def test_len(self, tagset):
|
||||
assert len(tagset) == 2
|
||||
|
||||
def test_and(self, tagset):
|
||||
common = tagset & {'unread'}
|
||||
assert isinstance(common, set)
|
||||
assert isinstance(common, collections.abc.Set)
|
||||
assert common == {'unread'}
|
||||
|
||||
def test_or(self, tagset):
|
||||
res = tagset | {'foo'}
|
||||
assert isinstance(res, set)
|
||||
assert isinstance(res, collections.abc.Set)
|
||||
assert res == {'unread', 'inbox', 'foo'}
|
||||
|
||||
def test_sub(self, tagset):
|
||||
res = tagset - {'unread'}
|
||||
assert isinstance(res, set)
|
||||
assert isinstance(res, collections.abc.Set)
|
||||
assert res == {'inbox'}
|
||||
|
||||
def test_rsub(self, tagset):
|
||||
res = {'foo', 'unread'} - tagset
|
||||
assert isinstance(res, set)
|
||||
assert isinstance(res, collections.abc.Set)
|
||||
assert res == {'foo'}
|
||||
|
||||
def test_xor(self, tagset):
|
||||
res = tagset ^ {'unread', 'foo'}
|
||||
assert isinstance(res, set)
|
||||
assert isinstance(res, collections.abc.Set)
|
||||
assert res == {'inbox', 'foo'}
|
||||
|
||||
def test_rxor(self, tagset):
|
||||
res = {'unread', 'foo'} ^ tagset
|
||||
assert isinstance(res, set)
|
||||
assert isinstance(res, collections.abc.Set)
|
||||
assert res == {'inbox', 'foo'}
|
||||
|
||||
|
||||
class TestMutableTagset:
|
||||
|
||||
@pytest.fixture
|
||||
def tagset(self, maildir, notmuch):
|
||||
"""An non-empty mutable tagset.
|
||||
|
||||
This will have the default new mail tags: inbox, unread.
|
||||
"""
|
||||
_, pathname = maildir.deliver()
|
||||
notmuch('new')
|
||||
with database.Database(maildir.path,
|
||||
mode=database.Mode.READ_WRITE) as db:
|
||||
msg = db.get(pathname)
|
||||
yield msg.tags
|
||||
|
||||
def test_type(self, tagset):
|
||||
assert isinstance(tagset, collections.abc.MutableSet)
|
||||
assert isinstance(tagset, tags.MutableTagSet)
|
||||
|
||||
def test_hash(self, tagset):
|
||||
assert not isinstance(tagset, collections.abc.Hashable)
|
||||
with pytest.raises(TypeError):
|
||||
hash(tagset)
|
||||
|
||||
def test_add(self, tagset):
|
||||
assert 'foo' not in tagset
|
||||
tagset.add('foo')
|
||||
assert 'foo' in tagset
|
||||
|
||||
def test_discard(self, tagset):
|
||||
assert 'inbox' in tagset
|
||||
tagset.discard('inbox')
|
||||
assert 'inbox' not in tagset
|
||||
|
||||
def test_discard_not_present(self, tagset):
|
||||
assert 'foo' not in tagset
|
||||
tagset.discard('foo')
|
||||
|
||||
def test_clear(self, tagset):
|
||||
assert len(tagset) > 0
|
||||
tagset.clear()
|
||||
assert len(tagset) == 0
|
||||
|
||||
def test_from_maildir_flags(self, maildir, notmuch):
|
||||
_, pathname = maildir.deliver(flagged=True)
|
||||
notmuch('new')
|
||||
with database.Database(maildir.path,
|
||||
mode=database.Mode.READ_WRITE) as db:
|
||||
msg = db.get(pathname)
|
||||
msg.tags.discard('flagged')
|
||||
msg.tags.from_maildir_flags()
|
||||
assert 'flagged' in msg.tags
|
||||
|
||||
def test_to_maildir_flags(self, maildir, notmuch):
|
||||
_, pathname = maildir.deliver(flagged=True)
|
||||
notmuch('new')
|
||||
with database.Database(maildir.path,
|
||||
mode=database.Mode.READ_WRITE) as db:
|
||||
msg = db.get(pathname)
|
||||
flags = msg.path.name.split(',')[-1]
|
||||
assert 'F' in flags
|
||||
msg.tags.discard('flagged')
|
||||
msg.tags.to_maildir_flags()
|
||||
flags = msg.path.name.split(',')[-1]
|
||||
assert 'F' not in flags
|
102
bindings/python-cffi/tests/test_thread.py
Normal file
102
bindings/python-cffi/tests/test_thread.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
import collections.abc
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import notdb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def thread(maildir, notmuch):
|
||||
"""Return a single thread with one matched message."""
|
||||
msgid, _ = maildir.deliver(body='foo')
|
||||
maildir.deliver(body='bar',
|
||||
headers=[('In-Reply-To', '<{}>'.format(msgid))])
|
||||
notmuch('new')
|
||||
with notdb.Database(maildir.path) as db:
|
||||
yield next(db.threads('foo'))
|
||||
|
||||
|
||||
def test_type(thread):
|
||||
assert isinstance(thread, notdb.Thread)
|
||||
assert isinstance(thread, collections.abc.Iterable)
|
||||
|
||||
|
||||
def test_threadid(thread):
|
||||
assert isinstance(thread.threadid, notdb.BinString)
|
||||
assert thread.threadid
|
||||
|
||||
|
||||
def test_len(thread):
|
||||
assert len(thread) == 2
|
||||
|
||||
|
||||
def test_toplevel_type(thread):
|
||||
assert isinstance(thread.toplevel(), collections.abc.Iterator)
|
||||
|
||||
|
||||
def test_toplevel(thread):
|
||||
msgs = thread.toplevel()
|
||||
assert isinstance(next(msgs), notdb.Message)
|
||||
with pytest.raises(StopIteration):
|
||||
next(msgs)
|
||||
|
||||
|
||||
def test_toplevel_reply(thread):
|
||||
msg = next(thread.toplevel())
|
||||
assert isinstance(next(msg.replies()), notdb.Message)
|
||||
|
||||
|
||||
def test_iter(thread):
|
||||
msgs = list(iter(thread))
|
||||
assert len(msgs) == len(thread)
|
||||
for msg in msgs:
|
||||
assert isinstance(msg, notdb.Message)
|
||||
|
||||
|
||||
def test_matched(thread):
|
||||
assert thread.matched == 1
|
||||
|
||||
|
||||
def test_authors_type(thread):
|
||||
assert isinstance(thread.authors, notdb.BinString)
|
||||
|
||||
|
||||
def test_authors(thread):
|
||||
assert thread.authors == 'src@example.com'
|
||||
|
||||
|
||||
def test_subject(thread):
|
||||
assert thread.subject == 'Test mail'
|
||||
|
||||
|
||||
def test_first(thread):
|
||||
# XXX Someone seems to treat things as local time instead of
|
||||
# UTC or the other way around.
|
||||
now = int(time.time())
|
||||
assert abs(now - thread.first) < 3600*24
|
||||
|
||||
|
||||
def test_last(thread):
|
||||
# XXX Someone seems to treat things as local time instead of
|
||||
# UTC or the other way around.
|
||||
now = int(time.time())
|
||||
assert abs(now - thread.last) < 3600*24
|
||||
|
||||
|
||||
def test_first_last(thread):
|
||||
# Sadly we only have second resolution so these will always be the
|
||||
# same time in our tests.
|
||||
assert thread.first <= thread.last
|
||||
|
||||
|
||||
def test_tags_type(thread):
|
||||
assert isinstance(thread.tags, notdb.ImmutableTagSet)
|
||||
|
||||
|
||||
def test_tags_cache(thread):
|
||||
assert thread.tags is thread.tags
|
||||
|
||||
|
||||
def test_tags(thread):
|
||||
assert 'inbox' in thread.tags
|
16
bindings/python-cffi/tox.ini
Normal file
16
bindings/python-cffi/tox.ini
Normal file
|
@ -0,0 +1,16 @@
|
|||
[pytest]
|
||||
minversion = 3.0
|
||||
addopts = -ra --cov=notdb --cov=tests
|
||||
|
||||
[tox]
|
||||
envlist = py35,py36,py37,pypy35,pypy36
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
cffi
|
||||
pytest
|
||||
pytest-cov
|
||||
commands = pytest --cov={envsitepackagesdir}/notdb {posargs}
|
||||
|
||||
[testenv:pypy35]
|
||||
basepython = pypy3.5
|
Loading…
Reference in a new issue