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