]> git.notmuchmail.org Git - notmuch/blob - bindings/python-cffi/notmuch2/_database.py
a15c4d0324576261c89a71d59de5edd217314b91
[notmuch] / bindings / python-cffi / notmuch2 / _database.py
1 import collections
2 import configparser
3 import enum
4 import functools
5 import os
6 import pathlib
7 import weakref
8
9 import notmuch2._base as base
10 import notmuch2._capi as capi
11 import notmuch2._errors as errors
12 import notmuch2._message as message
13 import notmuch2._query as querymod
14 import notmuch2._tags as tags
15
16
17 __all__ = ['Database', 'AtomicContext', 'DbRevision']
18
19
20 def _config_pathname():
21     """Return the path of the configuration file.
22
23     :rtype: pathlib.Path
24     """
25     cfgfname = os.getenv('NOTMUCH_CONFIG', '~/.notmuch-config')
26     return pathlib.Path(os.path.expanduser(cfgfname))
27
28
29 class Mode(enum.Enum):
30     READ_ONLY = capi.lib.NOTMUCH_DATABASE_MODE_READ_ONLY
31     READ_WRITE = capi.lib.NOTMUCH_DATABASE_MODE_READ_WRITE
32
33
34 class QuerySortOrder(enum.Enum):
35     OLDEST_FIRST = capi.lib.NOTMUCH_SORT_OLDEST_FIRST
36     NEWEST_FIRST = capi.lib.NOTMUCH_SORT_NEWEST_FIRST
37     MESSAGE_ID = capi.lib.NOTMUCH_SORT_MESSAGE_ID
38     UNSORTED = capi.lib.NOTMUCH_SORT_UNSORTED
39
40
41 class QueryExclude(enum.Enum):
42     TRUE = capi.lib.NOTMUCH_EXCLUDE_TRUE
43     FLAG = capi.lib.NOTMUCH_EXCLUDE_FLAG
44     FALSE = capi.lib.NOTMUCH_EXCLUDE_FALSE
45     ALL = capi.lib.NOTMUCH_EXCLUDE_ALL
46
47
48 class Database(base.NotmuchObject):
49     """Toplevel access to notmuch.
50
51     A :class:`Database` can be opened read-only or read-write.
52     Modifications are not atomic by default, use :meth:`begin_atomic`
53     for atomic updates.  If the underlying database has been modified
54     outside of this class a :exc:`XapianError` will be raised and the
55     instance must be closed and a new one created.
56
57     You can use an instance of this class as a context-manager.
58
59     :cvar MODE: The mode a database can be opened with, an enumeration
60        of ``READ_ONLY`` and ``READ_WRITE``
61     :cvar SORT: The sort order for search results, ``OLDEST_FIRST``,
62        ``NEWEST_FIRST``, ``MESSAGE_ID`` or ``UNSORTED``.
63     :cvar EXCLUDE: Which messages to exclude from queries, ``TRUE``,
64        ``FLAG``, ``FALSE`` or ``ALL``.  See the query documentation
65        for details.
66     :cvar AddedMessage: A namedtuple ``(msg, dup)`` used by
67        :meth:`add` as return value.
68     :cvar STR_MODE_MAP: A map mapping strings to :attr:`MODE` items.
69        This is used to implement the ``ro`` and ``rw`` string
70        variants.
71
72     :ivar closed: Boolean indicating if the database is closed or
73        still open.
74
75     :param path: The directory of where the database is stored.  If
76        ``None`` the location will be read from the user's
77        configuration file, respecting the ``NOTMUCH_CONFIG``
78        environment variable if set.
79     :type path: str, bytes, os.PathLike or pathlib.Path
80     :param mode: The mode to open the database in.  One of
81        :attr:`MODE.READ_ONLY` OR :attr:`MODE.READ_WRITE`.  For
82        convenience you can also use the strings ``ro`` for
83        :attr:`MODE.READ_ONLY` and ``rw`` for :attr:`MODE.READ_WRITE`.
84     :type mode: :attr:`MODE` or str.
85
86     :raises KeyError: if an unknown mode string is used.
87     :raises OSError: or subclasses if the configuration file can not
88        be opened.
89     :raises configparser.Error: or subclasses if the configuration
90        file can not be parsed.
91     :raises NotmuchError: or subclasses for other failures.
92     """
93
94     MODE = Mode
95     SORT = QuerySortOrder
96     EXCLUDE = QueryExclude
97     AddedMessage = collections.namedtuple('AddedMessage', ['msg', 'dup'])
98     _db_p = base.MemoryPointer()
99     STR_MODE_MAP = {
100         'ro': MODE.READ_ONLY,
101         'rw': MODE.READ_WRITE,
102     }
103
104     def __init__(self, path=None, mode=MODE.READ_ONLY):
105         if isinstance(mode, str):
106             mode = self.STR_MODE_MAP[mode]
107         self.mode = mode
108         if path is None:
109             path = self.default_path()
110         if not hasattr(os, 'PathLike') and isinstance(path, pathlib.Path):
111             path = bytes(path)
112         db_pp = capi.ffi.new('notmuch_database_t **')
113         cmsg = capi.ffi.new('char**')
114         ret = capi.lib.notmuch_database_open_verbose(os.fsencode(path),
115                                                      mode.value, db_pp, cmsg)
116         if cmsg[0]:
117             msg = capi.ffi.string(cmsg[0]).decode(errors='replace')
118             capi.lib.free(cmsg[0])
119         else:
120             msg = None
121         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
122             raise errors.NotmuchError(ret, msg)
123         self._db_p = db_pp[0]
124         self.closed = False
125
126     @classmethod
127     def create(cls, path=None):
128         """Create and open database in READ_WRITE mode.
129
130         This is creates a new notmuch database and returns an opened
131         instance in :attr:`MODE.READ_WRITE` mode.
132
133         :param path: The directory of where the database is stored.  If
134            ``None`` the location will be read from the user's
135            configuration file, respecting the ``NOTMUCH_CONFIG``
136            environment variable if set.
137         :type path: str, bytes or os.PathLike
138
139         :raises OSError: or subclasses if the configuration file can not
140            be opened.
141         :raises configparser.Error: or subclasses if the configuration
142            file can not be parsed.
143         :raises NotmuchError: if the config file does not have the
144            database.path setting.
145         :raises FileError: if the database already exists.
146
147         :returns: The newly created instance.
148         """
149         if path is None:
150             path = cls.default_path()
151         if not hasattr(os, 'PathLike') and isinstance(path, pathlib.Path):
152             path = bytes(path)
153         db_pp = capi.ffi.new('notmuch_database_t **')
154         cmsg = capi.ffi.new('char**')
155         ret = capi.lib.notmuch_database_create_verbose(os.fsencode(path),
156                                                        db_pp, cmsg)
157         if cmsg[0]:
158             msg = capi.ffi.string(cmsg[0]).decode(errors='replace')
159             capi.lib.free(cmsg[0])
160         else:
161             msg = None
162         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
163             raise errors.NotmuchError(ret, msg)
164
165         # Now close the db and let __init__ open it.  Inefficient but
166         # creating is not a hot loop while this allows us to have a
167         # clean API.
168         ret = capi.lib.notmuch_database_destroy(db_pp[0])
169         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
170             raise errors.NotmuchError(ret)
171         return cls(path, cls.MODE.READ_WRITE)
172
173     @staticmethod
174     def default_path(cfg_path=None):
175         """Return the path of the user's default database.
176
177         This reads the user's configuration file and returns the
178         default path of the database.
179
180         :param cfg_path: The pathname of the notmuch configuration file.
181            If not specified tries to use the pathname provided in the
182            :env:`NOTMUCH_CONFIG` environment variable and falls back
183            to :file:`~/.notmuch-config.
184         :type cfg_path: str, bytes, os.PathLike or pathlib.Path.
185
186         :returns: The path of the database, which does not necessarily
187            exists.
188         :rtype: pathlib.Path
189         :raises OSError: or subclasses if the configuration file can not
190            be opened.
191         :raises configparser.Error: or subclasses if the configuration
192            file can not be parsed.
193         :raises NotmuchError if the config file does not have the
194            database.path setting.
195         """
196         if not cfg_path:
197             cfg_path = _config_pathname()
198         if not hasattr(os, 'PathLike') and isinstance(cfg_path, pathlib.Path):
199             cfg_path = bytes(cfg_path)
200         parser = configparser.ConfigParser()
201         with open(cfg_path) as fp:
202             parser.read_file(fp)
203         try:
204             return pathlib.Path(parser.get('database', 'path'))
205         except configparser.Error:
206             raise errors.NotmuchError(
207                 'No database.path setting in {}'.format(cfg_path))
208
209     def __del__(self):
210         self._destroy()
211
212     @property
213     def alive(self):
214         try:
215             self._db_p
216         except errors.ObjectDestroyedError:
217             return False
218         else:
219             return True
220
221     def _destroy(self):
222         try:
223             ret = capi.lib.notmuch_database_destroy(self._db_p)
224         except errors.ObjectDestroyedError:
225             ret = capi.lib.NOTMUCH_STATUS_SUCCESS
226         else:
227             self._db_p = None
228         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
229             raise errors.NotmuchError(ret)
230
231     def close(self):
232         """Close the notmuch database.
233
234         Once closed most operations will fail.  This can still be
235         useful however to explicitly close a database which is opened
236         read-write as this would otherwise stop other processes from
237         reading the database while it is open.
238
239         :raises ObjectDestroyedError: if used after destroyed.
240         """
241         ret = capi.lib.notmuch_database_close(self._db_p)
242         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
243             raise errors.NotmuchError(ret)
244         self.closed = True
245
246     def __enter__(self):
247         return self
248
249     def __exit__(self, exc_type, exc_value, traceback):
250         self.close()
251
252     @property
253     def path(self):
254         """The pathname of the notmuch database.
255
256         This is returned as a :class:`pathlib.Path` instance.
257
258         :raises ObjectDestroyedError: if used after destoryed.
259         """
260         try:
261             return self._cache_path
262         except AttributeError:
263             ret = capi.lib.notmuch_database_get_path(self._db_p)
264             self._cache_path = pathlib.Path(os.fsdecode(capi.ffi.string(ret)))
265             return self._cache_path
266
267     @property
268     def version(self):
269         """The database format version.
270
271         This is a positive integer.
272
273         :raises ObjectDestroyedError: if used after destoryed.
274         """
275         try:
276             return self._cache_version
277         except AttributeError:
278             ret = capi.lib.notmuch_database_get_version(self._db_p)
279             self._cache_version = ret
280             return ret
281
282     @property
283     def needs_upgrade(self):
284         """Whether the database should be upgraded.
285
286         If *True* the database can be upgraded using :meth:`upgrade`.
287         Not doing so may result in some operations raising
288         :exc:`UpgradeRequiredError`.
289
290         A read-only database will never be upgradable.
291
292         :raises ObjectDestroyedError: if used after destoryed.
293         """
294         ret = capi.lib.notmuch_database_needs_upgrade(self._db_p)
295         return bool(ret)
296
297     def upgrade(self, progress_cb=None):
298         """Upgrade the database to the latest version.
299
300         Upgrade the database, optionally with a progress callback
301         which should be a callable which will be called with a
302         floating point number in the range of [0.0 .. 1.0].
303         """
304         raise NotImplementedError
305
306     def atomic(self):
307         """Return a context manager to perform atomic operations.
308
309         The returned context manager can be used to perform atomic
310         operations on the database.
311
312         .. note:: Unlinke a traditional RDBMS transaction this does
313            not imply durability, it only ensures the changes are
314            performed atomically.
315
316         :raises ObjectDestroyedError: if used after destoryed.
317         """
318         ctx = AtomicContext(self, '_db_p')
319         return ctx
320
321     def revision(self):
322         """The currently committed revision in the database.
323
324         Returned as a ``(revision, uuid)`` namedtuple.
325
326         :raises ObjectDestroyedError: if used after destoryed.
327         """
328         raw_uuid = capi.ffi.new('char**')
329         rev = capi.lib.notmuch_database_get_revision(self._db_p, raw_uuid)
330         return DbRevision(rev, capi.ffi.string(raw_uuid[0]))
331
332     def get_directory(self, path):
333         raise NotImplementedError
334
335     def add(self, filename, *, sync_flags=False):
336         """Add a message to the database.
337
338         Add a new message to the notmuch database.  The message is
339         referred to by the pathname of the maildir file.  If the
340         message ID of the new message already exists in the database,
341         this adds ``pathname`` to the list of list of files for the
342         existing message.
343
344         :param filename: The path of the file containing the message.
345         :type filename: str, bytes, os.PathLike or pathlib.Path.
346         :param sync_flags: Whether to sync the known maildir flags to
347            notmuch tags.  See :meth:`Message.flags_to_tags` for
348            details.
349
350         :returns: A tuple where the first item is the newly inserted
351            messages as a :class:`Message` instance, and the second
352            item is a boolean indicating if the message inserted was a
353            duplicate.  This is the namedtuple ``AddedMessage(msg,
354            dup)``.
355         :rtype: Database.AddedMessage
356
357         If an exception is raised, no message was added.
358
359         :raises XapianError: A Xapian exception occurred.
360         :raises FileError: The file referred to by ``pathname`` could
361            not be opened.
362         :raises FileNotEmailError: The file referreed to by
363            ``pathname`` is not recognised as an email message.
364         :raises ReadOnlyDatabaseError: The database is opened in
365            READ_ONLY mode.
366         :raises UpgradeRequiredError: The database must be upgraded
367            first.
368         :raises ObjectDestroyedError: if used after destoryed.
369         """
370         if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
371             filename = bytes(filename)
372         msg_pp = capi.ffi.new('notmuch_message_t **')
373         ret = capi.lib.notmuch_database_add_message(self._db_p,
374                                                     os.fsencode(filename),
375                                                     msg_pp)
376         ok = [capi.lib.NOTMUCH_STATUS_SUCCESS,
377               capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID]
378         if ret not in ok:
379             raise errors.NotmuchError(ret)
380         msg = message.Message(self, msg_pp[0], db=self)
381         if sync_flags:
382             msg.tags.from_maildir_flags()
383         return self.AddedMessage(
384             msg, ret == capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID)
385
386     def remove(self, filename):
387         """Remove a message from the notmuch database.
388
389         Removing a message which is not in the database is just a
390         silent nop-operation.
391
392         :param filename: The pathname of the file containing the
393            message to be removed.
394         :type filename: str, bytes, os.PathLike or pathlib.Path.
395
396         :returns: True if the message is still in the database.  This
397            can happen when multiple files contain the same message ID.
398            The true/false distinction is fairly arbitrary, but think
399            of it as ``dup = db.remove_message(name); if dup: ...``.
400         :rtype: bool
401
402         :raises XapianError: A Xapian exception ocurred.
403         :raises ReadOnlyDatabaseError: The database is opened in
404            READ_ONLY mode.
405         :raises UpgradeRequiredError: The database must be upgraded
406            first.
407         :raises ObjectDestroyedError: if used after destoryed.
408         """
409         if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
410             filename = bytes(filename)
411         ret = capi.lib.notmuch_database_remove_message(self._db_p,
412                                                        os.fsencode(filename))
413         ok = [capi.lib.NOTMUCH_STATUS_SUCCESS,
414               capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID]
415         if ret not in ok:
416             raise errors.NotmuchError(ret)
417         if ret == capi.lib.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID:
418             return True
419         else:
420             return False
421
422     def find(self, msgid):
423         """Return the message matching the given message ID.
424
425         If a message with the given message ID is found a
426         :class:`Message` instance is returned.  Otherwise a
427         :exc:`LookupError` is raised.
428
429         :param msgid: The message ID to look for.
430         :type msgid: str
431
432         :returns: The message instance.
433         :rtype: Message
434
435         :raises LookupError: If no message was found.
436         :raises OutOfMemoryError: When there is no memory to allocate
437            the message instance.
438         :raises XapianError: A Xapian exception ocurred.
439         :raises ObjectDestroyedError: if used after destoryed.
440         """
441         msg_pp = capi.ffi.new('notmuch_message_t **')
442         ret = capi.lib.notmuch_database_find_message(self._db_p,
443                                                      msgid.encode(), msg_pp)
444         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
445             raise errors.NotmuchError(ret)
446         msg_p = msg_pp[0]
447         if msg_p == capi.ffi.NULL:
448             raise LookupError
449         msg = message.Message(self, msg_p, db=self)
450         return msg
451
452     def get(self, filename):
453         """Return the :class:`Message` given a pathname.
454
455         If a message with the given pathname exists in the database
456         return the :class:`Message` instance for the message.
457         Otherwise raise a :exc:`LookupError` exception.
458
459         :param filename: The pathname of the message.
460         :type filename: str, bytes, os.PathLike or pathlib.Path
461
462         :returns: The message instance.
463         :rtype: Message
464
465         :raises LookupError: If no message was found.  This is also
466            a subclass of :exc:`KeyError`.
467         :raises OutOfMemoryError: When there is no memory to allocate
468            the message instance.
469         :raises XapianError: A Xapian exception ocurred.
470         :raises ObjectDestroyedError: if used after destoryed.
471         """
472         if not hasattr(os, 'PathLike') and isinstance(filename, pathlib.Path):
473             filename = bytes(filename)
474         msg_pp = capi.ffi.new('notmuch_message_t **')
475         ret = capi.lib.notmuch_database_find_message_by_filename(
476             self._db_p, os.fsencode(filename), msg_pp)
477         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
478             raise errors.NotmuchError(ret)
479         msg_p = msg_pp[0]
480         if msg_p == capi.ffi.NULL:
481             raise LookupError
482         msg = message.Message(self, msg_p, db=self)
483         return msg
484
485     @property
486     def tags(self):
487         """Return an immutable set with all tags used in this database.
488
489         This returns an immutable set-like object implementing the
490         collections.abc.Set Abstract Base Class.  Due to the
491         underlying libnotmuch implementation some operations have
492         different performance characteristics then plain set objects.
493         Mainly any lookup operation is O(n) rather then O(1).
494
495         Normal usage treats tags as UTF-8 encoded unicode strings so
496         they are exposed to Python as normal unicode string objects.
497         If you need to handle tags stored in libnotmuch which are not
498         valid unicode do check the :class:`ImmutableTagSet` docs for
499         how to handle this.
500
501         :rtype: ImmutableTagSet
502
503         :raises ObjectDestroyedError: if used after destoryed.
504         """
505         try:
506             ref = self._cached_tagset
507         except AttributeError:
508             tagset = None
509         else:
510             tagset = ref()
511         if tagset is None:
512             tagset = tags.ImmutableTagSet(
513                 self, '_db_p', capi.lib.notmuch_database_get_all_tags)
514             self._cached_tagset = weakref.ref(tagset)
515         return tagset
516
517     def _create_query(self, query, *,
518                       omit_excluded=EXCLUDE.TRUE,
519                       sort=SORT.UNSORTED,  # Check this default
520                       exclude_tags=None):
521         """Create an internal query object.
522
523         :raises OutOfMemoryError: if no memory is available to
524            allocate the query.
525         """
526         if isinstance(query, str):
527             query = query.encode('utf-8')
528         query_p = capi.lib.notmuch_query_create(self._db_p, query)
529         if query_p == capi.ffi.NULL:
530             raise errors.OutOfMemoryError()
531         capi.lib.notmuch_query_set_omit_excluded(query_p, omit_excluded.value)
532         capi.lib.notmuch_query_set_sort(query_p, sort.value)
533         if exclude_tags is not None:
534             for tag in exclude_tags:
535                 if isinstance(tag, str):
536                     tag = str.encode('utf-8')
537                 capi.lib.notmuch_query_add_tag_exclude(query_p, tag)
538         return querymod.Query(self, query_p)
539
540     def messages(self, query, *,
541                  omit_excluded=EXCLUDE.TRUE,
542                  sort=SORT.UNSORTED,  # Check this default
543                  exclude_tags=None):
544         """Search the database for messages.
545
546         :returns: An iterator over the messages found.
547         :rtype: MessageIter
548
549         :raises OutOfMemoryError: if no memory is available to
550            allocate the query.
551         :raises ObjectDestroyedError: if used after destoryed.
552         """
553         query = self._create_query(query,
554                                    omit_excluded=omit_excluded,
555                                    sort=sort,
556                                    exclude_tags=exclude_tags)
557         return query.messages()
558
559     def count_messages(self, query, *,
560                        omit_excluded=EXCLUDE.TRUE,
561                        sort=SORT.UNSORTED,  # Check this default
562                        exclude_tags=None):
563         """Search the database for messages.
564
565         :returns: An iterator over the messages found.
566         :rtype: MessageIter
567
568         :raises ObjectDestroyedError: if used after destoryed.
569         """
570         query = self._create_query(query,
571                                    omit_excluded=omit_excluded,
572                                    sort=sort,
573                                    exclude_tags=exclude_tags)
574         return query.count_messages()
575
576     def threads(self,  query, *,
577                 omit_excluded=EXCLUDE.TRUE,
578                 sort=SORT.UNSORTED,  # Check this default
579                 exclude_tags=None):
580         query = self._create_query(query,
581                                    omit_excluded=omit_excluded,
582                                    sort=sort,
583                                    exclude_tags=exclude_tags)
584         return query.threads()
585
586     def count_threads(self, query, *,
587                       omit_excluded=EXCLUDE.TRUE,
588                       sort=SORT.UNSORTED,  # Check this default
589                       exclude_tags=None):
590         query = self._create_query(query,
591                                    omit_excluded=omit_excluded,
592                                    sort=sort,
593                                    exclude_tags=exclude_tags)
594         return query.count_threads()
595
596     def status_string(self):
597         raise NotImplementedError
598
599     def __repr__(self):
600         return 'Database(path={self.path}, mode={self.mode})'.format(self=self)
601
602
603 class AtomicContext:
604     """Context manager for atomic support.
605
606     This supports the notmuch_database_begin_atomic and
607     notmuch_database_end_atomic API calls.  The object can not be
608     directly instantiated by the user, only via ``Database.atomic``.
609     It does keep a reference to the :class:`Database` instance to keep
610     the C memory alive.
611
612     :raises XapianError: When this is raised at enter time the atomic
613        section is not active.  When it is raised at exit time the
614        atomic section is still active and you may need to try using
615        :meth:`force_end`.
616     :raises ObjectDestroyedError: if used after destoryed.
617     """
618
619     def __init__(self, db, ptr_name):
620         self._db = db
621         self._ptr = lambda: getattr(db, ptr_name)
622
623     def __del__(self):
624         self._destroy()
625
626     @property
627     def alive(self):
628         return self.parent.alive
629
630     def _destroy(self):
631         pass
632
633     def __enter__(self):
634         ret = capi.lib.notmuch_database_begin_atomic(self._ptr())
635         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
636             raise errors.NotmuchError(ret)
637         return self
638
639     def __exit__(self, exc_type, exc_value, traceback):
640         ret = capi.lib.notmuch_database_end_atomic(self._ptr())
641         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
642             raise errors.NotmuchError(ret)
643
644     def force_end(self):
645         """Force ending the atomic section.
646
647         This can only be called once __exit__ has been called.  It
648         will attept to close the atomic section (again).  This is
649         useful if the original exit raised an exception and the atomic
650         section is still open.  But things are pretty ugly by now.
651
652         :raises XapianError: If exiting fails, the atomic section is
653            not ended.
654         :raises UnbalancedAtomicError: If the database was currently
655            not in an atomic section.
656         :raises ObjectDestroyedError: if used after destoryed.
657         """
658         ret = capi.lib.notmuch_database_end_atomic(self._ptr())
659         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
660             raise errors.NotmuchError(ret)
661
662
663 @functools.total_ordering
664 class DbRevision:
665     """A database revision.
666
667     The database revision number increases monotonically with each
668     commit to the database.  Which means user-visible changes can be
669     ordered.  This object is sortable with other revisions.  It
670     carries the UUID of the database to ensure it is only ever
671     compared with revisions from the same database.
672     """
673
674     def __init__(self, rev, uuid):
675         self._rev = rev
676         self._uuid = uuid
677
678     @property
679     def rev(self):
680         """The revision number, a positive integer."""
681         return self._rev
682
683     @property
684     def uuid(self):
685         """The UUID of the database, consider this opaque."""
686         return self._uuid
687
688     def __eq__(self, other):
689         if isinstance(other, self.__class__):
690             if self.uuid != other.uuid:
691                 return False
692             return self.rev == other.rev
693         else:
694             return NotImplemented
695
696     def __lt__(self, other):
697         if self.__class__ is other.__class__:
698             if self.uuid != other.uuid:
699                 return False
700             return self.rev < other.rev
701         else:
702             return NotImplemented
703
704     def __repr__(self):
705         return 'DbRevision(rev={self.rev}, uuid={self.uuid})'.format(self=self)