py3k: Add and use a mixin class that implements __str__
[notmuch] / bindings / python / notmuch / thread.py
1 """
2 This file is part of notmuch.
3
4 Notmuch is free software: you can redistribute it and/or modify it
5 under the terms of the GNU General Public License as published by the
6 Free Software Foundation, either version 3 of the License, or (at your
7 option) any later version.
8
9 Notmuch is distributed in the hope that it will be useful, but WITHOUT
10 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
12 for more details.
13
14 You should have received a copy of the GNU General Public License
15 along with notmuch.  If not, see <http://www.gnu.org/licenses/>.
16
17 Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>'
18 """
19
20 from ctypes import c_char_p, c_long, c_int
21 from notmuch.globals import (nmlib, STATUS,
22     NotmuchError, NotmuchThreadP, NotmuchThreadsP, NotmuchMessagesP,
23     NotmuchTagsP, Python3StringMixIn)
24 from notmuch.message import Messages
25 from notmuch.tag import Tags
26 from datetime import date
27
28
29 class Threads(Python3StringMixIn):
30     """Represents a list of notmuch threads
31
32     This object provides an iterator over a list of notmuch threads
33     (Technically, it provides a wrapper for the underlying
34     *notmuch_threads_t* structure). Do note that the underlying
35     library only provides a one-time iterator (it cannot reset the
36     iterator to the start). Thus iterating over the function will
37     "exhaust" the list of threads, and a subsequent iteration attempt
38     will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also
39     note, that any function that uses iteration will also
40     exhaust the messages. So both::
41
42       for thread in threads: print thread
43
44     as well as::
45
46        number_of_msgs = len(threads)
47
48     will "exhaust" the threads. If you need to re-iterate over a list of
49     messages you will need to retrieve a new :class:`Threads` object.
50
51     Things are not as bad as it seems though, you can store and reuse
52     the single Thread objects as often as you want as long as you
53     keep the parent Threads object around. (Recall that due to
54     hierarchical memory allocation, all derived Threads objects will
55     be invalid when we delete the parent Threads() object, even if it
56     was already "exhausted".) So this works::
57
58       db   = Database()
59       threads = Query(db,'').search_threads() #get a Threads() object
60       threadlist = []
61       for thread in threads:
62          threadlist.append(thread)
63
64       # threads is "exhausted" now and even len(threads) will raise an
65       # exception.
66       # However it will be kept around until all retrieved Thread() objects are
67       # also deleted. If you did e.g. an explicit del(threads) here, the
68       # following lines would fail.
69
70       # You can reiterate over *threadlist* however as often as you want.
71       # It is simply a list with Thread objects.
72
73       print (threadlist[0].get_thread_id())
74       print (threadlist[1].get_thread_id())
75       print (threadlist[0].get_total_messages())
76     """
77
78     #notmuch_threads_get
79     _get = nmlib.notmuch_threads_get
80     _get.argtypes = [NotmuchThreadsP]
81     _get.restype = NotmuchThreadP
82
83     def __init__(self, threads_p, parent=None):
84         """
85         :param threads_p:  A pointer to an underlying *notmuch_threads_t*
86              structure. These are not publically exposed, so a user
87              will almost never instantiate a :class:`Threads` object
88              herself. They are usually handed back as a result,
89              e.g. in :meth:`Query.search_threads`.  *threads_p* must be
90              valid, we will raise an :exc:`NotmuchError`
91              (STATUS.NULL_POINTER) if it is `None`.
92         :type threads_p: :class:`ctypes.c_void_p`
93         :param parent: The parent object
94              (ie :class:`Query`) these tags are derived from. It saves
95              a reference to it, so we can automatically delete the db
96              object once all derived objects are dead.
97         :TODO: Make the iterator work more than once and cache the tags in
98                the Python object.(?)
99         """
100         if threads_p is None:
101             raise NotmuchError(STATUS.NULL_POINTER)
102
103         self._threads = threads_p
104         #store parent, so we keep them alive as long as self  is alive
105         self._parent = parent
106
107     def __iter__(self):
108         """ Make Threads an iterator """
109         return self
110
111     _valid = nmlib.notmuch_threads_valid
112     _valid.argtypes = [NotmuchThreadsP]
113     _valid.restype = bool
114
115     _move_to_next = nmlib.notmuch_threads_move_to_next
116     _move_to_next.argtypes = [NotmuchThreadsP]
117     _move_to_next.restype = None
118
119     def __next__(self):
120         if self._threads is None:
121             raise NotmuchError(STATUS.NOT_INITIALIZED)
122
123         if not self._valid(self._threads):
124             self._threads = None
125             raise StopIteration
126
127         thread = Thread(Threads._get(self._threads), self)
128         self._move_to_next(self._threads)
129         return thread
130     next = __next__ # python2.x iterator protocol compatibility
131
132     def __len__(self):
133         """len(:class:`Threads`) returns the number of contained Threads
134
135         .. note:: As this iterates over the threads, we will not be able to
136                iterate over them again! So this will fail::
137
138                  #THIS FAILS
139                  threads = Database().create_query('').search_threads()
140                  if len(threads) > 0:              #this 'exhausts' threads
141                      # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
142                      for thread in threads: print thread
143         """
144         if self._threads is None:
145             raise NotmuchError(STATUS.NOT_INITIALIZED)
146
147         i = 0
148         # returns 'bool'. On out-of-memory it returns None
149         while self._valid(self._threads):
150             self._move_to_next(self._threads)
151             i += 1
152         # reset self._threads to mark as "exhausted"
153         self._threads = None
154         return i
155
156     def __nonzero__(self):
157         """Check if :class:`Threads` contains at least one more valid thread
158
159         The existence of this function makes 'if Threads: foo' work, as
160         that will implicitely call len() exhausting the iterator if
161         __nonzero__ does not exist. This function makes `bool(Threads())`
162         work repeatedly.
163
164         :return: True if there is at least one more thread in the
165            Iterator, False if not. None on a "Out-of-memory" error.
166         """
167         return self._threads is not None and \
168             self._valid(self._threads) > 0
169
170     _destroy = nmlib.notmuch_threads_destroy
171     _destroy.argtypes = [NotmuchThreadsP]
172     _destroy.argtypes = None
173
174     def __del__(self):
175         """Close and free the notmuch Threads"""
176         if self._threads is not None:
177             self._destroy(self._threads)
178
179
180 class Thread(object):
181     """Represents a single message thread."""
182
183     """notmuch_thread_get_thread_id"""
184     _get_thread_id = nmlib.notmuch_thread_get_thread_id
185     _get_thread_id.argtypes = [NotmuchThreadP]
186     _get_thread_id.restype = c_char_p
187
188     """notmuch_thread_get_authors"""
189     _get_authors = nmlib.notmuch_thread_get_authors
190     _get_authors.argtypes = [NotmuchThreadP]
191     _get_authors.restype = c_char_p
192
193     """notmuch_thread_get_subject"""
194     _get_subject = nmlib.notmuch_thread_get_subject
195     _get_subject.argtypes = [NotmuchThreadP]
196     _get_subject.restype = c_char_p
197
198     """notmuch_thread_get_toplevel_messages"""
199     _get_toplevel_messages = nmlib.notmuch_thread_get_toplevel_messages
200     _get_toplevel_messages.argtypes = [NotmuchThreadP]
201     _get_toplevel_messages.restype = NotmuchMessagesP
202
203     _get_newest_date = nmlib.notmuch_thread_get_newest_date
204     _get_newest_date.argtypes = [NotmuchThreadP]
205     _get_newest_date.restype = c_long
206
207     _get_oldest_date = nmlib.notmuch_thread_get_oldest_date
208     _get_oldest_date.argtypes = [NotmuchThreadP]
209     _get_oldest_date.restype = c_long
210
211     """notmuch_thread_get_tags"""
212     _get_tags = nmlib.notmuch_thread_get_tags
213     _get_tags.argtypes = [NotmuchThreadP]
214     _get_tags.restype = NotmuchTagsP
215
216     def __init__(self, thread_p, parent=None):
217         """
218         :param thread_p: A pointer to an internal notmuch_thread_t
219             Structure.  These are not publically exposed, so a user
220             will almost never instantiate a :class:`Thread` object
221             herself. They are usually handed back as a result,
222             e.g. when iterating through :class:`Threads`. *thread_p*
223             must be valid, we will raise an :exc:`NotmuchError`
224             (STATUS.NULL_POINTER) if it is `None`.
225
226         :param parent: A 'parent' object is passed which this message is
227               derived from. We save a reference to it, so we can
228               automatically delete the parent object once all derived
229               objects are dead.
230         """
231         if thread_p is None:
232             raise NotmuchError(STATUS.NULL_POINTER)
233         self._thread = thread_p
234         #keep reference to parent, so we keep it alive
235         self._parent = parent
236
237     def get_thread_id(self):
238         """Get the thread ID of 'thread'
239
240         The returned string belongs to 'thread' and will only be valid
241         for as long as the thread is valid.
242
243         :returns: String with a message ID
244         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread
245                     is not initialized.
246         """
247         if self._thread is None:
248             raise NotmuchError(STATUS.NOT_INITIALIZED)
249         return Thread._get_thread_id(self._thread)
250
251     _get_total_messages = nmlib.notmuch_thread_get_total_messages
252     _get_total_messages.argtypes = [NotmuchThreadP]
253     _get_total_messages.restype = c_int
254
255     def get_total_messages(self):
256         """Get the total number of messages in 'thread'
257
258         :returns: The number of all messages in the database
259                   belonging to this thread. Contrast with
260                   :meth:`get_matched_messages`.
261         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread
262                     is not initialized.
263         """
264         if self._thread is None:
265             raise NotmuchError(STATUS.NOT_INITIALIZED)
266         return self._get_total_messages(self._thread)
267
268     def get_toplevel_messages(self):
269         """Returns a :class:`Messages` iterator for the top-level messages in
270            'thread'
271
272            This iterator will not necessarily iterate over all of the messages
273            in the thread. It will only iterate over the messages in the thread
274            which are not replies to other messages in the thread.
275
276            To iterate over all messages in the thread, the caller will need to
277            iterate over the result of :meth:`Message.get_replies` for each
278            top-level message (and do that recursively for the resulting
279            messages, etc.).
280
281         :returns: :class:`Messages`
282         :exception: :exc:`NotmuchError`
283
284                       * STATUS.NOT_INITIALIZED if query is not inited
285                       * STATUS.NULL_POINTER if search_messages failed
286         """
287         if self._thread is None:
288             raise NotmuchError(STATUS.NOT_INITIALIZED)
289
290         msgs_p = Thread._get_toplevel_messages(self._thread)
291
292         if msgs_p is None:
293             raise NotmuchError(STATUS.NULL_POINTER)
294
295         return Messages(msgs_p, self)
296
297     _get_matched_messages = nmlib.notmuch_thread_get_matched_messages
298     _get_matched_messages.argtypes = [NotmuchThreadP]
299     _get_matched_messages.restype = c_int
300
301     def get_matched_messages(self):
302         """Returns the number of messages in 'thread' that matched the query
303
304         :returns: The number of all messages belonging to this thread that
305                   matched the :class:`Query`from which this thread was created.
306                   Contrast with :meth:`get_total_messages`.
307         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread
308                     is not initialized.
309         """
310         if self._thread is None:
311             raise NotmuchError(STATUS.NOT_INITIALIZED)
312         return self._get_matched_messages(self._thread)
313
314     def get_authors(self):
315         """Returns the authors of 'thread'
316
317         The returned string is a comma-separated list of the names of the
318         authors of mail messages in the query results that belong to this
319         thread.
320
321         The returned string belongs to 'thread' and will only be valid for
322         as long as this Thread() is not deleted.
323         """
324         if self._thread is None:
325             raise NotmuchError(STATUS.NOT_INITIALIZED)
326         authors = Thread._get_authors(self._thread)
327         if authors is None:
328             return None
329         return authors.decode('UTF-8', errors='ignore')
330
331     def get_subject(self):
332         """Returns the Subject of 'thread'
333
334         The returned string belongs to 'thread' and will only be valid for
335         as long as this Thread() is not deleted.
336         """
337         if self._thread is None:
338             raise NotmuchError(STATUS.NOT_INITIALIZED)
339         subject = Thread._get_subject(self._thread)
340         if subject is None:
341             return None
342         return subject.decode('UTF-8', errors='ignore')
343
344     def get_newest_date(self):
345         """Returns time_t of the newest message date
346
347         :returns: A time_t timestamp.
348         :rtype: c_unit64
349         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
350                     is not initialized.
351         """
352         if self._thread is None:
353             raise NotmuchError(STATUS.NOT_INITIALIZED)
354         return Thread._get_newest_date(self._thread)
355
356     def get_oldest_date(self):
357         """Returns time_t of the oldest message date
358
359         :returns: A time_t timestamp.
360         :rtype: c_unit64
361         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
362                     is not initialized.
363         """
364         if self._thread is None:
365             raise NotmuchError(STATUS.NOT_INITIALIZED)
366         return Thread._get_oldest_date(self._thread)
367
368     def get_tags(self):
369         """ Returns the message tags
370
371         In the Notmuch database, tags are stored on individual
372         messages, not on threads. So the tags returned here will be all
373         tags of the messages which matched the search and which belong to
374         this thread.
375
376         The :class:`Tags` object is owned by the thread and as such, will only
377         be valid for as long as this :class:`Thread` is valid (e.g. until the
378         query from which it derived is explicitely deleted).
379
380         :returns: A :class:`Tags` iterator.
381         :exception: :exc:`NotmuchError`
382
383                       * STATUS.NOT_INITIALIZED if the thread
384                         is not initialized.
385                       * STATUS.NULL_POINTER, on error
386         """
387         if self._thread is None:
388             raise NotmuchError(STATUS.NOT_INITIALIZED)
389
390         tags_p = Thread._get_tags(self._thread)
391         if tags_p == None:
392             raise NotmuchError(STATUS.NULL_POINTER)
393         return Tags(tags_p, self)
394
395     def __unicode__(self):
396         frm = "thread:%s %12s [%d/%d] %s; %s (%s)"
397
398         return frm % (self.get_thread_id(),
399                       date.fromtimestamp(self.get_newest_date()),
400                       self.get_matched_messages(),
401                       self.get_total_messages(),
402                       self.get_authors(),
403                       self.get_subject(),
404                       self.get_tags(),
405                      )
406
407     _destroy = nmlib.notmuch_thread_destroy
408     _destroy.argtypes = [NotmuchThreadP]
409     _destroy.restype = None
410
411     def __del__(self):
412         """Close and free the notmuch Thread"""
413         if self._thread is not None:
414             self._destroy(self._thread)