]> git.notmuchmail.org Git - notmuch/blob - bindings/python/notmuch/database.py
Merge branch 'release'
[notmuch] / bindings / python / notmuch / database.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 <https://www.gnu.org/licenses/>.
16
17 Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>
18 """
19
20 import os
21 import codecs
22 import warnings
23 from ctypes import c_char_p, c_void_p, c_uint, byref, POINTER
24 from .compat import SafeConfigParser
25 from .globals import (
26     nmlib,
27     Enum,
28     _str,
29     NotmuchDatabaseP,
30     NotmuchDirectoryP,
31     NotmuchMessageP,
32     NotmuchTagsP,
33 )
34 from .errors import (
35     STATUS,
36     FileError,
37     NotmuchError,
38     NullPointerError,
39     NotInitializedError,
40 )
41 from .message import Message
42 from .tag import Tags
43 from .query import Query
44 from .directory import Directory
45
46 class Database(object):
47     """The :class:`Database` is the highest-level object that notmuch
48     provides. It references a notmuch database, and can be opened in
49     read-only or read-write mode. A :class:`Query` can be derived from
50     or be applied to a specific database to find messages. Also adding
51     and removing messages to the database happens via this
52     object. Modifications to the database are not atmic by default (see
53     :meth:`begin_atomic`) and once a database has been modified, all
54     other database objects pointing to the same data-base will throw an
55     :exc:`XapianError` as the underlying database has been
56     modified. Close and reopen the database to continue working with it.
57
58     :class:`Database` objects implement the context manager protocol
59     so you can use the :keyword:`with` statement to ensure that the
60     database is properly closed. See :meth:`close` for more
61     information.
62
63     .. note::
64
65         Any function in this class can and will throw an
66         :exc:`NotInitializedError` if the database was not intitialized
67         properly.
68     """
69     _std_db_path = None
70     """Class attribute to cache user's default database"""
71
72     MODE = Enum(['READ_ONLY', 'READ_WRITE'])
73     """Constants: Mode in which to open the database"""
74
75     """notmuch_database_get_directory"""
76     _get_directory = nmlib.notmuch_database_get_directory
77     _get_directory.argtypes = [NotmuchDatabaseP, c_char_p, POINTER(NotmuchDirectoryP)]
78     _get_directory.restype = c_uint
79
80     """notmuch_database_get_path"""
81     _get_path = nmlib.notmuch_database_get_path
82     _get_path.argtypes = [NotmuchDatabaseP]
83     _get_path.restype = c_char_p
84
85     """notmuch_database_get_version"""
86     _get_version = nmlib.notmuch_database_get_version
87     _get_version.argtypes = [NotmuchDatabaseP]
88     _get_version.restype = c_uint
89
90     """notmuch_database_get_revision"""
91     _get_revision = nmlib.notmuch_database_get_revision
92     _get_revision.argtypes = [NotmuchDatabaseP, POINTER(c_char_p)]
93     _get_revision.restype = c_uint
94
95     """notmuch_database_open"""
96     _open = nmlib.notmuch_database_open
97     _open.argtypes = [c_char_p, c_uint, POINTER(NotmuchDatabaseP)]
98     _open.restype = c_uint
99
100     """notmuch_database_upgrade"""
101     _upgrade = nmlib.notmuch_database_upgrade
102     _upgrade.argtypes = [NotmuchDatabaseP, c_void_p, c_void_p]
103     _upgrade.restype = c_uint
104
105     """ notmuch_database_find_message"""
106     _find_message = nmlib.notmuch_database_find_message
107     _find_message.argtypes = [NotmuchDatabaseP, c_char_p,
108                               POINTER(NotmuchMessageP)]
109     _find_message.restype = c_uint
110
111     """notmuch_database_find_message_by_filename"""
112     _find_message_by_filename = nmlib.notmuch_database_find_message_by_filename
113     _find_message_by_filename.argtypes = [NotmuchDatabaseP, c_char_p,
114                                           POINTER(NotmuchMessageP)]
115     _find_message_by_filename.restype = c_uint
116
117     """notmuch_database_get_all_tags"""
118     _get_all_tags = nmlib.notmuch_database_get_all_tags
119     _get_all_tags.argtypes = [NotmuchDatabaseP]
120     _get_all_tags.restype = NotmuchTagsP
121
122     """notmuch_database_create"""
123     _create = nmlib.notmuch_database_create
124     _create.argtypes = [c_char_p, POINTER(NotmuchDatabaseP)]
125     _create.restype = c_uint
126
127     def __init__(self, path = None, create = False,
128                  mode = MODE.READ_ONLY):
129         """If *path* is `None`, we will try to read a users notmuch
130         configuration and use his configured database. The location of the
131         configuration file can be specified through the environment variable
132         *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`.
133
134         If *create* is `True`, the database will always be created in
135         :attr:`MODE`.READ_WRITE mode. Default mode for opening is READ_ONLY.
136
137         :param path:   Directory to open/create the database in (see
138                        above for behavior if `None`)
139         :type path:    `str` or `None`
140         :param create: Pass `False` to open an existing, `True` to create a new
141                        database.
142         :type create:  bool
143         :param mode:   Mode to open a database in. Is always
144                        :attr:`MODE`.READ_WRITE when creating a new one.
145         :type mode:    :attr:`MODE`
146         :raises: :exc:`NotmuchError` or derived exception in case of
147             failure.
148         """
149         self._db = None
150         self.mode = mode
151         if path is None:
152             # no path specified. use a user's default database
153             if Database._std_db_path is None:
154                 #the following line throws a NotmuchError if it fails
155                 Database._std_db_path = self._get_user_default_db()
156             path = Database._std_db_path
157
158         if create == False:
159             self.open(path, mode)
160         else:
161             self.create(path)
162
163     _destroy = nmlib.notmuch_database_destroy
164     _destroy.argtypes = [NotmuchDatabaseP]
165     _destroy.restype = c_uint
166
167     def __del__(self):
168         if self._db:
169             status = self._destroy(self._db)
170             if status != STATUS.SUCCESS:
171                 raise NotmuchError(status)
172
173     def _assert_db_is_initialized(self):
174         """Raises :exc:`NotInitializedError` if self._db is `None`"""
175         if not self._db:
176             raise NotInitializedError()
177
178     def create(self, path):
179         """Creates a new notmuch database
180
181         This function is used by __init__() and usually does not need
182         to be called directly. It wraps the underlying
183         *notmuch_database_create* function and creates a new notmuch
184         database at *path*. It will always return a database in :attr:`MODE`
185         .READ_WRITE mode as creating an empty database for
186         reading only does not make a great deal of sense.
187
188         :param path: A directory in which we should create the database.
189         :type path: str
190         :raises: :exc:`NotmuchError` in case of any failure
191                     (possibly after printing an error message on stderr).
192         """
193         if self._db:
194             raise NotmuchError(message="Cannot create db, this Database() "
195                                        "already has an open one.")
196
197         db = NotmuchDatabaseP()
198         status = Database._create(_str(path), byref(db))
199
200         if status != STATUS.SUCCESS:
201             raise NotmuchError(status)
202         self._db = db
203         return status
204
205     def open(self, path, mode=0):
206         """Opens an existing database
207
208         This function is used by __init__() and usually does not need
209         to be called directly. It wraps the underlying
210         *notmuch_database_open* function.
211
212         :param status: Open the database in read-only or read-write mode
213         :type status:  :attr:`MODE`
214         :raises: Raises :exc:`NotmuchError` in case of any failure
215                     (possibly after printing an error message on stderr).
216         """
217         db = NotmuchDatabaseP()
218         status = Database._open(_str(path), mode, byref(db))
219
220         if status != STATUS.SUCCESS:
221             raise NotmuchError(status)
222         self._db = db
223         return status
224
225     _close = nmlib.notmuch_database_close
226     _close.argtypes = [NotmuchDatabaseP]
227     _close.restype = c_uint
228
229     def close(self):
230         '''
231         Closes the notmuch database.
232
233         .. warning::
234
235             This function closes the notmuch database. From that point
236             on every method invoked on any object ever derived from
237             the closed database may cease to function and raise a
238             NotmuchError.
239         '''
240         if self._db:
241             status = self._close(self._db)
242             if status != STATUS.SUCCESS:
243                 raise NotmuchError(status)
244
245     def __enter__(self):
246         '''
247         Implements the context manager protocol.
248         '''
249         return self
250
251     def __exit__(self, exc_type, exc_value, traceback):
252         '''
253         Implements the context manager protocol.
254         '''
255         self.close()
256
257     def get_path(self):
258         """Returns the file path of an open database"""
259         self._assert_db_is_initialized()
260         return Database._get_path(self._db).decode('utf-8')
261
262     def get_version(self):
263         """Returns the database format version
264
265         :returns: The database version as positive integer
266         """
267         self._assert_db_is_initialized()
268         return Database._get_version(self._db)
269
270     def get_revision (self):
271         """Returns the committed database revison and UUID
272
273         :returns: (revison, uuid) The database revision as a positive integer
274         and the UUID of the database.
275         """
276         self._assert_db_is_initialized()
277         uuid = c_char_p ()
278         revision = Database._get_revision(self._db, byref (uuid))
279         return (revision, uuid.value.decode ('utf-8'))
280
281     _needs_upgrade = nmlib.notmuch_database_needs_upgrade
282     _needs_upgrade.argtypes = [NotmuchDatabaseP]
283     _needs_upgrade.restype = bool
284
285     def needs_upgrade(self):
286         """Does this database need to be upgraded before writing to it?
287
288         If this function returns `True` then no functions that modify the
289         database (:meth:`index_file`,
290         :meth:`Message.add_tag`, :meth:`Directory.set_mtime`,
291         etc.) will work unless :meth:`upgrade` is called successfully first.
292
293         :returns: `True` or `False`
294         """
295         self._assert_db_is_initialized()
296         return self._needs_upgrade(self._db)
297
298     def upgrade(self):
299         """Upgrades the current database
300
301         After opening a database in read-write mode, the client should
302         check if an upgrade is needed (notmuch_database_needs_upgrade) and
303         if so, upgrade with this function before making any modifications.
304
305         NOT IMPLEMENTED: The optional progress_notify callback can be
306         used by the caller to provide progress indication to the
307         user. If non-NULL it will be called periodically with
308         'progress' as a floating-point value in the range of [0.0..1.0]
309         indicating the progress made so far in the upgrade process.
310
311         :TODO: catch exceptions, document return values and etc...
312         """
313         self._assert_db_is_initialized()
314         status = Database._upgrade(self._db, None, None)
315         #TODO: catch exceptions, document return values and etc
316         return status
317
318     _begin_atomic = nmlib.notmuch_database_begin_atomic
319     _begin_atomic.argtypes = [NotmuchDatabaseP]
320     _begin_atomic.restype = c_uint
321
322     def begin_atomic(self):
323         """Begin an atomic database operation
324
325         Any modifications performed between a successful
326         :meth:`begin_atomic` and a :meth:`end_atomic` will be applied to
327         the database atomically.  Note that, unlike a typical database
328         transaction, this only ensures atomicity, not durability;
329         neither begin nor end necessarily flush modifications to disk.
330
331         :returns: :attr:`STATUS`.SUCCESS or raises
332         :raises: :exc:`NotmuchError`: :attr:`STATUS`.XAPIAN_EXCEPTION
333                     Xapian exception occurred; atomic section not entered.
334
335         *Added in notmuch 0.9*"""
336         self._assert_db_is_initialized()
337         status = self._begin_atomic(self._db)
338         if status != STATUS.SUCCESS:
339             raise NotmuchError(status)
340         return status
341
342     _end_atomic = nmlib.notmuch_database_end_atomic
343     _end_atomic.argtypes = [NotmuchDatabaseP]
344     _end_atomic.restype = c_uint
345
346     def end_atomic(self):
347         """Indicate the end of an atomic database operation
348
349         See :meth:`begin_atomic` for details.
350
351         :returns: :attr:`STATUS`.SUCCESS or raises
352
353         :raises:
354             :exc:`NotmuchError`:
355                 :attr:`STATUS`.XAPIAN_EXCEPTION
356                     A Xapian exception occurred; atomic section not
357                     ended.
358                 :attr:`STATUS`.UNBALANCED_ATOMIC:
359                     end_atomic has been called more times than begin_atomic.
360
361         *Added in notmuch 0.9*"""
362         self._assert_db_is_initialized()
363         status = self._end_atomic(self._db)
364         if status != STATUS.SUCCESS:
365             raise NotmuchError(status)
366         return status
367
368     def get_directory(self, path):
369         """Returns a :class:`Directory` of path,
370
371         :param path: An unicode string containing the path relative to the path
372               of database (see :meth:`get_path`), or else should be an absolute
373               path with initial components that match the path of 'database'.
374         :returns: :class:`Directory` or raises an exception.
375         :raises: :exc:`FileError` if path is not relative database or absolute
376                  with initial components same as database.
377         """
378         self._assert_db_is_initialized()
379
380         # sanity checking if path is valid, and make path absolute
381         if path and path[0] == os.sep:
382             # we got an absolute path
383             if not path.startswith(self.get_path()):
384                 # but its initial components are not equal to the db path
385                 raise FileError('Database().get_directory() called '
386                                 'with a wrong absolute path')
387             abs_dirpath = path
388         else:
389             #we got a relative path, make it absolute
390             abs_dirpath = os.path.abspath(os.path.join(self.get_path(), path))
391
392         dir_p = NotmuchDirectoryP()
393         status = Database._get_directory(self._db, _str(path), byref(dir_p))
394
395         if status != STATUS.SUCCESS:
396             raise NotmuchError(status)
397         if not dir_p:
398             return None
399
400         # return the Directory, init it with the absolute path
401         return Directory(abs_dirpath, dir_p, self)
402
403     _index_file = nmlib.notmuch_database_index_file
404     _index_file.argtypes = [NotmuchDatabaseP, c_char_p,
405                              c_void_p,
406                              POINTER(NotmuchMessageP)]
407     _index_file.restype = c_uint
408
409     def index_file(self, filename, sync_maildir_flags=False):
410         """Adds a new message to the database
411
412         :param filename: should be a path relative to the path of the
413             open database (see :meth:`get_path`), or else should be an
414             absolute filename with initial components that match the
415             path of the database.
416
417             The file should be a single mail message (not a
418             multi-message mbox) that is expected to remain at its
419             current location, since the notmuch database will reference
420             the filename, and will not copy the entire contents of the
421             file.
422
423         :param sync_maildir_flags: If the message contains Maildir
424             flags, we will -depending on the notmuch configuration- sync
425             those tags to initial notmuch tags, if set to `True`. It is
426             `False` by default to remain consistent with the libnotmuch
427             API. You might want to look into the underlying method
428             :meth:`Message.maildir_flags_to_tags`.
429
430         :returns: On success, we return
431
432            1) a :class:`Message` object that can be used for things
433               such as adding tags to the just-added message.
434            2) one of the following :attr:`STATUS` values:
435
436               :attr:`STATUS`.SUCCESS
437                   Message successfully added to database.
438               :attr:`STATUS`.DUPLICATE_MESSAGE_ID
439                   Message has the same message ID as another message already
440                   in the database. The new filename was successfully added
441                   to the list of the filenames for the existing message.
442
443         :rtype:   2-tuple(:class:`Message`, :attr:`STATUS`)
444
445         :raises: Raises a :exc:`NotmuchError` with the following meaning.
446               If such an exception occurs, nothing was added to the database.
447
448               :attr:`STATUS`.FILE_ERROR
449                       An error occurred trying to open the file, (such as
450                       permission denied, or file not found, etc.).
451               :attr:`STATUS`.FILE_NOT_EMAIL
452                       The contents of filename don't look like an email
453                       message.
454               :attr:`STATUS`.READ_ONLY_DATABASE
455                       Database was opened in read-only mode so no message can
456                       be added.
457         """
458         self._assert_db_is_initialized()
459         msg_p = NotmuchMessageP()
460         status = self._index_file(self._db, _str(filename), c_void_p(None), byref(msg_p))
461
462         if not status in [STATUS.SUCCESS, STATUS.DUPLICATE_MESSAGE_ID]:
463             raise NotmuchError(status)
464
465         #construct Message() and return
466         msg = Message(msg_p, self)
467         #automatic sync initial tags from Maildir flags
468         if sync_maildir_flags:
469             msg.maildir_flags_to_tags()
470         return (msg, status)
471
472     def add_message(self, filename, sync_maildir_flags=False):
473         """Deprecated alias for :meth:`index_file`
474         """
475         warnings.warn(
476                 "This function is deprecated and will be removed in the future, use index_file.", DeprecationWarning)
477
478         return self.index_file(filename, sync_maildir_flags=sync_maildir_flags)
479
480     _remove_message = nmlib.notmuch_database_remove_message
481     _remove_message.argtypes = [NotmuchDatabaseP, c_char_p]
482     _remove_message.restype = c_uint
483
484     def remove_message(self, filename):
485         """Removes a message (filename) from the given notmuch database
486
487         Note that only this particular filename association is removed from
488         the database. If the same message (as determined by the message ID)
489         is still available via other filenames, then the message will
490         persist in the database for those filenames. When the last filename
491         is removed for a particular message, the database content for that
492         message will be entirely removed.
493
494         :returns: A :attr:`STATUS` value with the following meaning:
495
496              :attr:`STATUS`.SUCCESS
497                The last filename was removed and the message was removed
498                from the database.
499              :attr:`STATUS`.DUPLICATE_MESSAGE_ID
500                This filename was removed but the message persists in the
501                database with at least one other filename.
502
503         :raises: Raises a :exc:`NotmuchError` with the following meaning.
504              If such an exception occurs, nothing was removed from the
505              database.
506
507              :attr:`STATUS`.READ_ONLY_DATABASE
508                Database was opened in read-only mode so no message can be
509                removed.
510         """
511         self._assert_db_is_initialized()
512         status = self._remove_message(self._db, _str(filename))
513         if status not in [STATUS.SUCCESS, STATUS.DUPLICATE_MESSAGE_ID]:
514             raise NotmuchError(status)
515         return status
516
517     def find_message(self, msgid):
518         """Returns a :class:`Message` as identified by its message ID
519
520         Wraps the underlying *notmuch_database_find_message* function.
521
522         :param msgid: The message ID
523         :type msgid: unicode or str
524         :returns: :class:`Message` or `None` if no message is found.
525         :raises:
526             :exc:`OutOfMemoryError`
527                   If an Out-of-memory occured while constructing the message.
528             :exc:`XapianError`
529                   In case of a Xapian Exception. These exceptions
530                   include "Database modified" situations, e.g. when the
531                   notmuch database has been modified by another program
532                   in the meantime. In this case, you should close and
533                   reopen the database and retry.
534             :exc:`NotInitializedError` if
535                     the database was not intitialized.
536         """
537         self._assert_db_is_initialized()
538         msg_p = NotmuchMessageP()
539         status = Database._find_message(self._db, _str(msgid), byref(msg_p))
540         if status != STATUS.SUCCESS:
541             raise NotmuchError(status)
542         return msg_p and Message(msg_p, self) or None
543
544     def find_message_by_filename(self, filename):
545         """Find a message with the given filename
546
547         :returns: If the database contains a message with the given
548             filename, then a class:`Message:` is returned.  This
549             function returns None if no message is found with the given
550             filename.
551
552         :raises: :exc:`OutOfMemoryError` if an Out-of-memory occured while
553                  constructing the message.
554         :raises: :exc:`XapianError` in case of a Xapian Exception.
555                  These exceptions include "Database modified"
556                  situations, e.g. when the notmuch database has been
557                  modified by another program in the meantime. In this
558                  case, you should close and reopen the database and
559                  retry.
560         :raises: :exc:`NotInitializedError` if the database was not
561                  intitialized.
562
563         *Added in notmuch 0.9*"""
564         self._assert_db_is_initialized()
565
566         msg_p = NotmuchMessageP()
567         status = Database._find_message_by_filename(self._db, _str(filename),
568                                                     byref(msg_p))
569         if status != STATUS.SUCCESS:
570             raise NotmuchError(status)
571         return msg_p and Message(msg_p, self) or None
572
573     def get_all_tags(self):
574         """Returns :class:`Tags` with a list of all tags found in the database
575
576         :returns: :class:`Tags`
577         :execption: :exc:`NotmuchError` with :attr:`STATUS`.NULL_POINTER
578                     on error
579         """
580         self._assert_db_is_initialized()
581         tags_p = Database._get_all_tags(self._db)
582         if not tags_p:
583             raise NullPointerError()
584         return Tags(tags_p, self)
585
586     def create_query(self, querystring):
587         """Returns a :class:`Query` derived from this database
588
589         This is a shorthand method for doing::
590
591           # short version
592           # Automatically frees the Database() when 'q' is deleted
593
594           q  = Database(dbpath).create_query('from:"Biene Maja"')
595
596           # long version, which is functionally equivalent but will keep the
597           # Database in the 'db' variable around after we delete 'q':
598
599           db = Database(dbpath)
600           q  = Query(db,'from:"Biene Maja"')
601
602         This function is a python extension and not in the underlying C API.
603         """
604         return Query(self, querystring)
605
606     """notmuch_database_status_string"""
607     _status_string = nmlib.notmuch_database_status_string
608     _status_string.argtypes = [NotmuchDatabaseP]
609     _status_string.restype = c_char_p
610
611     def status_string(self):
612         """Returns the status string of the database
613
614         This is sometimes used for additional error reporting
615         """
616         self._assert_db_is_initialized()
617         s = Database._status_string(self._db)
618         if s:
619             return s.decode('utf-8', 'ignore')
620         return s
621
622     def __repr__(self):
623         return "'Notmuch DB " + self.get_path() + "'"
624
625     def _get_user_default_db(self):
626         """ Reads a user's notmuch config and returns his db location
627
628         Throws a NotmuchError if it cannot find it"""
629         config = SafeConfigParser()
630         conf_f = os.getenv('NOTMUCH_CONFIG',
631                            os.path.expanduser('~/.notmuch-config'))
632         config.readfp(codecs.open(conf_f, 'r', 'utf-8'))
633         if not config.has_option('database', 'path'):
634             raise NotmuchError(message="No DB path specified"
635                                        " and no user default found")
636         return config.get('database', 'path')