Skip to content

Commit bc7c7cd

Browse files
gh-77617: Add sqlite3 command-line interface (#95026)
Co-authored-by: Serhiy Storchaka <[email protected]>
1 parent 1e6b635 commit bc7c7cd

File tree

5 files changed

+281
-0
lines changed

5 files changed

+281
-0
lines changed

Doc/library/sqlite3.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,6 +1442,26 @@ and you can let the ``sqlite3`` module convert SQLite types to
14421442
Python types via :ref:`converters <sqlite3-converters>`.
14431443

14441444

1445+
.. _sqlite3-cli:
1446+
1447+
Command-line interface
1448+
^^^^^^^^^^^^^^^^^^^^^^
1449+
1450+
The ``sqlite3`` module can be invoked as a script
1451+
in order to provide a simple SQLite shell.
1452+
Type ``.quit`` or CTRL-D to exit the shell.
1453+
1454+
.. program:: python -m sqlite3 [-h] [-v] [filename] [sql]
1455+
1456+
.. option:: -h, --help
1457+
Print CLI help.
1458+
1459+
.. option:: -v, --version
1460+
Print underlying SQLite library version.
1461+
1462+
.. versionadded:: 3.12
1463+
1464+
14451465
.. _sqlite3-howtos:
14461466

14471467
How-to guides

Doc/whatsnew/3.12.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ os
112112
(Contributed by Kumar Aditya in :gh:`93312`.)
113113

114114

115+
sqlite3
116+
-------
117+
118+
* Add a :ref:`command-line interface <sqlite3-cli>`.
119+
(Contributed by Erlend E. Aasland in :gh:`77617`.)
120+
121+
115122
Optimizations
116123
=============
117124

Lib/sqlite3/__main__.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import sqlite3
2+
import sys
3+
4+
from argparse import ArgumentParser
5+
from code import InteractiveConsole
6+
from textwrap import dedent
7+
8+
9+
def execute(c, sql, suppress_errors=True):
10+
try:
11+
for row in c.execute(sql):
12+
print(row)
13+
except sqlite3.Error as e:
14+
tp = type(e).__name__
15+
try:
16+
print(f"{tp} ({e.sqlite_errorname}): {e}", file=sys.stderr)
17+
except AttributeError:
18+
print(f"{tp}: {e}", file=sys.stderr)
19+
if not suppress_errors:
20+
sys.exit(1)
21+
22+
23+
class SqliteInteractiveConsole(InteractiveConsole):
24+
25+
def __init__(self, connection):
26+
super().__init__()
27+
self._con = connection
28+
self._cur = connection.cursor()
29+
30+
def runsource(self, source, filename="<input>", symbol="single"):
31+
match source:
32+
case ".version":
33+
print(f"{sqlite3.sqlite_version}")
34+
case ".help":
35+
print("Enter SQL code and press enter.")
36+
case ".quit":
37+
sys.exit(0)
38+
case _:
39+
if not sqlite3.complete_statement(source):
40+
return True
41+
execute(self._cur, source)
42+
return False
43+
44+
45+
def main():
46+
parser = ArgumentParser(
47+
description="Python sqlite3 CLI",
48+
prog="python -m sqlite3",
49+
)
50+
parser.add_argument(
51+
"filename", type=str, default=":memory:", nargs="?",
52+
help=(
53+
"SQLite database to open (defaults to ':memory:'). "
54+
"A new database is created if the file does not previously exist."
55+
),
56+
)
57+
parser.add_argument(
58+
"sql", type=str, nargs="?",
59+
help=(
60+
"An SQL query to execute. "
61+
"Any returned rows are printed to stdout."
62+
),
63+
)
64+
parser.add_argument(
65+
"-v", "--version", action="version",
66+
version=f"SQLite version {sqlite3.sqlite_version}",
67+
help="Print underlying SQLite library version",
68+
)
69+
args = parser.parse_args()
70+
71+
if args.filename == ":memory:":
72+
db_name = "a transient in-memory database"
73+
else:
74+
db_name = repr(args.filename)
75+
76+
banner = dedent(f"""
77+
sqlite3 shell, running on SQLite version {sqlite3.sqlite_version}
78+
Connected to {db_name}
79+
80+
Each command will be run using execute() on the cursor.
81+
Type ".help" for more information; type ".quit" or CTRL-D to quit.
82+
""").strip()
83+
sys.ps1 = "sqlite> "
84+
sys.ps2 = " ... "
85+
86+
con = sqlite3.connect(args.filename, isolation_level=None)
87+
try:
88+
if args.sql:
89+
execute(con, args.sql, suppress_errors=False)
90+
else:
91+
console = SqliteInteractiveConsole(con)
92+
console.interact(banner, exitmsg="")
93+
finally:
94+
con.close()
95+
96+
97+
main()

Lib/test/test_sqlite3/test_cli.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""sqlite3 CLI tests."""
2+
3+
import sqlite3 as sqlite
4+
import subprocess
5+
import sys
6+
import unittest
7+
8+
from test.support import SHORT_TIMEOUT, requires_subprocess
9+
from test.support.os_helper import TESTFN, unlink
10+
11+
12+
@requires_subprocess()
13+
class CommandLineInterface(unittest.TestCase):
14+
15+
def _do_test(self, *args, expect_success=True):
16+
with subprocess.Popen(
17+
[sys.executable, "-Xutf8", "-m", "sqlite3", *args],
18+
encoding="utf-8",
19+
bufsize=0,
20+
stdout=subprocess.PIPE,
21+
stderr=subprocess.PIPE,
22+
) as proc:
23+
proc.wait()
24+
if expect_success == bool(proc.returncode):
25+
self.fail("".join(proc.stderr))
26+
stdout = proc.stdout.read()
27+
stderr = proc.stderr.read()
28+
if expect_success:
29+
self.assertEqual(stderr, "")
30+
else:
31+
self.assertEqual(stdout, "")
32+
return stdout, stderr
33+
34+
def expect_success(self, *args):
35+
out, _ = self._do_test(*args)
36+
return out
37+
38+
def expect_failure(self, *args):
39+
_, err = self._do_test(*args, expect_success=False)
40+
return err
41+
42+
def test_cli_help(self):
43+
out = self.expect_success("-h")
44+
self.assertIn("usage: python -m sqlite3", out)
45+
46+
def test_cli_version(self):
47+
out = self.expect_success("-v")
48+
self.assertIn(sqlite.sqlite_version, out)
49+
50+
def test_cli_execute_sql(self):
51+
out = self.expect_success(":memory:", "select 1")
52+
self.assertIn("(1,)", out)
53+
54+
def test_cli_execute_too_much_sql(self):
55+
stderr = self.expect_failure(":memory:", "select 1; select 2")
56+
err = "ProgrammingError: You can only execute one statement at a time"
57+
self.assertIn(err, stderr)
58+
59+
def test_cli_execute_incomplete_sql(self):
60+
stderr = self.expect_failure(":memory:", "sel")
61+
self.assertIn("OperationalError (SQLITE_ERROR)", stderr)
62+
63+
def test_cli_on_disk_db(self):
64+
self.addCleanup(unlink, TESTFN)
65+
out = self.expect_success(TESTFN, "create table t(t)")
66+
self.assertEqual(out, "")
67+
out = self.expect_success(TESTFN, "select count(t) from t")
68+
self.assertIn("(0,)", out)
69+
70+
71+
@requires_subprocess()
72+
class InteractiveSession(unittest.TestCase):
73+
TIMEOUT = SHORT_TIMEOUT / 10.
74+
MEMORY_DB_MSG = "Connected to a transient in-memory database"
75+
PS1 = "sqlite> "
76+
PS2 = "... "
77+
78+
def start_cli(self, *args):
79+
return subprocess.Popen(
80+
[sys.executable, "-Xutf8", "-m", "sqlite3", *args],
81+
encoding="utf-8",
82+
bufsize=0,
83+
stdin=subprocess.PIPE,
84+
# Note: the banner is printed to stderr, the prompt to stdout.
85+
stdout=subprocess.PIPE,
86+
stderr=subprocess.PIPE,
87+
)
88+
89+
def expect_success(self, proc):
90+
proc.wait()
91+
if proc.returncode:
92+
self.fail("".join(proc.stderr))
93+
94+
def test_interact(self):
95+
with self.start_cli() as proc:
96+
out, err = proc.communicate(timeout=self.TIMEOUT)
97+
self.assertIn(self.MEMORY_DB_MSG, err)
98+
self.assertIn(self.PS1, out)
99+
self.expect_success(proc)
100+
101+
def test_interact_quit(self):
102+
with self.start_cli() as proc:
103+
out, err = proc.communicate(input=".quit", timeout=self.TIMEOUT)
104+
self.assertIn(self.MEMORY_DB_MSG, err)
105+
self.assertIn(self.PS1, out)
106+
self.expect_success(proc)
107+
108+
def test_interact_version(self):
109+
with self.start_cli() as proc:
110+
out, err = proc.communicate(input=".version", timeout=self.TIMEOUT)
111+
self.assertIn(self.MEMORY_DB_MSG, err)
112+
self.assertIn(sqlite.sqlite_version, out)
113+
self.expect_success(proc)
114+
115+
def test_interact_valid_sql(self):
116+
with self.start_cli() as proc:
117+
out, err = proc.communicate(input="select 1;",
118+
timeout=self.TIMEOUT)
119+
self.assertIn(self.MEMORY_DB_MSG, err)
120+
self.assertIn("(1,)", out)
121+
self.expect_success(proc)
122+
123+
def test_interact_valid_multiline_sql(self):
124+
with self.start_cli() as proc:
125+
out, err = proc.communicate(input="select 1\n;",
126+
timeout=self.TIMEOUT)
127+
self.assertIn(self.MEMORY_DB_MSG, err)
128+
self.assertIn(self.PS2, out)
129+
self.assertIn("(1,)", out)
130+
self.expect_success(proc)
131+
132+
def test_interact_invalid_sql(self):
133+
with self.start_cli() as proc:
134+
out, err = proc.communicate(input="sel;", timeout=self.TIMEOUT)
135+
self.assertIn(self.MEMORY_DB_MSG, err)
136+
self.assertIn("OperationalError (SQLITE_ERROR)", err)
137+
self.expect_success(proc)
138+
139+
def test_interact_on_disk_file(self):
140+
self.addCleanup(unlink, TESTFN)
141+
with self.start_cli(TESTFN) as proc:
142+
out, err = proc.communicate(input="create table t(t);",
143+
timeout=self.TIMEOUT)
144+
self.assertIn(TESTFN, err)
145+
self.assertIn(self.PS1, out)
146+
self.expect_success(proc)
147+
with self.start_cli(TESTFN, "select count(t) from t") as proc:
148+
out = proc.stdout.read()
149+
err = proc.stderr.read()
150+
self.assertIn("(0,)", out)
151+
self.expect_success(proc)
152+
153+
154+
if __name__ == "__main__":
155+
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :mod:`sqlite3` :ref:`command-line interface <sqlite3-cli>`.
2+
Patch by Erlend Aasland.

0 commit comments

Comments
 (0)