Skip to content

gh-133934: Improve .help in the sqlite3 CLI #133935

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 180 additions & 19 deletions Lib/sqlite3/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,41 +41,206 @@ def execute(c, sql, suppress_errors=True, theme=theme_no_color):

class SqliteInteractiveConsole(InteractiveConsole):
"""A simple SQLite REPL."""
PS1 = "sqlite> "
PS2 = " ... "
Comment on lines +44 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove these and use sys.ps1 like before, this would break current behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self.PS1 is needed by this line to get the length of plain prompt string. The length can't be obtained through sys.ps1 because sys.ps1 has surrounding control sequences for coloring.

I tested it and it doesn't break current behavior.

ruler = "="
doc_header = "Documented commands (type .help <command>):"
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Show version of the runtime SQLite library.
Print version of the runtime SQLite library.

This is the more common way of describing this. And also this does not need a new function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as explained at #133935 (comment), we do need a function here.

"""
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"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not have both. Please only keep the -- option, we could have -a too but it is unnecessary IMO

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}'")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use print and add the theme normally IMO


def do_quit(self, _):
""".q(uit)

Exit this program.
"""
sys.exit(0)
Comment on lines +99 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed?

Copy link
Contributor Author

@tanloong tanloong May 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's just a one-liner we still need to put it in a function with a do_ prefix to mark it as a dot command. That way the quit command is accessible by .help.


do_q = do_quit

def _help_message_from_doc(self, doc):
# copied from Lib/pdb.py#L2544
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will change, we don't need it anyway for inter-stdlib copies

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

if not strings:
print("<empty>")
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="<input>", symbol="single"):
"""Override runsource, the core of the InteractiveConsole REPL.

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


Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions Lib/test/test_sqlite3/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>):", 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Make ``.help`` in the :mod:`sqlite3` command-line interface print list of
available commands and ``.help <command>`` prints help for that command.
Comment on lines +1 to +2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Make ``.help`` in the :mod:`sqlite3` command-line interface print list of
available commands and ``.help <command>`` prints help for that command.
Make ``.help`` in the :mod:`sqlite3` command-line interface print a list of
available commands and ``.help <command>`` print help for that command.

Loading