]> git.notmuchmail.org Git - notmuch/blob - bindings/python-cffi/notmuch2/_tags.py
Add missing set methods to tagsets
[notmuch] / bindings / python-cffi / notmuch2 / _tags.py
1 import collections.abc
2
3 import notmuch2._base as base
4 import notmuch2._capi as capi
5 import notmuch2._errors as errors
6
7
8 __all__ = ['ImmutableTagSet', 'MutableTagSet', 'TagsIter']
9
10
11 class ImmutableTagSet(base.NotmuchObject, collections.abc.Set):
12     """The tags associated with a message thread or whole database.
13
14     Both a thread as well as the database expose the union of all tags
15     in messages associated with them.  This exposes these as a
16     :class:`collections.abc.Set` object.
17
18     Note that due to the underlying notmuch API the performance of the
19     implementation is not the same as you would expect from normal
20     sets.  E.g. the :meth:`__contains__` and :meth:`__len__` are O(n)
21     rather then O(1).
22
23     Tags are internally stored as bytestrings but normally exposed as
24     unicode strings using the UTF-8 encoding and the *ignore* decoder
25     error handler.  However the :meth:`iter` method can be used to
26     return tags as bytestrings or using a different error handler.
27
28     Note that when doing arithmetic operations on tags, this class
29     will return a plain normal set as it is no longer associated with
30     the message.
31
32     :param parent: the parent object
33     :param ptr_name: the name of the attribute on the parent which will
34        return the memory pointer.  This allows this object to
35        access the pointer via the parent's descriptor and thus
36        trigger :class:`MemoryPointer`'s memory safety.
37     :param cffi_fn: the callable CFFI wrapper to retrieve the tags
38        iter.  This can be one of notmuch_database_get_all_tags,
39        notmuch_thread_get_tags or notmuch_message_get_tags.
40     """
41
42     def __init__(self, parent, ptr_name, cffi_fn):
43         self._parent = parent
44         self._ptr = lambda: getattr(parent, ptr_name)
45         self._cffi_fn = cffi_fn
46
47     def __del__(self):
48         self._destroy()
49
50     @property
51     def alive(self):
52         return self._parent.alive
53
54     def _destroy(self):
55         pass
56
57     @classmethod
58     def _from_iterable(cls, it):
59         return set(it)
60
61     def __iter__(self):
62         """Return an iterator over the tags.
63
64         Tags are yielded as unicode strings, decoded using the
65         "ignore" error handler.
66
67         :raises NullPointerError: If the iterator can not be created.
68         """
69         return self.iter(encoding='utf-8', errors='ignore')
70
71     def iter(self, *, encoding=None, errors='strict'):
72         """Aternate iterator constructor controlling string decoding.
73
74         Tags are stored as bytes in the notmuch database, in Python
75         it's easier to work with unicode strings and thus is what the
76         normal iterator returns.  However this method allows you to
77         specify how you would like to get the tags, defaulting to the
78         bytestring representation instead of unicode strings.
79
80         :param encoding: Which codec to use.  The default *None* does not
81            decode at all and will return the unmodified bytes.
82            Otherwise this is passed on to :func:`str.decode`.
83         :param errors: If using a codec, this is the error handler.
84            See :func:`str.decode` to which this is passed on.
85
86         :raises NullPointerError: When things do not go as planned.
87         """
88         # self._cffi_fn should point either to
89         # notmuch_database_get_all_tags, notmuch_thread_get_tags or
90         # notmuch_message_get_tags.  nothmuch.h suggests these never
91         # fail, let's handle NULL anyway.
92         tags_p = self._cffi_fn(self._ptr())
93         if tags_p == capi.ffi.NULL:
94             raise errors.NullPointerError()
95         tags = TagsIter(self, tags_p, encoding=encoding, errors=errors)
96         return tags
97
98     def __len__(self):
99         return sum(1 for t in self)
100
101     def __contains__(self, tag):
102         if isinstance(tag, str):
103             tag = tag.encode()
104         for msg_tag in self.iter():
105             if tag == msg_tag:
106                 return True
107         else:
108             return False
109
110     def __eq__(self, other):
111         return tuple(sorted(self.iter())) == tuple(sorted(other.iter()))
112
113     def issubset(self, other):
114         return self <= other
115
116     def issuperset(self, other):
117         return self >= other
118
119     def union(self, other):
120         return self | other
121
122     def intersection(self, other):
123         return self & other
124
125     def difference(self, other):
126         return self - other
127
128     def symmetric_difference(self, other):
129         return self ^ other
130
131     def copy(self):
132         return set(self)
133
134     def __hash__(self):
135         return hash(tuple(self.iter()))
136
137     def __repr__(self):
138         return '<{name} object at 0x{addr:x} tags={{{tags}}}>'.format(
139             name=self.__class__.__name__,
140             addr=id(self),
141             tags=', '.join(repr(t) for t in self))
142
143
144 class MutableTagSet(ImmutableTagSet, collections.abc.MutableSet):
145     """The tags associated with a message.
146
147     This is a :class:`collections.abc.MutableSet` object which can be
148     used to manipulate the tags of a message.
149
150     Note that due to the underlying notmuch API the performance of the
151     implementation is not the same as you would expect from normal
152     sets.  E.g. the ``in`` operator and variants are O(n) rather then
153     O(1).
154
155     Tags are bytestrings and calling ``iter()`` will return an
156     iterator yielding bytestrings.  However the :meth:`iter` method
157     can be used to return tags as unicode strings, while all other
158     operations accept either byestrings or unicode strings.  In case
159     unicode strings are used they will be encoded using utf-8 before
160     being passed to notmuch.
161     """
162
163     # Since we subclass ImmutableTagSet we inherit a __hash__.  But we
164     # are mutable, setting it to None will make the Python machinary
165     # recognise us as unhashable.
166     __hash__ = None
167
168     def add(self, tag):
169         """Add a tag to the message.
170
171         :param tag: The tag to add.
172         :type tag: str or bytes.  A str will be encoded using UTF-8.
173
174         :param sync_flags: Whether to sync the maildir flags with the
175            new set of tags.  Leaving this as *None* respects the
176            configuration set in the database, while *True* will always
177            sync and *False* will never sync.
178         :param sync_flags: NoneType or bool
179
180         :raises TypeError: If the tag is not a valid type.
181         :raises TagTooLongError: If the added tag exceeds the maximum
182            lenght, see ``notmuch_cffi.NOTMUCH_TAG_MAX``.
183         :raises ReadOnlyDatabaseError: If the database is opened in
184            read-only mode.
185         """
186         if isinstance(tag, str):
187             tag = tag.encode()
188         if not isinstance(tag, bytes):
189             raise TypeError('Not a valid type for a tag: {}'.format(type(tag)))
190         ret = capi.lib.notmuch_message_add_tag(self._ptr(), tag)
191         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
192             raise errors.NotmuchError(ret)
193
194     def discard(self, tag):
195         """Remove a tag from the message.
196
197         :param tag: The tag to remove.
198         :type tag: str of bytes.  A str will be encoded using UTF-8.
199         :param sync_flags: Whether to sync the maildir flags with the
200            new set of tags.  Leaving this as *None* respects the
201            configuration set in the database, while *True* will always
202            sync and *False* will never sync.
203         :param sync_flags: NoneType or bool
204
205         :raises TypeError: If the tag is not a valid type.
206         :raises TagTooLongError: If the tag exceeds the maximum
207            lenght, see ``notmuch_cffi.NOTMUCH_TAG_MAX``.
208         :raises ReadOnlyDatabaseError: If the database is opened in
209            read-only mode.
210         """
211         if isinstance(tag, str):
212             tag = tag.encode()
213         if not isinstance(tag, bytes):
214             raise TypeError('Not a valid type for a tag: {}'.format(type(tag)))
215         ret = capi.lib.notmuch_message_remove_tag(self._ptr(), tag)
216         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
217             raise errors.NotmuchError(ret)
218
219     def clear(self):
220         """Remove all tags from the message.
221
222         :raises ReadOnlyDatabaseError: If the database is opened in
223            read-only mode.
224         """
225         ret = capi.lib.notmuch_message_remove_all_tags(self._ptr())
226         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
227             raise errors.NotmuchError(ret)
228
229     def from_maildir_flags(self):
230         """Update the tags based on the state in the message's maildir flags.
231
232         This function examines the filenames of 'message' for maildir
233         flags, and adds or removes tags on 'message' as follows when
234         these flags are present:
235
236         Flag    Action if present
237         ----    -----------------
238         'D'     Adds the "draft" tag to the message
239         'F'     Adds the "flagged" tag to the message
240         'P'     Adds the "passed" tag to the message
241         'R'     Adds the "replied" tag to the message
242         'S'     Removes the "unread" tag from the message
243
244         For each flag that is not present, the opposite action
245         (add/remove) is performed for the corresponding tags.
246
247         Flags are identified as trailing components of the filename
248         after a sequence of ":2,".
249
250         If there are multiple filenames associated with this message,
251         the flag is considered present if it appears in one or more
252         filenames. (That is, the flags from the multiple filenames are
253         combined with the logical OR operator.)
254         """
255         ret = capi.lib.notmuch_message_maildir_flags_to_tags(self._ptr())
256         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
257             raise errors.NotmuchError(ret)
258
259     def to_maildir_flags(self):
260         """Update the message's maildir flags based on the notmuch tags.
261
262         If the message's filename is in a maildir directory, that is a
263         directory named ``new`` or ``cur``, and has a valid maildir
264         filename then the flags will be added as such:
265
266         'D' if the message has the "draft" tag
267         'F' if the message has the "flagged" tag
268         'P' if the message has the "passed" tag
269         'R' if the message has the "replied" tag
270         'S' if the message does not have the "unread" tag
271
272         Any existing flags unmentioned in the list above will be
273         preserved in the renaming.
274
275         Also, if this filename is in a directory named "new", rename it to
276         be within the neighboring directory named "cur".
277
278         In case there are multiple files associated with the message
279         all filenames will get the same logic applied.
280         """
281         ret = capi.lib.notmuch_message_tags_to_maildir_flags(self._ptr())
282         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
283             raise errors.NotmuchError(ret)
284
285
286 class TagsIter(base.NotmuchObject, collections.abc.Iterator):
287     """Iterator over tags.
288
289     This is only an interator, not a container so calling
290     :meth:`__iter__` does not return a new, replenished iterator but
291     only itself.
292
293     :param parent: The parent object to keep alive.
294     :param tags_p: The CFFI pointer to the C-level tags iterator.
295     :param encoding: Which codec to use.  The default *None* does not
296        decode at all and will return the unmodified bytes.
297        Otherwise this is passed on to :func:`str.decode`.
298     :param errors: If using a codec, this is the error handler.
299        See :func:`str.decode` to which this is passed on.
300
301     :raises ObjectDestroyedError: if used after destroyed.
302     """
303     _tags_p = base.MemoryPointer()
304
305     def __init__(self, parent, tags_p, *, encoding=None, errors='strict'):
306         self._parent = parent
307         self._tags_p = tags_p
308         self._encoding = encoding
309         self._errors = errors
310
311     def __del__(self):
312         self._destroy()
313
314     @property
315     def alive(self):
316         if not self._parent.alive:
317             return False
318         try:
319             self._tags_p
320         except errors.ObjectDestroyedError:
321             return False
322         else:
323             return True
324
325     def _destroy(self):
326         if self.alive:
327             try:
328                 capi.lib.notmuch_tags_destroy(self._tags_p)
329             except errors.ObjectDestroyedError:
330                 pass
331         self._tags_p = None
332
333     def __iter__(self):
334         """Return the iterator itself.
335
336         Note that as this is an iterator and not a container this will
337         not return a new iterator.  Thus any elements already consumed
338         will not be yielded by the :meth:`__next__` method anymore.
339         """
340         return self
341
342     def __next__(self):
343         if not capi.lib.notmuch_tags_valid(self._tags_p):
344             self._destroy()
345             raise StopIteration()
346         tag_p = capi.lib.notmuch_tags_get(self._tags_p)
347         tag = capi.ffi.string(tag_p)
348         if self._encoding:
349             tag = tag.decode(encoding=self._encoding, errors=self._errors)
350         capi.lib.notmuch_tags_move_to_next(self._tags_p)
351         return tag
352
353     def __repr__(self):
354         try:
355             self._tags_p
356         except errors.ObjectDestroyedError:
357             return '<TagsIter (exhausted)>'
358         else:
359             return '<TagsIter>'