Skip to content

Commit 8500c98

Browse files
committed
Improve .help in sqlite3 CLI
Make `.help` print list of available commands and `.help <command>` prints help for that command
1 parent 27ed645 commit 8500c98

File tree

2 files changed

+212
-19
lines changed

2 files changed

+212
-19
lines changed

Lib/sqlite3/__main__.py

Lines changed: 180 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,41 +41,206 @@ def execute(c, sql, suppress_errors=True, theme=theme_no_color):
4141

4242
class SqliteInteractiveConsole(InteractiveConsole):
4343
"""A simple SQLite REPL."""
44+
PS1 = "sqlite> "
45+
PS2 = " ... "
46+
ruler = "="
47+
doc_header = "Documented commands (type .help <command>):"
48+
undoc_header = "Undocumented commands:"
4449

4550
def __init__(self, connection, use_color=False):
4651
super().__init__()
4752
self._con = connection
4853
self._cur = connection.cursor()
4954
self._use_color = use_color
55+
self._theme = get_theme(force_no_color=not use_color)
56+
57+
s = self._theme.syntax
58+
sys.ps1 = f"{s.prompt}{self.PS1}{s.reset}"
59+
sys.ps2 = f"{s.prompt}{self.PS2}{s.reset}"
60+
61+
def do_version(self, _):
62+
""".version
63+
64+
Show version of the runtime SQLite library.
65+
"""
66+
print(sqlite3.sqlite_version)
67+
68+
def do_help(self, arg):
69+
""".help [-all] [command]
70+
71+
Without argument, print the list of available commands.
72+
With a command name as argument, print help about that command.
73+
With more command names as arguments, only the first one is used.
74+
"""
75+
if not arg:
76+
cmds = sorted(name[3:] for name in dir(self.__class__)
77+
if name.startswith("do_"))
78+
cmds_doc = []
79+
cmds_undoc = []
80+
for cmd in cmds:
81+
if getattr(self, f"do_{cmd}").__doc__:
82+
cmds_doc.append(cmd)
83+
else:
84+
cmds_undoc.append(cmd)
85+
self._print_commands(self.doc_header, cmds_doc, 80)
86+
self._print_commands(self.undoc_header, cmds_undoc, 80)
87+
else:
88+
arg = arg.split()[0]
89+
if arg in ("-all", "--all"):
90+
names = sorted(name for name in dir(self.__class__)
91+
if name.startswith("do_"))
92+
print(self._help_message_from_method_names(names))
93+
else:
94+
if (method := getattr(self, "do_" + arg, None)) is not None:
95+
print(self._help_message_from_doc(method.__doc__))
96+
else:
97+
self._error(f"No help for '{arg}'")
98+
99+
def do_quit(self, _):
100+
""".q(uit)
101+
102+
Exit this program.
103+
"""
104+
sys.exit(0)
105+
106+
do_q = do_quit
107+
108+
def _help_message_from_doc(self, doc):
109+
# copied from Lib/pdb.py#L2544
110+
lines = [line.strip() for line in doc.rstrip().splitlines()]
111+
if not lines:
112+
return "No help message found."
113+
if "" in lines:
114+
usage_end = lines.index("")
115+
else:
116+
usage_end = 1
117+
formatted = []
118+
indent = " " * len(self.PS1)
119+
for i, line in enumerate(lines):
120+
if i == 0:
121+
prefix = "Usage: "
122+
elif i < usage_end:
123+
prefix = " "
124+
else:
125+
prefix = ""
126+
formatted.append(indent + prefix + line)
127+
return "\n".join(formatted)
128+
129+
def _help_message_from_method_names(self, names):
130+
formatted = []
131+
indent = " " * len(self.PS1)
132+
for name in names:
133+
if not (doc := getattr(self, name).__doc__):
134+
formatted.append(f".{name[3:]}")
135+
continue
136+
lines = [line.strip() for line in doc.rstrip().splitlines()]
137+
if "" in lines:
138+
usage_end = lines.index("")
139+
else:
140+
usage_end = 1
141+
for i, line in enumerate(lines):
142+
# skip method aliases, e.g. do_q for do_quit
143+
if i == 0 and line in formatted:
144+
break
145+
elif i < usage_end:
146+
formatted.append(line)
147+
elif not line and i == usage_end:
148+
continue
149+
else:
150+
formatted.append(indent + line)
151+
return "\n".join(formatted)
152+
153+
def _error(self, msg):
154+
t = self._theme.traceback
155+
self.write(f"{t.message}{msg}{t.reset}\n")
156+
157+
def _print_commands(self, header, cmds, maxcol):
158+
# copied and modified from Lib/cmd.py#L351
159+
if cmds:
160+
print(header)
161+
if self.ruler:
162+
print(self.ruler * len(header))
163+
self._columnize(cmds, maxcol-1)
164+
print()
165+
166+
def _columnize(self, strings, displaywidth=80):
167+
"""Display a list of strings as a compact set of columns.
168+
169+
Each column is only as wide as necessary.
170+
Columns are separated by two spaces (one was not legible enough).
171+
"""
172+
# copied and modified from Lib/cmd.py#L359
173+
if not strings:
174+
print("<empty>")
175+
return
176+
177+
size = len(strings)
178+
if size == 1:
179+
print(strings[0])
180+
return
181+
# Try every row count from 1 upwards
182+
for nrows in range(1, size):
183+
ncols = (size+nrows-1) // nrows
184+
colwidths = []
185+
totwidth = -2
186+
for col in range(ncols):
187+
colwidth = 0
188+
for row in range(nrows):
189+
i = row + nrows*col
190+
if i >= size:
191+
break
192+
x = strings[i]
193+
colwidth = max(colwidth, len(x))
194+
colwidths.append(colwidth)
195+
totwidth += colwidth + 2
196+
if totwidth > displaywidth:
197+
break
198+
if totwidth <= displaywidth:
199+
break
200+
else:
201+
nrows = size
202+
ncols = 1
203+
colwidths = [0]
204+
for row in range(nrows):
205+
texts = []
206+
for col in range(ncols):
207+
i = row + nrows*col
208+
if i >= size:
209+
x = ""
210+
else:
211+
x = strings[i]
212+
texts.append(x)
213+
while texts and not texts[-1]:
214+
del texts[-1]
215+
for col in range(len(texts)):
216+
texts[col] = texts[col].ljust(colwidths[col])
217+
print(" ".join(texts))
50218

51219
def runsource(self, source, filename="<input>", symbol="single"):
52220
"""Override runsource, the core of the InteractiveConsole REPL.
53221
54222
Return True if more input is needed; buffering is done automatically.
55223
Return False if input is a complete statement ready for execution.
56224
"""
57-
theme = get_theme(force_no_color=not self._use_color)
58-
59225
if not source or source.isspace():
60226
return False
61227
if source[0] == ".":
62-
match source[1:].strip():
63-
case "version":
64-
print(f"{sqlite3.sqlite_version}")
65-
case "help":
66-
print("Enter SQL code and press enter.")
67-
case "quit":
68-
sys.exit(0)
69-
case "":
70-
pass
71-
case _ as unknown:
72-
t = theme.traceback
228+
if line := source[1:].strip():
229+
try:
230+
cmd, arg = line.split(maxsplit=1)
231+
except ValueError:
232+
cmd, arg = line, None
233+
if (func := getattr(self, "do_" + cmd, None)) is not None:
234+
func(arg)
235+
else:
236+
t = self._theme.traceback
73237
self.write(f'{t.type}Error{t.reset}:{t.message} unknown'
74-
f'command or invalid arguments: "{unknown}".\n{t.reset}')
238+
f' command or invalid arguments: "{line}".'
239+
f' Enter ".help" for help{t.reset}\n')
75240
else:
76241
if not sqlite3.complete_statement(source):
77242
return True
78-
execute(self._cur, source, theme=theme)
243+
execute(self._cur, source, theme=self._theme)
79244
return False
80245

81246

@@ -124,10 +289,6 @@ def main(*args):
124289
""").strip()
125290

126291
theme = get_theme()
127-
s = theme.syntax
128-
129-
sys.ps1 = f"{s.prompt}sqlite> {s.reset}"
130-
sys.ps2 = f"{s.prompt} ... {s.reset}"
131292

132293
con = sqlite3.connect(args.filename, isolation_level=None)
133294
try:

Lib/test/test_sqlite3/test_cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,38 @@ def test_interact_version(self):
117117
self.assertEqual(out.count(self.PS2), 0)
118118
self.assertIn(sqlite3.sqlite_version, out)
119119

120+
def test_interact_help(self):
121+
out, err = self.run_cli(commands=(".help",))
122+
self.assertEndsWith(out, self.PS1)
123+
self.assertEqual(out.count(self.PS1), 2)
124+
self.assertEqual(out.count(self.PS2), 0)
125+
self.assertIn("version", out)
126+
self.assertIn("quit", out)
127+
self.assertIn("Documented commands (type .help <command>):", out)
128+
129+
out, err = self.run_cli(commands=(".help help unknown", ".help unknown help"))
130+
self.assertEndsWith(out, self.PS1)
131+
self.assertEqual(out.count(self.PS1), 3)
132+
self.assertEqual(out.count(self.PS2), 0)
133+
self.assertEqual(out.count("Usage: .help [-all] [command]"), 1)
134+
self.assertEqual(err.count("No help for 'unknown'"), 1)
135+
136+
out, err = self.run_cli(commands=(".help -all",))
137+
self.assertEndsWith(out, self.PS1)
138+
self.assertEqual(out.count(self.PS1), 2)
139+
self.assertEqual(out.count(self.PS2), 0)
140+
self.assertEqual(out.count(".help [-all] [command]\n"), 1)
141+
self.assertEqual(out.count(".q(uit)\n"), 1)
142+
self.assertEqual(out.count(".version\n"), 1)
143+
144+
out, err = self.run_cli(commands=(".help --all",))
145+
self.assertEndsWith(out, self.PS1)
146+
self.assertEqual(out.count(self.PS1), 2)
147+
self.assertEqual(out.count(self.PS2), 0)
148+
self.assertEqual(out.count(".help [-all] [command]\n"), 1)
149+
self.assertEqual(out.count(".q(uit)\n"), 1)
150+
self.assertEqual(out.count(".version\n"), 1)
151+
120152
def test_interact_empty_source(self):
121153
out, err = self.run_cli(commands=("", " "))
122154
self.assertIn(self.MEMORY_DB_MSG, err)

0 commit comments

Comments
 (0)