Skip to content

[3.7] bpo-27645: Add support for native backup facility of SQLite (GH-4238) #6064

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Doc/library/sqlite3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,56 @@ Connection Objects
f.write('%s\n' % line)


.. method:: backup(target, *, pages=0, progress=None, name="main", sleep=0.250)

This method makes a backup of a SQLite database even while it's being accessed
by other clients, or concurrently by the same connection. The copy will be
written into the mandatory argument *target*, that must be another
:class:`Connection` instance.

By default, or when *pages* is either ``0`` or a negative integer, the entire
database is copied in a single step; otherwise the method performs a loop
copying up to *pages* pages at a time.

If *progress* is specified, it must either be ``None`` or a callable object that
will be executed at each iteration with three integer arguments, respectively
the *status* of the last iteration, the *remaining* number of pages still to be
copied and the *total* number of pages.

The *name* argument specifies the database name that will be copied: it must be
a string containing either ``"main"``, the default, to indicate the main
database, ``"temp"`` to indicate the temporary database or the name specified
after the ``AS`` keyword in an ``ATTACH DATABASE`` statement for an attached
database.

The *sleep* argument specifies the number of seconds to sleep by between
successive attempts to backup remaining pages, can be specified either as an
integer or a floating point value.

Example 1, copy an existing database into another::

import sqlite3

def progress(status, remaining, total):
print(f'Copied {total-remaining} of {total} pages...')

con = sqlite3.connect('existing_db.db')
with sqlite3.connect('backup.db') as bck:
con.backup(bck, pages=1, progress=progress)

Example 2, copy an existing database into a transient copy::

import sqlite3

source = sqlite3.connect('existing_db.db')
dest = sqlite3.connect(':memory:')
source.backup(dest)

Availability: SQLite 3.6.11 or higher

.. versionadded:: 3.7


.. _sqlite3-cursor-objects:

Cursor Objects
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.7.rst
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,15 @@ can be set within the scope of a group.
``'^$'`` or ``(?=-)`` that matches an empty string.
(Contributed by Serhiy Storchaka in :issue:`25054`.)


sqlite3
-------

:class:`sqlite3.Connection` now exposes a :class:`~sqlite3.Connection.backup`
method, if the underlying SQLite library is at version 3.6.11 or higher.
(Contributed by Lele Gaifax in :issue:`27645`.)


ssl
---

Expand Down
162 changes: 162 additions & 0 deletions Lib/sqlite3/test/backup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import sqlite3 as sqlite
import unittest


@unittest.skipIf(sqlite.sqlite_version_info < (3, 6, 11), "Backup API not supported")
class BackupTests(unittest.TestCase):
def setUp(self):
cx = self.cx = sqlite.connect(":memory:")
cx.execute('CREATE TABLE foo (key INTEGER)')
cx.executemany('INSERT INTO foo (key) VALUES (?)', [(3,), (4,)])
cx.commit()

def tearDown(self):
self.cx.close()

def verify_backup(self, bckcx):
result = bckcx.execute("SELECT key FROM foo ORDER BY key").fetchall()
self.assertEqual(result[0][0], 3)
self.assertEqual(result[1][0], 4)

def test_bad_target_none(self):
with self.assertRaises(TypeError):
self.cx.backup(None)

def test_bad_target_filename(self):
with self.assertRaises(TypeError):
self.cx.backup('some_file_name.db')

def test_bad_target_same_connection(self):
with self.assertRaises(ValueError):
self.cx.backup(self.cx)

def test_bad_target_closed_connection(self):
bck = sqlite.connect(':memory:')
bck.close()
with self.assertRaises(sqlite.ProgrammingError):
self.cx.backup(bck)

def test_bad_target_in_transaction(self):
bck = sqlite.connect(':memory:')
bck.execute('CREATE TABLE bar (key INTEGER)')
bck.executemany('INSERT INTO bar (key) VALUES (?)', [(3,), (4,)])
with self.assertRaises(sqlite.OperationalError) as cm:
self.cx.backup(bck)
if sqlite.sqlite_version_info < (3, 8, 7):
self.assertEqual(str(cm.exception), 'target is in transaction')

def test_keyword_only_args(self):
with self.assertRaises(TypeError):
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, 1)

def test_simple(self):
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck)
self.verify_backup(bck)

def test_progress(self):
journal = []

def progress(status, remaining, total):
journal.append(status)

with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, pages=1, progress=progress)
self.verify_backup(bck)

self.assertEqual(len(journal), 2)
self.assertEqual(journal[0], sqlite.SQLITE_OK)
self.assertEqual(journal[1], sqlite.SQLITE_DONE)

def test_progress_all_pages_at_once_1(self):
journal = []

def progress(status, remaining, total):
journal.append(remaining)

with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, progress=progress)
self.verify_backup(bck)

self.assertEqual(len(journal), 1)
self.assertEqual(journal[0], 0)

def test_progress_all_pages_at_once_2(self):
journal = []

def progress(status, remaining, total):
journal.append(remaining)

with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, pages=-1, progress=progress)
self.verify_backup(bck)

self.assertEqual(len(journal), 1)
self.assertEqual(journal[0], 0)

def test_non_callable_progress(self):
with self.assertRaises(TypeError) as cm:
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, pages=1, progress='bar')
self.assertEqual(str(cm.exception), 'progress argument must be a callable')

def test_modifying_progress(self):
journal = []

def progress(status, remaining, total):
if not journal:
self.cx.execute('INSERT INTO foo (key) VALUES (?)', (remaining+1000,))
self.cx.commit()
journal.append(remaining)

with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, pages=1, progress=progress)
self.verify_backup(bck)

result = bck.execute("SELECT key FROM foo"
" WHERE key >= 1000"
" ORDER BY key").fetchall()
self.assertEqual(result[0][0], 1001)

self.assertEqual(len(journal), 3)
self.assertEqual(journal[0], 1)
self.assertEqual(journal[1], 1)
self.assertEqual(journal[2], 0)

def test_failing_progress(self):
def progress(status, remaining, total):
raise SystemError('nearly out of space')

with self.assertRaises(SystemError) as err:
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, progress=progress)
self.assertEqual(str(err.exception), 'nearly out of space')

def test_database_source_name(self):
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, name='main')
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, name='temp')
with self.assertRaises(sqlite.OperationalError) as cm:
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, name='non-existing')
self.assertIn(
str(cm.exception),
['SQL logic error', 'SQL logic error or missing database']
)

self.cx.execute("ATTACH DATABASE ':memory:' AS attached_db")
self.cx.execute('CREATE TABLE attached_db.foo (key INTEGER)')
self.cx.executemany('INSERT INTO attached_db.foo (key) VALUES (?)', [(3,), (4,)])
self.cx.commit()
with sqlite.connect(':memory:') as bck:
self.cx.backup(bck, name='attached_db')
self.verify_backup(bck)


def suite():
return unittest.makeSuite(BackupTests)

if __name__ == "__main__":
unittest.main()
5 changes: 3 additions & 2 deletions Lib/test/test_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sqlite3
from sqlite3.test import (dbapi, types, userfunctions,
factory, transactions, hooks, regression,
dump)
dump, backup)

def load_tests(*args):
if test.support.verbose:
Expand All @@ -18,7 +18,8 @@ def load_tests(*args):
userfunctions.suite(),
factory.suite(), transactions.suite(),
hooks.suite(), regression.suite(),
dump.suite()])
dump.suite(),
backup.suite()])

if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:class:`sqlite3.Connection` now exposes a :class:`~sqlite3.Connection.backup`
method, if the underlying SQLite library is at version 3.6.11
or higher. Patch by Lele Gaifax.
Loading