X-Git-Url: https://git.notmuchmail.org/git?a=blobdiff_plain;f=bindings%2Fpython-cffi%2Fnotmuch2%2F_message.py;fp=bindings%2Fpython-cffi%2Fnotmuch2%2F_message.py;h=bb5614260e8046a1ceb4e7b3450d0200ab684939;hb=e2df30f7a98f91543d0b3561dbb366eb4b3d812c;hp=0000000000000000000000000000000000000000;hpb=a950aa28449feef76246ad2b64224fd72e2e574c;p=notmuch diff --git a/bindings/python-cffi/notmuch2/_message.py b/bindings/python-cffi/notmuch2/_message.py new file mode 100644 index 00000000..bb561426 --- /dev/null +++ b/bindings/python-cffi/notmuch2/_message.py @@ -0,0 +1,691 @@ +import collections +import contextlib +import os +import pathlib +import weakref + +import notmuch2._base as base +import notmuch2._capi as capi +import notmuch2._errors as errors +import notmuch2._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: + + :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)