X-Git-Url: https://git.notmuchmail.org/git?a=blobdiff_plain;f=bindings%2Fpython-cffi%2Fnotmuch2%2F_tags.py;fp=bindings%2Fpython-cffi%2Fnotmuch2%2F_tags.py;h=fe422a7944882d568eeaf81e2a3b7f8099db2bf9;hb=e2df30f7a98f91543d0b3561dbb366eb4b3d812c;hp=0000000000000000000000000000000000000000;hpb=a950aa28449feef76246ad2b64224fd72e2e574c;p=notmuch diff --git a/bindings/python-cffi/notmuch2/_tags.py b/bindings/python-cffi/notmuch2/_tags.py new file mode 100644 index 00000000..fe422a79 --- /dev/null +++ b/bindings/python-cffi/notmuch2/_tags.py @@ -0,0 +1,338 @@ +import collections.abc + +import notmuch2._base as base +import notmuch2._capi as capi +import notmuch2._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 '' + else: + return ''