Skip to content

Commit d7aed41

Browse files
lelitberkerpeksag
authored andcommitted
bpo-27645: Add support for native backup facility of SQLite (GH-4238)
1 parent c10b288 commit d7aed41

File tree

7 files changed

+369
-2
lines changed

7 files changed

+369
-2
lines changed

Doc/library/sqlite3.rst

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,56 @@ Connection Objects
532532
f.write('%s\n' % line)
533533

534534

535+
.. method:: backup(target, *, pages=0, progress=None, name="main", sleep=0.250)
536+
537+
This method makes a backup of a SQLite database even while it's being accessed
538+
by other clients, or concurrently by the same connection. The copy will be
539+
written into the mandatory argument *target*, that must be another
540+
:class:`Connection` instance.
541+
542+
By default, or when *pages* is either ``0`` or a negative integer, the entire
543+
database is copied in a single step; otherwise the method performs a loop
544+
copying up to *pages* pages at a time.
545+
546+
If *progress* is specified, it must either be ``None`` or a callable object that
547+
will be executed at each iteration with three integer arguments, respectively
548+
the *status* of the last iteration, the *remaining* number of pages still to be
549+
copied and the *total* number of pages.
550+
551+
The *name* argument specifies the database name that will be copied: it must be
552+
a string containing either ``"main"``, the default, to indicate the main
553+
database, ``"temp"`` to indicate the temporary database or the name specified
554+
after the ``AS`` keyword in an ``ATTACH DATABASE`` statement for an attached
555+
database.
556+
557+
The *sleep* argument specifies the number of seconds to sleep by between
558+
successive attempts to backup remaining pages, can be specified either as an
559+
integer or a floating point value.
560+
561+
Example 1, copy an existing database into another::
562+
563+
import sqlite3
564+
565+
def progress(status, remaining, total):
566+
print(f'Copied {total-remaining} of {total} pages...')
567+
568+
con = sqlite3.connect('existing_db.db')
569+
with sqlite3.connect('backup.db') as bck:
570+
con.backup(bck, pages=1, progress=progress)
571+
572+
Example 2, copy an existing database into a transient copy::
573+
574+
import sqlite3
575+
576+
source = sqlite3.connect('existing_db.db')
577+
dest = sqlite3.connect(':memory:')
578+
source.backup(dest)
579+
580+
Availability: SQLite 3.6.11 or higher
581+
582+
.. versionadded:: 3.7
583+
584+
535585
.. _sqlite3-cursor-objects:
536586

537587
Cursor Objects

Doc/whatsnew/3.7.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,15 @@ can be set within the scope of a group.
630630
``'^$'`` or ``(?=-)`` that matches an empty string.
631631
(Contributed by Serhiy Storchaka in :issue:`25054`.)
632632

633+
634+
sqlite3
635+
-------
636+
637+
:class:`sqlite3.Connection` now exposes a :class:`~sqlite3.Connection.backup`
638+
method, if the underlying SQLite library is at version 3.6.11 or higher.
639+
(Contributed by Lele Gaifax in :issue:`27645`.)
640+
641+
633642
ssl
634643
---
635644

Lib/sqlite3/test/backup.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import sqlite3 as sqlite
2+
import unittest
3+
4+
5+
@unittest.skipIf(sqlite.sqlite_version_info < (3, 6, 11), "Backup API not supported")
6+
class BackupTests(unittest.TestCase):
7+
def setUp(self):
8+
cx = self.cx = sqlite.connect(":memory:")
9+
cx.execute('CREATE TABLE foo (key INTEGER)')
10+
cx.executemany('INSERT INTO foo (key) VALUES (?)', [(3,), (4,)])
11+
cx.commit()
12+
13+
def tearDown(self):
14+
self.cx.close()
15+
16+
def verify_backup(self, bckcx):
17+
result = bckcx.execute("SELECT key FROM foo ORDER BY key").fetchall()
18+
self.assertEqual(result[0][0], 3)
19+
self.assertEqual(result[1][0], 4)
20+
21+
def test_bad_target_none(self):
22+
with self.assertRaises(TypeError):
23+
self.cx.backup(None)
24+
25+
def test_bad_target_filename(self):
26+
with self.assertRaises(TypeError):
27+
self.cx.backup('some_file_name.db')
28+
29+
def test_bad_target_same_connection(self):
30+
with self.assertRaises(ValueError):
31+
self.cx.backup(self.cx)
32+
33+
def test_bad_target_closed_connection(self):
34+
bck = sqlite.connect(':memory:')
35+
bck.close()
36+
with self.assertRaises(sqlite.ProgrammingError):
37+
self.cx.backup(bck)
38+
39+
def test_bad_target_in_transaction(self):
40+
bck = sqlite.connect(':memory:')
41+
bck.execute('CREATE TABLE bar (key INTEGER)')
42+
bck.executemany('INSERT INTO bar (key) VALUES (?)', [(3,), (4,)])
43+
with self.assertRaises(sqlite.OperationalError) as cm:
44+
self.cx.backup(bck)
45+
if sqlite.sqlite_version_info < (3, 8, 7):
46+
self.assertEqual(str(cm.exception), 'target is in transaction')
47+
48+
def test_keyword_only_args(self):
49+
with self.assertRaises(TypeError):
50+
with sqlite.connect(':memory:') as bck:
51+
self.cx.backup(bck, 1)
52+
53+
def test_simple(self):
54+
with sqlite.connect(':memory:') as bck:
55+
self.cx.backup(bck)
56+
self.verify_backup(bck)
57+
58+
def test_progress(self):
59+
journal = []
60+
61+
def progress(status, remaining, total):
62+
journal.append(status)
63+
64+
with sqlite.connect(':memory:') as bck:
65+
self.cx.backup(bck, pages=1, progress=progress)
66+
self.verify_backup(bck)
67+
68+
self.assertEqual(len(journal), 2)
69+
self.assertEqual(journal[0], sqlite.SQLITE_OK)
70+
self.assertEqual(journal[1], sqlite.SQLITE_DONE)
71+
72+
def test_progress_all_pages_at_once_1(self):
73+
journal = []
74+
75+
def progress(status, remaining, total):
76+
journal.append(remaining)
77+
78+
with sqlite.connect(':memory:') as bck:
79+
self.cx.backup(bck, progress=progress)
80+
self.verify_backup(bck)
81+
82+
self.assertEqual(len(journal), 1)
83+
self.assertEqual(journal[0], 0)
84+
85+
def test_progress_all_pages_at_once_2(self):
86+
journal = []
87+
88+
def progress(status, remaining, total):
89+
journal.append(remaining)
90+
91+
with sqlite.connect(':memory:') as bck:
92+
self.cx.backup(bck, pages=-1, progress=progress)
93+
self.verify_backup(bck)
94+
95+
self.assertEqual(len(journal), 1)
96+
self.assertEqual(journal[0], 0)
97+
98+
def test_non_callable_progress(self):
99+
with self.assertRaises(TypeError) as cm:
100+
with sqlite.connect(':memory:') as bck:
101+
self.cx.backup(bck, pages=1, progress='bar')
102+
self.assertEqual(str(cm.exception), 'progress argument must be a callable')
103+
104+
def test_modifying_progress(self):
105+
journal = []
106+
107+
def progress(status, remaining, total):
108+
if not journal:
109+
self.cx.execute('INSERT INTO foo (key) VALUES (?)', (remaining+1000,))
110+
self.cx.commit()
111+
journal.append(remaining)
112+
113+
with sqlite.connect(':memory:') as bck:
114+
self.cx.backup(bck, pages=1, progress=progress)
115+
self.verify_backup(bck)
116+
117+
result = bck.execute("SELECT key FROM foo"
118+
" WHERE key >= 1000"
119+
" ORDER BY key").fetchall()
120+
self.assertEqual(result[0][0], 1001)
121+
122+
self.assertEqual(len(journal), 3)
123+
self.assertEqual(journal[0], 1)
124+
self.assertEqual(journal[1], 1)
125+
self.assertEqual(journal[2], 0)
126+
127+
def test_failing_progress(self):
128+
def progress(status, remaining, total):
129+
raise SystemError('nearly out of space')
130+
131+
with self.assertRaises(SystemError) as err:
132+
with sqlite.connect(':memory:') as bck:
133+
self.cx.backup(bck, progress=progress)
134+
self.assertEqual(str(err.exception), 'nearly out of space')
135+
136+
def test_database_source_name(self):
137+
with sqlite.connect(':memory:') as bck:
138+
self.cx.backup(bck, name='main')
139+
with sqlite.connect(':memory:') as bck:
140+
self.cx.backup(bck, name='temp')
141+
with self.assertRaises(sqlite.OperationalError) as cm:
142+
with sqlite.connect(':memory:') as bck:
143+
self.cx.backup(bck, name='non-existing')
144+
self.assertIn(
145+
str(cm.exception),
146+
['SQL logic error', 'SQL logic error or missing database']
147+
)
148+
149+
self.cx.execute("ATTACH DATABASE ':memory:' AS attached_db")
150+
self.cx.execute('CREATE TABLE attached_db.foo (key INTEGER)')
151+
self.cx.executemany('INSERT INTO attached_db.foo (key) VALUES (?)', [(3,), (4,)])
152+
self.cx.commit()
153+
with sqlite.connect(':memory:') as bck:
154+
self.cx.backup(bck, name='attached_db')
155+
self.verify_backup(bck)
156+
157+
158+
def suite():
159+
return unittest.makeSuite(BackupTests)
160+
161+
if __name__ == "__main__":
162+
unittest.main()

Lib/test/test_sqlite.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sqlite3
88
from sqlite3.test import (dbapi, types, userfunctions,
99
factory, transactions, hooks, regression,
10-
dump)
10+
dump, backup)
1111

1212
def load_tests(*args):
1313
if test.support.verbose:
@@ -18,7 +18,8 @@ def load_tests(*args):
1818
userfunctions.suite(),
1919
factory.suite(), transactions.suite(),
2020
hooks.suite(), regression.suite(),
21-
dump.suite()])
21+
dump.suite(),
22+
backup.suite()])
2223

2324
if __name__ == "__main__":
2425
unittest.main()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:class:`sqlite3.Connection` now exposes a :class:`~sqlite3.Connection.backup`
2+
method, if the underlying SQLite library is at version 3.6.11
3+
or higher. Patch by Lele Gaifax.

0 commit comments

Comments
 (0)