| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -134,6 +134,13 @@ shelve | |||
| 134 | 134 | (Contributed by Andrea Oliveri in :gh:`134004`.) | |
| 135 | 135 | ||
| 136 | 136 | ||
| 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 | + | ||
| 137 | 144 | ssl | |
| 138 | 145 | --- | |
| 139 | 146 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,8 @@ | |||
| 12 | 12 | from textwrap import dedent | |
| 13 | 13 | from _colorize import get_theme, theme_no_color | |
| 14 | 14 | ||
| 15 | + from ._completer import completer | ||
| 16 | + | ||
| 15 | 17 | ||
| 16 | 18 | def execute(c, sql, suppress_errors=True, theme=theme_no_color): | |
| 17 | 19 | """Helper that wraps execution of SQL code. | |
@@ -136,12 +138,9 @@ def main(*args): | |||
| 136 | 138 | execute(con, args.sql, suppress_errors=False, theme=theme) | |
| 137 | 139 | else: | |
| 138 | 140 | # 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="") | ||
| 145 | 144 | finally: | |
| 146 | 145 | con.close() | |
| 147 | 146 | ||
| Original file line number | Diff line number | Diff 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) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,14 +1,22 @@ | |||
| 1 | 1 | """sqlite3 CLI tests.""" | |
| 2 | 2 | import sqlite3 | |
| 3 | + import sys | ||
| 4 | + import textwrap | ||
| 3 | 5 | import unittest | |
| 6 | + import unittest.mock | ||
| 7 | + import os | ||
| 4 | 8 | ||
| 5 | 9 | from sqlite3.__main__ import main as cli | |
| 10 | + from test.support.import_helper import import_module | ||
| 6 | 11 | from test.support.os_helper import TESTFN, unlink | |
| 12 | + from test.support.pty_helper import run_pty | ||
| 7 | 13 | from test.support import ( | |
| 8 | 14 | captured_stdout, | |
| 9 | 15 | captured_stderr, | |
| 10 | 16 | captured_stdin, | |
| 11 | 17 | force_not_colorized_test_class, | |
| 18 | + requires_subprocess, | ||
| 19 | + verbose, | ||
| 12 | 20 | ) | |
| 13 | 21 | ||
| 14 | 22 | ||
@@ -200,5 +208,108 @@ def test_color(self): | |||
| 200 | 208 | self.assertIn('\x1b[1;35mOperationalError (SQLITE_ERROR)\x1b[0m: ' | |
| 201 | 209 | '\x1b[35mnear "sel": syntax error\x1b[0m', err) | |
| 202 | 210 | ||
| 211 | + | ||
| 212 | + @requires_subprocess() | ||
| 213 | + @force_not_colorized_test_class | ||
| 214 | + class Completion(unittest.TestCase): | ||
| 215 | + PS1 = "sqlite> " | ||
| 216 | + | ||
| 217 | + @classmethod | ||
| 218 | + def setUpClass(cls): | ||
| 219 | + _sqlite3 = import_module("_sqlite3") | ||
| 220 | + if not hasattr(_sqlite3, "SQLITE_KEYWORDS"): | ||
| 221 | + raise unittest.SkipTest("unable to determine SQLite keywords") | ||
| 222 | + | ||
| 223 | + readline = import_module("readline") | ||
| 224 | + if readline.backend == "editline": | ||
| 225 | + raise unittest.SkipTest("libedit readline is not supported") | ||
| 226 | + | ||
| 227 | + def write_input(self, input_, env=None): | ||
| 228 | + script = textwrap.dedent(""" | ||
| 229 | + import readline | ||
| 230 | + from sqlite3.__main__ import main | ||
| 231 | + | ||
| 232 | + readline.parse_and_bind("set colored-completion-prefix off") | ||
| 233 | + main() | ||
| 234 | + """) | ||
| 235 | + return run_pty(script, input_, env) | ||
| 236 | + | ||
| 237 | + def test_complete_sql_keywords(self): | ||
| 238 | + # List candidates starting with 'S', there should be multiple matches. | ||
| 239 | + input_ = b"S\t\tEL\t 1;\n.quit\n" | ||
| 240 | + output = self.write_input(input_) | ||
| 241 | + self.assertIn(b"SELECT", output) | ||
| 242 | + self.assertIn(b"SET", output) | ||
| 243 | + self.assertIn(b"SAVEPOINT", output) | ||
| 244 | + self.assertIn(b"(1,)", output) | ||
| 245 | + | ||
| 246 | + # Keywords are completed in upper case for even lower case user input. | ||
| 247 | + input_ = b"sel\t\t 1;\n.quit\n" | ||
| 248 | + output = self.write_input(input_) | ||
| 249 | + self.assertIn(b"SELECT", output) | ||
| 250 | + self.assertIn(b"(1,)", output) | ||
| 251 | + | ||
| 252 | + @unittest.skipIf(sys.platform.startswith("freebsd"), | ||
| 253 | + "Two actual tabs are inserted when there are no matching" | ||
| 254 | + " completions in the pseudo-terminal opened by run_pty()" | ||
| 255 | + " on FreeBSD") | ||
| 256 | + def test_complete_no_match(self): | ||
| 257 | + input_ = b"xyzzy\t\t\b\b\b\b\b\b\b.quit\n" | ||
| 258 | + # Set NO_COLOR to disable coloring for self.PS1. | ||
| 259 | + output = self.write_input(input_, env={**os.environ, "NO_COLOR": "1"}) | ||
| 260 | + lines = output.decode().splitlines() | ||
| 261 | + indices = ( | ||
| 262 | + i for i, line in enumerate(lines, 1) | ||
| 263 | + if line.startswith(f"{self.PS1}xyzzy") | ||
| 264 | + ) | ||
| 265 | + line_num = next(indices, -1) | ||
| 266 | + self.assertNotEqual(line_num, -1) | ||
| 267 | + # Completions occupy lines, assert no extra lines when there is nothing | ||
| 268 | + # to complete. | ||
| 269 | + self.assertEqual(line_num, len(lines)) | ||
| 270 | + | ||
| 271 | + def test_complete_no_input(self): | ||
| 272 | + from _sqlite3 import SQLITE_KEYWORDS | ||
| 273 | + | ||
| 274 | + script = textwrap.dedent(""" | ||
| 275 | + import readline | ||
| 276 | + from sqlite3.__main__ import main | ||
| 277 | + | ||
| 278 | + # Configure readline to ...: | ||
| 279 | + # - hide control sequences surrounding each candidate | ||
| 280 | + # - hide "Display all xxx possibilities? (y or n)" | ||
| 281 | + # - hide "--More--" | ||
| 282 | + # - show candidates one per line | ||
| 283 | + readline.parse_and_bind("set colored-completion-prefix off") | ||
| 284 | + readline.parse_and_bind("set colored-stats off") | ||
| 285 | + readline.parse_and_bind("set completion-query-items 0") | ||
| 286 | + readline.parse_and_bind("set page-completions off") | ||
| 287 | + readline.parse_and_bind("set completion-display-width 0") | ||
| 288 | + readline.parse_and_bind("set show-all-if-ambiguous off") | ||
| 289 | + readline.parse_and_bind("set show-all-if-unmodified off") | ||
| 290 | + | ||
| 291 | + main() | ||
| 292 | + """) | ||
| 293 | + input_ = b"\t\t.quit\n" | ||
| 294 | + output = run_pty(script, input_, env={**os.environ, "NO_COLOR": "1"}) | ||
| 295 | + try: | ||
| 296 | + lines = output.decode().splitlines() | ||
| 297 | + indices = [ | ||
| 298 | + i for i, line in enumerate(lines) | ||
| 299 | + if line.startswith(self.PS1) | ||
| 300 | + ] | ||
| 301 | + self.assertEqual(len(indices), 2) | ||
| 302 | + start, end = indices | ||
| 303 | + candidates = [l.strip() for l in lines[start+1:end]] | ||
| 304 | + self.assertEqual(candidates, sorted(SQLITE_KEYWORDS)) | ||
| 305 | + except: | ||
| 306 | + if verbose: | ||
| 307 | + print(' PTY output: '.center(30, '-')) | ||
| 308 | + print(output.decode(errors='replace')) | ||
| 309 | + print(' end PTY output '.center(30, '-')) | ||
| 310 | + raise | ||
| 311 | + | ||
| 312 | + | ||
| 313 | + | ||
| 203 | 314 | if __name__ == "__main__": | |
| 204 | 315 | unittest.main() | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1869,6 +1869,7 @@ Neil Tallim | |||
| 1869 | 1869 | Geoff Talvola | |
| 1870 | 1870 | Anish Tambe | |
| 1871 | 1871 | Musashi Tamura | |
| 1872 | + Long Tan | ||
| 1872 | 1873 | William Tanksley | |
| 1873 | 1874 | Christian Tanzer | |
| 1874 | 1875 | Steven Taschuk | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1 @@ | |||
| 1 | + Support keyword completion in the :mod:`sqlite3` command-line interface. | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -32,6 +32,7 @@ | |||
| 32 | 32 | #include "microprotocols.h" | |
| 33 | 33 | #include "row.h" | |
| 34 | 34 | #include "blob.h" | |
| 35 | + #include "util.h" | ||
| 35 | 36 | ||
| 36 | 37 | #if SQLITE_VERSION_NUMBER < 3015002 | |
| 37 | 38 | #error "SQLite 3.15.2 or higher required" | |
@@ -404,6 +405,40 @@ pysqlite_error_name(int rc) | |||
| 404 | 405 | return NULL; | |
| 405 | 406 | } | |
| 406 | 407 | ||
| 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 | + | ||
| 407 | 442 | static int | |
| 408 | 443 | add_integer_constants(PyObject *module) { | |
| 409 | 444 | #define ADD_INT(ival) \ | |
@@ -702,6 +737,10 @@ module_exec(PyObject *module) | |||
| 702 | 737 | goto error; | |
| 703 | 738 | } | |
| 704 | 739 | ||
| 740 | + if (add_keyword_tuple(module) < 0) { | ||
| 741 | + goto error; | ||
| 742 | + } | ||
| 743 | + | ||
| 705 | 744 | if (PyModule_AddStringConstant(module, "sqlite_version", sqlite3_libversion())) { | |
| 706 | 745 | goto error; | |
| 707 | 746 | } | |
| Back | FazBrowse Home | New Git URL |
0 commit comments