From 8500c9816dcb219468d643a87b78d164349d2f3d Mon Sep 17 00:00:00 2001 From: Tan Long Date: Fri, 9 May 2025 20:59:48 +0800 Subject: [PATCH 1/3] Improve `.help` in `sqlite3` CLI Make `.help` print list of available commands and `.help ` prints help for that command --- Lib/sqlite3/__main__.py | 199 +++++++++++++++++++++++++++--- Lib/test/test_sqlite3/test_cli.py | 32 +++++ 2 files changed, 212 insertions(+), 19 deletions(-) diff --git a/Lib/sqlite3/__main__.py b/Lib/sqlite3/__main__.py index c2fa23c46cf990..bf6539667d77ab 100644 --- a/Lib/sqlite3/__main__.py +++ b/Lib/sqlite3/__main__.py @@ -41,12 +41,180 @@ def execute(c, sql, suppress_errors=True, theme=theme_no_color): class SqliteInteractiveConsole(InteractiveConsole): """A simple SQLite REPL.""" + PS1 = "sqlite> " + PS2 = " ... " + ruler = "=" + doc_header = "Documented commands (type .help ):" + undoc_header = "Undocumented commands:" def __init__(self, connection, use_color=False): super().__init__() self._con = connection self._cur = connection.cursor() self._use_color = use_color + self._theme = get_theme(force_no_color=not use_color) + + s = self._theme.syntax + sys.ps1 = f"{s.prompt}{self.PS1}{s.reset}" + sys.ps2 = f"{s.prompt}{self.PS2}{s.reset}" + + def do_version(self, _): + """.version + + Show version of the runtime SQLite library. + """ + print(sqlite3.sqlite_version) + + def do_help(self, arg): + """.help [-all] [command] + + Without argument, print the list of available commands. + With a command name as argument, print help about that command. + With more command names as arguments, only the first one is used. + """ + if not arg: + cmds = sorted(name[3:] for name in dir(self.__class__) + if name.startswith("do_")) + cmds_doc = [] + cmds_undoc = [] + for cmd in cmds: + if getattr(self, f"do_{cmd}").__doc__: + cmds_doc.append(cmd) + else: + cmds_undoc.append(cmd) + self._print_commands(self.doc_header, cmds_doc, 80) + self._print_commands(self.undoc_header, cmds_undoc, 80) + else: + arg = arg.split()[0] + if arg in ("-all", "--all"): + names = sorted(name for name in dir(self.__class__) + if name.startswith("do_")) + print(self._help_message_from_method_names(names)) + else: + if (method := getattr(self, "do_" + arg, None)) is not None: + print(self._help_message_from_doc(method.__doc__)) + else: + self._error(f"No help for '{arg}'") + + def do_quit(self, _): + """.q(uit) + + Exit this program. + """ + sys.exit(0) + + do_q = do_quit + + def _help_message_from_doc(self, doc): + # copied from Lib/pdb.py#L2544 + lines = [line.strip() for line in doc.rstrip().splitlines()] + if not lines: + return "No help message found." + if "" in lines: + usage_end = lines.index("") + else: + usage_end = 1 + formatted = [] + indent = " " * len(self.PS1) + for i, line in enumerate(lines): + if i == 0: + prefix = "Usage: " + elif i < usage_end: + prefix = " " + else: + prefix = "" + formatted.append(indent + prefix + line) + return "\n".join(formatted) + + def _help_message_from_method_names(self, names): + formatted = [] + indent = " " * len(self.PS1) + for name in names: + if not (doc := getattr(self, name).__doc__): + formatted.append(f".{name[3:]}") + continue + lines = [line.strip() for line in doc.rstrip().splitlines()] + if "" in lines: + usage_end = lines.index("") + else: + usage_end = 1 + for i, line in enumerate(lines): + # skip method aliases, e.g. do_q for do_quit + if i == 0 and line in formatted: + break + elif i < usage_end: + formatted.append(line) + elif not line and i == usage_end: + continue + else: + formatted.append(indent + line) + return "\n".join(formatted) + + def _error(self, msg): + t = self._theme.traceback + self.write(f"{t.message}{msg}{t.reset}\n") + + def _print_commands(self, header, cmds, maxcol): + # copied and modified from Lib/cmd.py#L351 + if cmds: + print(header) + if self.ruler: + print(self.ruler * len(header)) + self._columnize(cmds, maxcol-1) + print() + + def _columnize(self, strings, displaywidth=80): + """Display a list of strings as a compact set of columns. + + Each column is only as wide as necessary. + Columns are separated by two spaces (one was not legible enough). + """ + # copied and modified from Lib/cmd.py#L359 + if not strings: + print("") + return + + size = len(strings) + if size == 1: + print(strings[0]) + return + # Try every row count from 1 upwards + for nrows in range(1, size): + ncols = (size+nrows-1) // nrows + colwidths = [] + totwidth = -2 + for col in range(ncols): + colwidth = 0 + for row in range(nrows): + i = row + nrows*col + if i >= size: + break + x = strings[i] + colwidth = max(colwidth, len(x)) + colwidths.append(colwidth) + totwidth += colwidth + 2 + if totwidth > displaywidth: + break + if totwidth <= displaywidth: + break + else: + nrows = size + ncols = 1 + colwidths = [0] + for row in range(nrows): + texts = [] + for col in range(ncols): + i = row + nrows*col + if i >= size: + x = "" + else: + x = strings[i] + texts.append(x) + while texts and not texts[-1]: + del texts[-1] + for col in range(len(texts)): + texts[col] = texts[col].ljust(colwidths[col]) + print(" ".join(texts)) def runsource(self, source, filename="", symbol="single"): """Override runsource, the core of the InteractiveConsole REPL. @@ -54,28 +222,25 @@ def runsource(self, source, filename="", symbol="single"): Return True if more input is needed; buffering is done automatically. Return False if input is a complete statement ready for execution. """ - theme = get_theme(force_no_color=not self._use_color) - if not source or source.isspace(): return False if source[0] == ".": - match source[1:].strip(): - case "version": - print(f"{sqlite3.sqlite_version}") - case "help": - print("Enter SQL code and press enter.") - case "quit": - sys.exit(0) - case "": - pass - case _ as unknown: - t = theme.traceback + if line := source[1:].strip(): + try: + cmd, arg = line.split(maxsplit=1) + except ValueError: + cmd, arg = line, None + if (func := getattr(self, "do_" + cmd, None)) is not None: + func(arg) + else: + t = self._theme.traceback self.write(f'{t.type}Error{t.reset}:{t.message} unknown' - f'command or invalid arguments: "{unknown}".\n{t.reset}') + f' command or invalid arguments: "{line}".' + f' Enter ".help" for help{t.reset}\n') else: if not sqlite3.complete_statement(source): return True - execute(self._cur, source, theme=theme) + execute(self._cur, source, theme=self._theme) return False @@ -124,10 +289,6 @@ def main(*args): """).strip() theme = get_theme() - s = theme.syntax - - sys.ps1 = f"{s.prompt}sqlite> {s.reset}" - sys.ps2 = f"{s.prompt} ... {s.reset}" con = sqlite3.connect(args.filename, isolation_level=None) try: diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py index 37e0f74f688659..3d60f2fda4a825 100644 --- a/Lib/test/test_sqlite3/test_cli.py +++ b/Lib/test/test_sqlite3/test_cli.py @@ -117,6 +117,38 @@ def test_interact_version(self): self.assertEqual(out.count(self.PS2), 0) self.assertIn(sqlite3.sqlite_version, out) + def test_interact_help(self): + out, err = self.run_cli(commands=(".help",)) + self.assertEndsWith(out, self.PS1) + self.assertEqual(out.count(self.PS1), 2) + self.assertEqual(out.count(self.PS2), 0) + self.assertIn("version", out) + self.assertIn("quit", out) + self.assertIn("Documented commands (type .help ):", out) + + out, err = self.run_cli(commands=(".help help unknown", ".help unknown help")) + self.assertEndsWith(out, self.PS1) + self.assertEqual(out.count(self.PS1), 3) + self.assertEqual(out.count(self.PS2), 0) + self.assertEqual(out.count("Usage: .help [-all] [command]"), 1) + self.assertEqual(err.count("No help for 'unknown'"), 1) + + out, err = self.run_cli(commands=(".help -all",)) + self.assertEndsWith(out, self.PS1) + self.assertEqual(out.count(self.PS1), 2) + self.assertEqual(out.count(self.PS2), 0) + self.assertEqual(out.count(".help [-all] [command]\n"), 1) + self.assertEqual(out.count(".q(uit)\n"), 1) + self.assertEqual(out.count(".version\n"), 1) + + out, err = self.run_cli(commands=(".help --all",)) + self.assertEndsWith(out, self.PS1) + self.assertEqual(out.count(self.PS1), 2) + self.assertEqual(out.count(self.PS2), 0) + self.assertEqual(out.count(".help [-all] [command]\n"), 1) + self.assertEqual(out.count(".q(uit)\n"), 1) + self.assertEqual(out.count(".version\n"), 1) + def test_interact_empty_source(self): out, err = self.run_cli(commands=("", " ")) self.assertIn(self.MEMORY_DB_MSG, err) From b28f24f16e2512bf28501fb4296d3cd0c4875fb2 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Mon, 12 May 2025 23:00:18 +0800 Subject: [PATCH 2/3] Add news entry --- .../next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst diff --git a/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst b/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst new file mode 100644 index 00000000000000..610377c0ee728b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst @@ -0,0 +1,2 @@ +Make ``.help`` in the :mod:`sqlite3` command-line interface print list of +available commands and `.help ` prints help for that command. From 8dab230129e22ec6753c90cc7a06b77f03801b8c Mon Sep 17 00:00:00 2001 From: Tan Long Date: Mon, 12 May 2025 23:08:34 +0800 Subject: [PATCH 3/3] Fix news entry typo --- .../next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst b/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst index 610377c0ee728b..a7485002ee93b6 100644 --- a/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst +++ b/Misc/NEWS.d/next/Library/2025-05-12-23-00-10.gh-issue-133934.6t6Gvp.rst @@ -1,2 +1,2 @@ Make ``.help`` in the :mod:`sqlite3` command-line interface print list of -available commands and `.help ` prints help for that command. +available commands and ``.help `` prints help for that command.