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