[PATCH v2 7/7] bindings/python-cffi: handle NOTMUCH_STATUS_OPERATION_INVALIDATED

Subject: [PATCH v2 7/7] bindings/python-cffi: handle NOTMUCH_STATUS_OPERATION_INVALIDATED

Date: Sun, 27 Jul 2025 16:11:50 +0200

To: notmuch@notmuchmail.org

Cc:

From: Anton Khirnov


Raise it as a newly added OperationInvalidatedError exception.
---
 bindings/python-cffi/notmuch2/_base.py      | 24 +++++-
 bindings/python-cffi/notmuch2/_build.py     |  6 ++
 bindings/python-cffi/notmuch2/_errors.py    |  3 +
 bindings/python-cffi/notmuch2/_message.py   |  3 +-
 bindings/python-cffi/notmuch2/_thread.py    |  3 +-
 bindings/python-cffi/tests/test_database.py | 84 +++++++++++++++++++++
 6 files changed, 118 insertions(+), 5 deletions(-)

diff --git a/bindings/python-cffi/notmuch2/_base.py b/bindings/python-cffi/notmuch2/_base.py
index 1b0b5b7e..3ce960f7 100644
--- a/bindings/python-cffi/notmuch2/_base.py
+++ b/bindings/python-cffi/notmuch2/_base.py
@@ -182,11 +182,16 @@ class NotmuchIter(NotmuchObject, collections.abc.Iterator):
     _iter_p = MemoryPointer()
 
     def __init__(self, parent, iter_p,
-                 *, fn_destroy, fn_valid, fn_get, fn_next):
+                 *, fn_destroy, fn_valid, fn_get, fn_next,
+                 fn_status = None):
+        # exactly one of those must be provided
+        assert(bool(fn_valid) != bool(fn_status))
+
         self._parent = parent
         self._iter_p = iter_p
         self._fn_destroy = fn_destroy
         self._fn_valid = fn_valid
+        self._fn_status = fn_status
         self._fn_get = fn_get
         self._fn_next = fn_next
 
@@ -212,6 +217,17 @@ class NotmuchIter(NotmuchObject, collections.abc.Iterator):
                 pass
         self._iter_p = None
 
+    def _check_status(self):
+        if self._fn_valid:
+            if not self._fn_valid(self._iter_p):
+                raise StopIteration
+        else:
+            status = self._fn_status(self._iter_p)
+            if status == capi.lib.NOTMUCH_STATUS_ITERATOR_EXHAUSTED:
+                raise StopIteration
+            elif status > 0:
+                raise errors.NotmuchError(status)
+
     def __iter__(self):
         """Return the iterator itself.
 
@@ -222,9 +238,11 @@ class NotmuchIter(NotmuchObject, collections.abc.Iterator):
         return self
 
     def __next__(self):
-        if not self._fn_valid(self._iter_p):
-            raise StopIteration()
+        self._check_status()
         obj_p = self._fn_get(self._iter_p)
+        if obj_p == capi.ffi.NULL:
+            self._check_status()
+            raise StopIteration
         self._fn_next(self._iter_p)
         return obj_p
 
diff --git a/bindings/python-cffi/notmuch2/_build.py b/bindings/python-cffi/notmuch2/_build.py
index 0429691a..2f3152c6 100644
--- a/bindings/python-cffi/notmuch2/_build.py
+++ b/bindings/python-cffi/notmuch2/_build.py
@@ -56,6 +56,8 @@ ffibuilder.cdef(
         NOTMUCH_STATUS_BAD_QUERY_SYNTAX,
         NOTMUCH_STATUS_NO_MAIL_ROOT,
         NOTMUCH_STATUS_CLOSED_DATABASE,
+        NOTMUCH_STATUS_ITERATOR_EXHAUSTED,
+        NOTMUCH_STATUS_OPERATION_INVALIDATED,
         NOTMUCH_STATUS_LAST_STATUS
     } notmuch_status_t;
     typedef enum {
@@ -187,6 +189,8 @@ ffibuilder.cdef(
 
     notmuch_bool_t
     notmuch_threads_valid (notmuch_threads_t *threads);
+    notmuch_status_t
+    notmuch_threads_status (notmuch_threads_t *threads);
     notmuch_thread_t *
     notmuch_threads_get (notmuch_threads_t *threads);
     void
@@ -221,6 +225,8 @@ ffibuilder.cdef(
 
     notmuch_bool_t
     notmuch_messages_valid (notmuch_messages_t *messages);
+    notmuch_status_t
+    notmuch_messages_status (notmuch_messages_t *messages);
     notmuch_message_t *
     notmuch_messages_get (notmuch_messages_t *messages);
     void
diff --git a/bindings/python-cffi/notmuch2/_errors.py b/bindings/python-cffi/notmuch2/_errors.py
index 17c3ad9c..483e794b 100644
--- a/bindings/python-cffi/notmuch2/_errors.py
+++ b/bindings/python-cffi/notmuch2/_errors.py
@@ -28,6 +28,8 @@ class NotmuchError(Exception):
                 ReadOnlyDatabaseError,
             capi.lib.NOTMUCH_STATUS_XAPIAN_EXCEPTION:
                 XapianError,
+            capi.lib.NOTMUCH_STATUS_OPERATION_INVALIDATED:
+                OperationInvalidatedError,
             capi.lib.NOTMUCH_STATUS_FILE_ERROR:
                 FileError,
             capi.lib.NOTMUCH_STATUS_FILE_NOT_EMAIL:
@@ -92,6 +94,7 @@ class NotmuchError(Exception):
 class OutOfMemoryError(NotmuchError): pass
 class ReadOnlyDatabaseError(NotmuchError): pass
 class XapianError(NotmuchError): pass
+class OperationInvalidatedError(XapianError): pass
 class FileError(NotmuchError): pass
 class FileNotEmailError(NotmuchError): pass
 class DuplicateMessageIdError(NotmuchError): pass
diff --git a/bindings/python-cffi/notmuch2/_message.py b/bindings/python-cffi/notmuch2/_message.py
index 79485238..e31e0c3c 100644
--- a/bindings/python-cffi/notmuch2/_message.py
+++ b/bindings/python-cffi/notmuch2/_message.py
@@ -707,7 +707,8 @@ class MessageIter(base.NotmuchIter):
         self._msg_cls = msg_cls
         super().__init__(parent, msgs_p,
                          fn_destroy=capi.lib.notmuch_messages_destroy,
-                         fn_valid=capi.lib.notmuch_messages_valid,
+                         fn_valid=None,
+                         fn_status=capi.lib.notmuch_messages_status,
                          fn_get=capi.lib.notmuch_messages_get,
                          fn_next=capi.lib.notmuch_messages_move_to_next)
 
diff --git a/bindings/python-cffi/notmuch2/_thread.py b/bindings/python-cffi/notmuch2/_thread.py
index e883f308..af49d2bf 100644
--- a/bindings/python-cffi/notmuch2/_thread.py
+++ b/bindings/python-cffi/notmuch2/_thread.py
@@ -185,7 +185,8 @@ class ThreadIter(base.NotmuchIter):
         self._db = db
         super().__init__(parent, threads_p,
                          fn_destroy=capi.lib.notmuch_threads_destroy,
-                         fn_valid=capi.lib.notmuch_threads_valid,
+                         fn_valid=None,
+                         fn_status=capi.lib.notmuch_threads_status,
                          fn_get=capi.lib.notmuch_threads_get,
                          fn_next=capi.lib.notmuch_threads_move_to_next)
 
diff --git a/bindings/python-cffi/tests/test_database.py b/bindings/python-cffi/tests/test_database.py
index 1557235d..c3b0c2e4 100644
--- a/bindings/python-cffi/tests/test_database.py
+++ b/bindings/python-cffi/tests/test_database.py
@@ -296,6 +296,41 @@ class TestQuery:
         with dbmod.Database(maildir.path, 'rw', config=notmuch2.Database.CONFIG.EMPTY) as db:
             yield db
 
+    def _db_modified(self, maildir, notmuch, ret_prepare=None):
+        # populate the database for the initial query
+        with dbmod.Database.create(maildir.path, config=notmuch2.Database.CONFIG.EMPTY) as db:
+            for i in range(32):
+                pathname = maildir.deliver(body = str(i))[1]
+                msg = db.add(str(pathname))[0]
+                msg.tags.add(str(i))
+
+        with dbmod.Database(maildir.path, 'ro', config=notmuch2.Database.CONFIG.EMPTY) as db:
+            # prepare value to be returned to caller
+            ret = ret_prepare(db) if ret_prepare else db
+
+            # modify the database sufficiently to trigger DatabaseModifiedException
+            for i in range(16):
+                with dbmod.Database(maildir.path, 'rw', config=notmuch2.Database.CONFIG.EMPTY) as db_rw:
+                    pathname = maildir.deliver(body = str(i))[1]
+                    db_rw.add(str(pathname))
+
+            yield ret
+
+    @pytest.fixture
+    def db_modified(self, maildir, notmuch):
+        "A db triggering DatabaseModifiedException."
+        yield from self._db_modified(maildir, notmuch)
+
+    @pytest.fixture
+    def db_modified_messages(self, maildir, notmuch):
+        "A tuple of (db, messages) triggering DatabaseModifiedException."
+        yield from self._db_modified(maildir, notmuch, lambda db: (db, db.messages('*')))
+
+    @pytest.fixture
+    def db_modified_threads(self, maildir, notmuch):
+        "A tuple of (db, threads) triggering DatabaseModifiedException."
+        yield from self._db_modified(maildir, notmuch, lambda db: (db, db.threads('*')))
+
     def test_count_messages(self, db):
         assert db.count_messages('*') == 3
 
@@ -371,3 +406,52 @@ class TestQuery:
             assert isinstance(msg, notmuch2.Message)
             assert msg.alive
             del msg
+
+    def test_operation_invalidated_query(self, db_modified):
+        # Test OperationInvalidatedError raised by instantiating the query.
+        for attempt in 1, 2:
+            try:
+                for msg in db_modified.messages('*'):
+                    pass
+                break
+            except notmuch2.OperationInvalidatedError:
+                if attempt == 1:
+                    db_modified.reopen()
+                    continue
+
+                raise
+
+    def test_operation_invalidated_messages(self, db_modified_messages):
+        # Test OperationInvalidatedError raised by iterating over query results;
+        # the query itself is created while the database is still usable.
+        db, messages = db_modified_messages
+
+        for attempt in 1, 2:
+            try:
+                for msg in messages:
+                    pass
+                break
+            except notmuch2.OperationInvalidatedError:
+                if attempt == 1:
+                    db.reopen()
+                    messages = db.messages('*')
+                    continue
+
+                raise
+
+    def test_operation_invalidated_threads(self, db_modified_threads):
+        db, threads = db_modified_threads
+
+        for attempt in 1, 2:
+            try:
+                for t in threads:
+                    for msg in t:
+                        pass
+                break
+            except notmuch2.OperationInvalidatedError:
+                if attempt == 1:
+                    db.reopen()
+                    threads = db.threads('*')
+                    continue
+
+                raise
-- 
2.39.5

_______________________________________________
notmuch mailing list -- notmuch@notmuchmail.org
To unsubscribe send an email to notmuch-leave@notmuchmail.org

Thread: