Skip to content

Commit 10f822b

Browse files
tanloongencukou
authored andcommitted
pythongh-133390: Support SQL keyword completion for sqlite3 CLI (python#133393)
1 parent 24069fb commit 10f822b

File tree

7 files changed

+193
-6
lines changed

7 files changed

+193
-6
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ shelve
134134
(Contributed by Andrea Oliveri in :gh:`134004`.)
135135

136136

137+
sqlite3
138+
-------
139+
140+
* Support SQL keyword completion in the :mod:`sqlite3` command-line interface.
141+
(Contributed by Long Tan in :gh:`133393`.)
142+
143+
137144
ssl
138145
---
139146

Lib/sqlite3/__main__.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from textwrap import dedent
1313
from _colorize import get_theme, theme_no_color
1414

15+
from ._completer import completer
16+
1517

1618
def execute(c, sql, suppress_errors=True, theme=theme_no_color):
1719
"""Helper that wraps execution of SQL code.
@@ -136,12 +138,9 @@ def main(*args):
136138
execute(con, args.sql, suppress_errors=False, theme=theme)
137139
else:
138140
# No SQL provided; start the REPL.
139-
console = SqliteInteractiveConsole(con, use_color=True)
140-
try:
141-
import readline # noqa: F401
142-
except ImportError:
143-
pass
144-
console.interact(banner, exitmsg="")
141+
with completer():
142+
console = SqliteInteractiveConsole(con, use_color=True)
143+
console.interact(banner, exitmsg="")
145144
finally:
146145
con.close()
147146

Lib/sqlite3/_completer.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from contextlib import contextmanager
2+
3+
try:
4+
from _sqlite3 import SQLITE_KEYWORDS
5+
except ImportError:
6+
SQLITE_KEYWORDS = ()
7+
8+
_completion_matches = []
9+
10+
11+
def _complete(text, state):
12+
global _completion_matches
13+
14+
if state == 0:
15+
text_upper = text.upper()
16+
_completion_matches = [c for c in SQLITE_KEYWORDS if c.startswith(text_upper)]
17+
try:
18+
return _completion_matches[state] + " "
19+
except IndexError:
20+
return None
21+
22+
23+
@contextmanager
24+
def completer():
25+
try:
26+
import readline
27+
except ImportError:
28+
yield
29+
return
30+
31+
old_completer = readline.get_completer()
32+
try:
33+
readline.set_completer(_complete)
34+
if readline.backend == "editline":
35+
# libedit uses "^I" instead of "tab"
36+
command_string = "bind ^I rl_complete"
37+
else:
38+
command_string = "tab: complete"
39+
readline.parse_and_bind(command_string)
40+
yield
41+
finally:
42+
readline.set_completer(old_completer)

Lib/test/test_sqlite3/test_cli.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""sqlite3 CLI tests."""
22
import sqlite3
3+
import sys
4+
import textwrap
35
import unittest
46

57
from sqlite3.__main__ import main as cli
8+
from test.support.import_helper import import_module
69
from test.support.os_helper import TESTFN, unlink
10+
from test.support.pty_helper import run_pty
711
from test.support import (
812
captured_stdout,
913
captured_stderr,
1014
captured_stdin,
1115
force_not_colorized_test_class,
16+
requires_subprocess,
1217
)
1318

1419

@@ -200,5 +205,98 @@ def test_color(self):
200205
self.assertIn('\x1b[1;35mOperationalError (SQLITE_ERROR)\x1b[0m: '
201206
'\x1b[35mnear "sel": syntax error\x1b[0m', err)
202207

208+
209+
@requires_subprocess()
210+
@force_not_colorized_test_class
211+
class Completion(unittest.TestCase):
212+
PS1 = "sqlite> "
213+
214+
@classmethod
215+
def setUpClass(cls):
216+
_sqlite3 = import_module("_sqlite3")
217+
if not hasattr(_sqlite3, "SQLITE_KEYWORDS"):
218+
raise unittest.SkipTest("unable to determine SQLite keywords")
219+
220+
readline = import_module("readline")
221+
if readline.backend == "editline":
222+
raise unittest.SkipTest("libedit readline is not supported")
223+
224+
def write_input(self, input_, env=None):
225+
script = textwrap.dedent("""
226+
import readline
227+
from sqlite3.__main__ import main
228+
229+
readline.parse_and_bind("set colored-completion-prefix off")
230+
main()
231+
""")
232+
return run_pty(script, input_, env)
233+
234+
def test_complete_sql_keywords(self):
235+
# List candidates starting with 'S', there should be multiple matches.
236+
input_ = b"S\t\tEL\t 1;\n.quit\n"
237+
output = self.write_input(input_)
238+
self.assertIn(b"SELECT", output)
239+
self.assertIn(b"SET", output)
240+
self.assertIn(b"SAVEPOINT", output)
241+
self.assertIn(b"(1,)", output)
242+
243+
# Keywords are completed in upper case for even lower case user input.
244+
input_ = b"sel\t\t 1;\n.quit\n"
245+
output = self.write_input(input_)
246+
self.assertIn(b"SELECT", output)
247+
self.assertIn(b"(1,)", output)
248+
249+
@unittest.skipIf(sys.platform.startswith("freebsd"),
250+
"Two actual tabs are inserted when there are no matching"
251+
" completions in the pseudo-terminal opened by run_pty()"
252+
" on FreeBSD")
253+
def test_complete_no_match(self):
254+
input_ = b"xyzzy\t\t\b\b\b\b\b\b\b.quit\n"
255+
# Set NO_COLOR to disable coloring for self.PS1.
256+
output = self.write_input(input_, env={"NO_COLOR": "1"})
257+
lines = output.decode().splitlines()
258+
indices = (
259+
i for i, line in enumerate(lines, 1)
260+
if line.startswith(f"{self.PS1}xyzzy")
261+
)
262+
line_num = next(indices, -1)
263+
self.assertNotEqual(line_num, -1)
264+
# Completions occupy lines, assert no extra lines when there is nothing
265+
# to complete.
266+
self.assertEqual(line_num, len(lines))
267+
268+
def test_complete_no_input(self):
269+
from _sqlite3 import SQLITE_KEYWORDS
270+
271+
script = textwrap.dedent("""
272+
import readline
273+
from sqlite3.__main__ import main
274+
275+
# Configure readline to ...:
276+
# - hide control sequences surrounding each candidate
277+
# - hide "Display all xxx possibilities? (y or n)"
278+
# - hide "--More--"
279+
# - show candidates one per line
280+
readline.parse_and_bind("set colored-completion-prefix off")
281+
readline.parse_and_bind("set colored-stats off")
282+
readline.parse_and_bind("set completion-query-items 0")
283+
readline.parse_and_bind("set page-completions off")
284+
readline.parse_and_bind("set completion-display-width 0")
285+
286+
main()
287+
""")
288+
input_ = b"\t\t.quit\n"
289+
output = run_pty(script, input_, env={"NO_COLOR": "1"})
290+
lines = output.decode().splitlines()
291+
indices = [
292+
i for i, line in enumerate(lines)
293+
if line.startswith(self.PS1)
294+
]
295+
self.assertEqual(len(indices), 2)
296+
start, end = indices
297+
candidates = [l.strip() for l in lines[start+1:end]]
298+
self.assertEqual(candidates, sorted(SQLITE_KEYWORDS))
299+
300+
203301
if __name__ == "__main__":
204302
unittest.main()

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,6 +1868,7 @@ Neil Tallim
18681868
Geoff Talvola
18691869
Anish Tambe
18701870
Musashi Tamura
1871+
Long Tan
18711872
William Tanksley
18721873
Christian Tanzer
18731874
Steven Taschuk
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support keyword completion in the :mod:`sqlite3` command-line interface.

Modules/_sqlite/module.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
#include "microprotocols.h"
3333
#include "row.h"
3434
#include "blob.h"
35+
#include "util.h"
3536

3637
#if SQLITE_VERSION_NUMBER < 3015002
3738
#error "SQLite 3.15.2 or higher required"
@@ -404,6 +405,40 @@ pysqlite_error_name(int rc)
404405
return NULL;
405406
}
406407

408+
static int
409+
add_keyword_tuple(PyObject *module)
410+
{
411+
#if SQLITE_VERSION_NUMBER >= 3024000
412+
int count = sqlite3_keyword_count();
413+
PyObject *keywords = PyTuple_New(count);
414+
if (keywords == NULL) {
415+
return -1;
416+
}
417+
for (int i = 0; i < count; i++) {
418+
const char *keyword;
419+
int size;
420+
int result = sqlite3_keyword_name(i, &keyword, &size);
421+
if (result != SQLITE_OK) {
422+
pysqlite_state *state = pysqlite_get_state(module);
423+
set_error_from_code(state, result);
424+
goto error;
425+
}
426+
PyObject *kwd = PyUnicode_FromStringAndSize(keyword, size);
427+
if (!kwd) {
428+
goto error;
429+
}
430+
PyTuple_SET_ITEM(keywords, i, kwd);
431+
}
432+
return PyModule_Add(module, "SQLITE_KEYWORDS", keywords);
433+
434+
error:
435+
Py_DECREF(keywords);
436+
return -1;
437+
#else
438+
return 0;
439+
#endif
440+
}
441+
407442
static int
408443
add_integer_constants(PyObject *module) {
409444
#define ADD_INT(ival) \
@@ -702,6 +737,10 @@ module_exec(PyObject *module)
702737
goto error;
703738
}
704739

740+
if (add_keyword_tuple(module) < 0) {
741+
goto error;
742+
}
743+
705744
if (PyModule_AddStringConstant(module, "sqlite_version", sqlite3_libversion())) {
706745
goto error;
707746
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy