From 10f822b55c79b5ce4bf46d462c93fa9a0b3074dc Mon Sep 17 00:00:00 2001 From: Tan Long Date: Fri, 6 Jun 2025 22:52:41 +0800 Subject: [PATCH 1/5] gh-133390: Support SQL keyword completion for sqlite3 CLI (#133393) --- Doc/whatsnew/3.15.rst | 7 ++ Lib/sqlite3/__main__.py | 11 +-- Lib/sqlite3/_completer.py | 42 ++++++++ Lib/test/test_sqlite3/test_cli.py | 98 +++++++++++++++++++ Misc/ACKS | 1 + ...-05-05-03-14-08.gh-issue-133390.AuTggn.rst | 1 + Modules/_sqlite/module.c | 39 ++++++++ 7 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 Lib/sqlite3/_completer.py create mode 100644 Misc/NEWS.d/next/Library/2025-05-05-03-14-08.gh-issue-133390.AuTggn.rst diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index e5bccf17eb63e2..2f8335a895c70b 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -134,6 +134,13 @@ shelve (Contributed by Andrea Oliveri in :gh:`134004`.) +sqlite3 +------- + +* Support SQL keyword completion in the :mod:`sqlite3` command-line interface. + (Contributed by Long Tan in :gh:`133393`.) + + ssl --- diff --git a/Lib/sqlite3/__main__.py b/Lib/sqlite3/__main__.py index c2fa23c46cf990..9e74b49ee828bc 100644 --- a/Lib/sqlite3/__main__.py +++ b/Lib/sqlite3/__main__.py @@ -12,6 +12,8 @@ from textwrap import dedent from _colorize import get_theme, theme_no_color +from ._completer import completer + def execute(c, sql, suppress_errors=True, theme=theme_no_color): """Helper that wraps execution of SQL code. @@ -136,12 +138,9 @@ def main(*args): execute(con, args.sql, suppress_errors=False, theme=theme) else: # No SQL provided; start the REPL. - console = SqliteInteractiveConsole(con, use_color=True) - try: - import readline # noqa: F401 - except ImportError: - pass - console.interact(banner, exitmsg="") + with completer(): + console = SqliteInteractiveConsole(con, use_color=True) + console.interact(banner, exitmsg="") finally: con.close() diff --git a/Lib/sqlite3/_completer.py b/Lib/sqlite3/_completer.py new file mode 100644 index 00000000000000..f21ef69cad6439 --- /dev/null +++ b/Lib/sqlite3/_completer.py @@ -0,0 +1,42 @@ +from contextlib import contextmanager + +try: + from _sqlite3 import SQLITE_KEYWORDS +except ImportError: + SQLITE_KEYWORDS = () + +_completion_matches = [] + + +def _complete(text, state): + global _completion_matches + + if state == 0: + text_upper = text.upper() + _completion_matches = [c for c in SQLITE_KEYWORDS if c.startswith(text_upper)] + try: + return _completion_matches[state] + " " + except IndexError: + return None + + +@contextmanager +def completer(): + try: + import readline + except ImportError: + yield + return + + old_completer = readline.get_completer() + try: + readline.set_completer(_complete) + if readline.backend == "editline": + # libedit uses "^I" instead of "tab" + command_string = "bind ^I rl_complete" + else: + command_string = "tab: complete" + readline.parse_and_bind(command_string) + yield + finally: + readline.set_completer(old_completer) diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index 37e0f74f688659..7f0b0f3650535a 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -1,14 +1,19 @@ """sqlite3 CLI tests.""" import sqlite3 +import sys +import textwrap import unittest from sqlite3.__main__ import main as cli +from test.support.import_helper import import_module from test.support.os_helper import TESTFN, unlink +from test.support.pty_helper import run_pty from test.support import ( captured_stdout, captured_stderr, captured_stdin, force_not_colorized_test_class, + requires_subprocess, ) @@ -200,5 +205,98 @@ def test_color(self): self.assertIn('\x1b[1;35mOperationalError (SQLITE_ERROR)\x1b[0m: ' '\x1b[35mnear "sel": syntax error\x1b[0m', err) + +@requires_subprocess() +@force_not_colorized_test_class +class Completion(unittest.TestCase): + PS1 = "sqlite> " + + @classmethod + def setUpClass(cls): + _sqlite3 = import_module("_sqlite3") + if not hasattr(_sqlite3, "SQLITE_KEYWORDS"): + raise unittest.SkipTest("unable to determine SQLite keywords") + + readline = import_module("readline") + if readline.backend == "editline": + raise unittest.SkipTest("libedit readline is not supported") + + def write_input(self, input_, env=None): + script = textwrap.dedent(""" + import readline + from sqlite3.__main__ import main + + readline.parse_and_bind("set colored-completion-prefix off") + main() + """) + return run_pty(script, input_, env) + + def test_complete_sql_keywords(self): + # List candidates starting with 'S', there should be multiple matches. + input_ = b"S\t\tEL\t 1;\n.quit\n" + output = self.write_input(input_) + self.assertIn(b"SELECT", output) + self.assertIn(b"SET", output) + self.assertIn(b"SAVEPOINT", output) + self.assertIn(b"(1,)", output) + + # Keywords are completed in upper case for even lower case user input. + input_ = b"sel\t\t 1;\n.quit\n" + output = self.write_input(input_) + self.assertIn(b"SELECT", output) + self.assertIn(b"(1,)", output) + + @unittest.skipIf(sys.platform.startswith("freebsd"), + "Two actual tabs are inserted when there are no matching" + " completions in the pseudo-terminal opened by run_pty()" + " on FreeBSD") + def test_complete_no_match(self): + input_ = b"xyzzy\t\t\b\b\b\b\b\b\b.quit\n" + # Set NO_COLOR to disable coloring for self.PS1. + output = self.write_input(input_, env={"NO_COLOR": "1"}) + lines = output.decode().splitlines() + indices = ( + i for i, line in enumerate(lines, 1) + if line.startswith(f"{self.PS1}xyzzy") + ) + line_num = next(indices, -1) + self.assertNotEqual(line_num, -1) + # Completions occupy lines, assert no extra lines when there is nothing + # to complete. + self.assertEqual(line_num, len(lines)) + + def test_complete_no_input(self): + from _sqlite3 import SQLITE_KEYWORDS + + script = textwrap.dedent(""" + import readline + from sqlite3.__main__ import main + + # Configure readline to ...: + # - hide control sequences surrounding each candidate + # - hide "Display all xxx possibilities? (y or n)" + # - hide "--More--" + # - show candidates one per line + readline.parse_and_bind("set colored-completion-prefix off") + readline.parse_and_bind("set colored-stats off") + readline.parse_and_bind("set completion-query-items 0") + readline.parse_and_bind("set page-completions off") + readline.parse_and_bind("set completion-display-width 0") + + main() + """) + input_ = b"\t\t.quit\n" + output = run_pty(script, input_, env={"NO_COLOR": "1"}) + lines = output.decode().splitlines() + indices = [ + i for i, line in enumerate(lines) + if line.startswith(self.PS1) + ] + self.assertEqual(len(indices), 2) + start, end = indices + candidates = [l.strip() for l in lines[start+1:end]] + self.assertEqual(candidates, sorted(SQLITE_KEYWORDS)) + + if __name__ == "__main__": unittest.main() diff --git a/Misc/ACKS b/Misc/ACKS index 2435943f1bb2bd..739af8d9e11a10 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1868,6 +1868,7 @@ Neil Tallim Geoff Talvola Anish Tambe Musashi Tamura +Long Tan William Tanksley Christian Tanzer Steven Taschuk diff --git a/Misc/NEWS.d/next/Library/2025-05-05-03-14-08.gh-issue-133390.AuTggn.rst b/Misc/NEWS.d/next/Library/2025-05-05-03-14-08.gh-issue-133390.AuTggn.rst new file mode 100644 index 00000000000000..38d5c311b1d437 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-05-05-03-14-08.gh-issue-133390.AuTggn.rst @@ -0,0 +1 @@ +Support keyword completion in the :mod:`sqlite3` command-line interface. diff --git a/Modules/_sqlite/module.c b/Modules/_sqlite/module.c index 909ddd1f990e19..5464fd1227ad20 100644 --- a/Modules/_sqlite/module.c +++ b/Modules/_sqlite/module.c @@ -32,6 +32,7 @@ #include "microprotocols.h" #include "row.h" #include "blob.h" +#include "util.h" #if SQLITE_VERSION_NUMBER < 3015002 #error "SQLite 3.15.2 or higher required" @@ -404,6 +405,40 @@ pysqlite_error_name(int rc) return NULL; } +static int +add_keyword_tuple(PyObject *module) +{ +#if SQLITE_VERSION_NUMBER >= 3024000 + int count = sqlite3_keyword_count(); + PyObject *keywords = PyTuple_New(count); + if (keywords == NULL) { + return -1; + } + for (int i = 0; i < count; i++) { + const char *keyword; + int size; + int result = sqlite3_keyword_name(i, &keyword, &size); + if (result != SQLITE_OK) { + pysqlite_state *state = pysqlite_get_state(module); + set_error_from_code(state, result); + goto error; + } + PyObject *kwd = PyUnicode_FromStringAndSize(keyword, size); + if (!kwd) { + goto error; + } + PyTuple_SET_ITEM(keywords, i, kwd); + } + return PyModule_Add(module, "SQLITE_KEYWORDS", keywords); + +error: + Py_DECREF(keywords); + return -1; +#else + return 0; +#endif +} + static int add_integer_constants(PyObject *module) { #define ADD_INT(ival) \ @@ -702,6 +737,10 @@ module_exec(PyObject *module) goto error; } + if (add_keyword_tuple(module) < 0) { + goto error; + } + if (PyModule_AddStringConstant(module, "sqlite_version", sqlite3_libversion())) { goto error; } From a59ec6afa0e6e8a9c8ff7e89cda16caf9b04a876 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 9 Jun 2025 11:58:42 +0200 Subject: [PATCH 2/5] Pass env variables to test subprocess --- Lib/test/test_sqlite3/test_cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index 7f0b0f3650535a..dd58953c8221b7 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -3,6 +3,7 @@ import sys import textwrap import unittest +import os from sqlite3.__main__ import main as cli from test.support.import_helper import import_module @@ -253,7 +254,7 @@ def test_complete_sql_keywords(self): def test_complete_no_match(self): input_ = b"xyzzy\t\t\b\b\b\b\b\b\b.quit\n" # Set NO_COLOR to disable coloring for self.PS1. - output = self.write_input(input_, env={"NO_COLOR": "1"}) + output = self.write_input(input_, env={**os.environ, "NO_COLOR": "1"}) lines = output.decode().splitlines() indices = ( i for i, line in enumerate(lines, 1) @@ -286,7 +287,7 @@ def test_complete_no_input(self): main() """) input_ = b"\t\t.quit\n" - output = run_pty(script, input_, env={"NO_COLOR": "1"}) + output = run_pty(script, input_, env={**os.environ, "NO_COLOR": "1"}) lines = output.decode().splitlines() indices = [ i for i, line in enumerate(lines) From bd6a870d8225b19727922f3d11abb022160bad58 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 9 Jun 2025 12:38:30 +0200 Subject: [PATCH 3/5] Import unittest.mock, so test_sqlite3.test_cli works on its own --- Lib/test/test_sqlite3/test_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index dd58953c8221b7..00bcf49f6adaa4 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -3,6 +3,7 @@ import sys import textwrap import unittest +import unittest.mock import os from sqlite3.__main__ import main as cli From 3d9721a18237a027c38a95956751452963397112 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 10 Jun 2025 13:58:11 +0200 Subject: [PATCH 4/5] Dump output on error in verbose mode --- Lib/test/test_sqlite3/test_cli.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index 00bcf49f6adaa4..833ae1eec570c8 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -16,6 +16,7 @@ captured_stdin, force_not_colorized_test_class, requires_subprocess, + verbose, ) @@ -289,15 +290,23 @@ def test_complete_no_input(self): """) input_ = b"\t\t.quit\n" output = run_pty(script, input_, env={**os.environ, "NO_COLOR": "1"}) - lines = output.decode().splitlines() - indices = [ - i for i, line in enumerate(lines) - if line.startswith(self.PS1) - ] - self.assertEqual(len(indices), 2) - start, end = indices - candidates = [l.strip() for l in lines[start+1:end]] - self.assertEqual(candidates, sorted(SQLITE_KEYWORDS)) + try: + lines = output.decode().splitlines() + indices = [ + i for i, line in enumerate(lines) + if line.startswith(self.PS1) + ] + self.assertEqual(len(indices), 3) + start, end = indices + candidates = [l.strip() for l in lines[start+1:end]] + self.assertEqual(candidates, sorted(SQLITE_KEYWORDS)) + except: + if verbose: + print(' PTY output: '.center(30, '-')) + print(output.decode(errors='replace')) + print(' end PTY output '.center(30, '-')) + raise + if __name__ == "__main__": From f1fa6c74ff9b79e48985f467634e7ae462f7a533 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 11 Jun 2025 09:17:12 +0200 Subject: [PATCH 5/5] Disable readline's show-all settings --- Lib/test/test_sqlite3/test_cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index 833ae1eec570c8..d993e28c4bb3a6 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -285,6 +285,8 @@ def test_complete_no_input(self): readline.parse_and_bind("set completion-query-items 0") readline.parse_and_bind("set page-completions off") readline.parse_and_bind("set completion-display-width 0") + readline.parse_and_bind("set show-all-if-ambiguous off") + readline.parse_and_bind("set show-all-if-unmodified off") main() """) @@ -296,7 +298,7 @@ def test_complete_no_input(self): i for i, line in enumerate(lines) if line.startswith(self.PS1) ] - self.assertEqual(len(indices), 3) + self.assertEqual(len(indices), 2) start, end = indices candidates = [l.strip() for l in lines[start+1:end]] self.assertEqual(candidates, sorted(SQLITE_KEYWORDS))