Skip to content

Commit c1c482e

Browse files
authored
Merge pull request #1859 from dbcli/RW/help-on-special-commands
Respond to `help <term>` on builtin special commands
2 parents f37f737 + 2530487 commit c1c482e

6 files changed

Lines changed: 133 additions & 8 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Upcoming (TBD)
44
Features
55
---------
66
* Add more output to the `status` command.
7+
* Respond to `help <term>` on builtin special commands.
78

89

910
Documentation

mycli/packages/special/iocommands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ def editor_command(command: str) -> bool:
190190
Is this an external editor command?
191191
:param command: string
192192
"""
193+
# special case: allow help on the \edit command
194+
if re.match(r'^([Hh][Ee][Ll][Pp])\s+(\\e|\\edit)\s*(;|\\G|\\g)?\s*$', command):
195+
return False
193196
# It is possible to have `\e filename` or `SELECT * FROM \e`. So we check
194197
# for both conditions.
195198
return (

mycli/packages/special/main.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
logger = logging.getLogger(__name__)
2323

2424
COMMANDS = {}
25+
CASE_SENSITIVE_COMMANDS = set()
26+
CASE_INSENSITIVE_COMMANDS = set()
2527

2628
SpecialCommand = namedtuple(
2729
"SpecialCommand",
@@ -111,9 +113,17 @@ def register_special_command(
111113
case_sensitive=case_sensitive,
112114
shortcut=aliases[0] if aliases else None,
113115
)
116+
if case_sensitive:
117+
CASE_SENSITIVE_COMMANDS.add(command)
118+
else:
119+
CASE_INSENSITIVE_COMMANDS.add(command.lower())
114120
aliases = [] if aliases is None else aliases
115121
for alias in aliases:
116122
cmd = alias.lower() if not case_sensitive else alias
123+
if case_sensitive:
124+
CASE_SENSITIVE_COMMANDS.add(alias)
125+
else:
126+
CASE_INSENSITIVE_COMMANDS.add(alias.lower())
117127
COMMANDS[cmd] = SpecialCommand(
118128
handler,
119129
command,
@@ -132,7 +142,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]:
132142
"""
133143
command, command_verbosity, arg = parse_special_command(sql)
134144

135-
if (command not in COMMANDS) and (command.lower() not in COMMANDS):
145+
if (command not in CASE_SENSITIVE_COMMANDS) and (command.lower() not in CASE_INSENSITIVE_COMMANDS):
136146
raise CommandNotFound(f'Command not found: {command}')
137147

138148
try:
@@ -144,7 +154,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]:
144154

145155
# "help <SQL KEYWORD> is a special case. We want built-in help, not
146156
# mycli help here.
147-
if command == "help" and arg:
157+
if command.lower() == "help" and arg:
148158
return show_keyword_help(cur=cur, arg=arg)
149159

150160
if special_cmd.arg_type == ArgType.NO_QUERY:
@@ -157,9 +167,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]:
157167
raise CommandNotFound(f"Command type not found: {command}")
158168

159169

160-
@special_command(
161-
"help", "help [term]", "Show this help, or search for a term on the server.", arg_type=ArgType.NO_QUERY, aliases=["\\?", "?"]
162-
)
170+
@special_command("help", "help [term]", "Show this table, or search for help on a term.", arg_type=ArgType.NO_QUERY, aliases=["\\?", "?"])
163171
def show_help(*_args) -> list[SQLResult]:
164172
header = ["Command", "Shortcut", "Usage", "Description"]
165173
result = []
@@ -170,14 +178,20 @@ def show_help(*_args) -> list[SQLResult]:
170178
return [SQLResult(header=header, rows=result, postamble=f'Docs index — {DOCS_URL}')]
171179

172180

173-
def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]:
181+
def _show_special_help(keyword: str) -> list[SQLResult]:
182+
header = ['name', 'description', 'example']
183+
description = '\n'.join(COMMANDS[keyword][2:4])
184+
rows = [(keyword, description, '')]
185+
return [SQLResult(header=header, rows=rows)]
186+
187+
188+
def _show_mysql_help(cur: Cursor, keyword: str) -> list[SQLResult]:
174189
"""
175190
Call the built-in "show <keyword>", to display help for an SQL keyword.
176191
:param cur: cursor
177192
:param arg: string
178193
:return: list
179194
"""
180-
keyword = arg.strip().strip('"\'')
181195
query = 'help %s'
182196
logger.debug(query)
183197
cur.execute(query, keyword)
@@ -193,6 +207,17 @@ def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]:
193207
return [SQLResult(status=f'No help found for "{keyword}".')]
194208

195209

210+
def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]:
211+
keyword = arg.strip().strip('"').strip("'").rstrip('+-')
212+
213+
if keyword in CASE_SENSITIVE_COMMANDS:
214+
return _show_special_help(keyword)
215+
elif keyword.lower() in CASE_INSENSITIVE_COMMANDS:
216+
return _show_special_help(keyword.lower())
217+
218+
return _show_mysql_help(cur, keyword)
219+
220+
196221
@special_command('\\bug', '\\bug', 'File a bug on GitHub.', arg_type=ArgType.NO_QUERY)
197222
def file_bug(*_args) -> list[SQLResult]:
198223
webbrowser.open_new_tab(ISSUES_URL)

test/features/fixture_data/help_commands.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
| connect | \r | connect [database] | Reconnect to the server, optionally switching databases. |
1818
| delimiter | <null> | delimiter <string> | Change end-of-statement delimiter. |
1919
| exit | \q | exit | Exit. |
20-
| help | \? | help [term] | Show this help, or search for a term on the server. |
20+
| help | \? | help [term] | Show this table, or search for help on a term. |
2121
| nopager | \n | nopager | Disable pager; print to stdout. |
2222
| notee | <null> | notee | Stop writing results to an output file. |
2323
| nowarnings | \w | nowarnings | Disable automatic warnings display. |

test/pytests/test_special_iocommands.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ def test_editor_command(monkeypatch):
174174
assert mycli.packages.special.editor_command(r"\e hello")
175175
assert mycli.packages.special.editor_command(r"\edit hello")
176176

177+
assert not mycli.packages.special.editor_command(r"HELP \e")
178+
assert not mycli.packages.special.editor_command(r"help \edit\g")
177179
assert not mycli.packages.special.editor_command(r"hello")
178180
assert not mycli.packages.special.editor_command(r"\ehello")
179181
assert not mycli.packages.special.editor_command(r"\edithello")
@@ -464,6 +466,11 @@ def test_simple_setters_and_toggle_timing() -> None:
464466
iocommands.set_show_favorite_query(False)
465467
assert iocommands.is_show_favorite_query() is False
466468

469+
iocommands.set_show_warnings_enabled(True)
470+
assert iocommands.is_show_warnings_enabled() is True
471+
iocommands.set_show_warnings_enabled(False)
472+
assert iocommands.is_show_warnings_enabled() is False
473+
467474
iocommands.set_destructive_keywords(['drop'])
468475
assert iocommands.DESTRUCTIVE_KEYWORDS == ['drop']
469476

test/pytests/test_special_main.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@
1616
@pytest.fixture
1717
def restore_commands() -> Iterator[None]:
1818
original_commands = special_main.COMMANDS.copy()
19+
original_case_sensitive_commands = special_main.CASE_SENSITIVE_COMMANDS.copy()
20+
original_case_insensitive_commands = special_main.CASE_INSENSITIVE_COMMANDS.copy()
1921
try:
2022
yield
2123
finally:
2224
special_main.COMMANDS.clear()
2325
special_main.COMMANDS.update(original_commands)
26+
special_main.CASE_SENSITIVE_COMMANDS.clear()
27+
special_main.CASE_SENSITIVE_COMMANDS.update(original_case_sensitive_commands)
28+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
29+
special_main.CASE_INSENSITIVE_COMMANDS.update(original_case_insensitive_commands)
2430

2531

2632
class FakeHelpCursor:
@@ -100,14 +106,35 @@ def handler() -> None:
100106
)
101107

102108

109+
def test_register_special_command_tracks_case_insensitive_commands(restore_commands: None) -> None:
110+
special_main.COMMANDS.clear()
111+
special_main.CASE_SENSITIVE_COMMANDS.clear()
112+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
113+
114+
special_main.register_special_command(
115+
lambda: None,
116+
'Demo',
117+
'demo',
118+
'Description',
119+
aliases=['\\d'],
120+
)
121+
122+
assert special_main.CASE_SENSITIVE_COMMANDS == set()
123+
assert special_main.CASE_INSENSITIVE_COMMANDS == {'demo', '\\d'}
124+
125+
103126
def test_special_command_decorator_registers_case_sensitive_command(restore_commands: None) -> None:
104127
special_main.COMMANDS.clear()
128+
special_main.CASE_SENSITIVE_COMMANDS.clear()
129+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
105130

106131
@special_main.special_command('Camel', 'Camel', 'Description', case_sensitive=True)
107132
def handler() -> None:
108133
return None
109134

110135
assert special_main.COMMANDS['Camel'].handler is handler
136+
assert 'Camel' in special_main.CASE_SENSITIVE_COMMANDS
137+
assert special_main.CASE_INSENSITIVE_COMMANDS == set()
111138
assert 'camel' not in special_main.COMMANDS
112139

113140

@@ -139,6 +166,26 @@ def test_execute_raises_for_case_sensitive_alias_lookup(restore_commands: None)
139166
special_main.execute(cast(Any, None), 'DEMO')
140167

141168

169+
def test_execute_raises_when_case_sensitive_exact_lookup_falls_back_to_lowercase(restore_commands: None) -> None:
170+
special_main.COMMANDS.clear()
171+
special_main.CASE_SENSITIVE_COMMANDS.clear()
172+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
173+
special_main.COMMANDS['camel'] = special_main.SpecialCommand(
174+
lambda: None,
175+
'Camel',
176+
'Camel',
177+
'Description',
178+
arg_type=special_main.ArgType.NO_QUERY,
179+
hidden=False,
180+
case_sensitive=True,
181+
shortcut=None,
182+
)
183+
special_main.CASE_SENSITIVE_COMMANDS.add('Camel')
184+
185+
with pytest.raises(special_main.CommandNotFound, match='Command not found: Camel'):
186+
special_main.execute(cast(Any, None), 'Camel')
187+
188+
142189
def test_execute_dispatches_no_query_command(restore_commands: None) -> None:
143190
calls: list[str] = []
144191

@@ -236,8 +283,24 @@ def fake_show_keyword_help(cur: object, arg: str) -> list[SQLResult]:
236283
assert calls == [(cur, 'select')]
237284

238285

286+
def test_execute_routes_uppercase_help_with_argument_to_keyword_help(monkeypatch) -> None:
287+
calls: list[tuple[object, str]] = []
288+
289+
def fake_show_keyword_help(cur: object, arg: str) -> list[SQLResult]:
290+
calls.append((cur, arg))
291+
return [SQLResult(status='keyword')]
292+
293+
monkeypatch.setattr(special_main, 'show_keyword_help', fake_show_keyword_help)
294+
295+
cur = object()
296+
assert special_main.execute(cast(Any, cur), 'HELP select') == [SQLResult(status='keyword')]
297+
assert calls == [(cur, 'select')]
298+
299+
239300
def test_execute_raises_for_unknown_arg_type(restore_commands: None) -> None:
240301
special_main.COMMANDS.clear()
302+
special_main.CASE_SENSITIVE_COMMANDS.clear()
303+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
241304
special_main.COMMANDS['demo'] = special_main.SpecialCommand(
242305
lambda: None,
243306
'demo',
@@ -248,6 +311,7 @@ def test_execute_raises_for_unknown_arg_type(restore_commands: None) -> None:
248311
case_sensitive=False,
249312
shortcut=None,
250313
)
314+
special_main.CASE_INSENSITIVE_COMMANDS.add('demo')
251315

252316
with pytest.raises(special_main.CommandNotFound, match='Command type not found: demo'):
253317
special_main.execute(cast(Any, None), 'demo')
@@ -265,6 +329,31 @@ def test_show_help_lists_only_visible_commands(restore_commands: None) -> None:
265329
assert result.postamble == f'Docs index — {DOCS_URL}'
266330

267331

332+
def test_show_keyword_help_for_special_command(restore_commands: None) -> None:
333+
special_main.COMMANDS.clear()
334+
special_main.CASE_SENSITIVE_COMMANDS.clear()
335+
special_main.CASE_INSENSITIVE_COMMANDS.clear()
336+
special_main.register_special_command(lambda: None, 'demo', 'demo <arg>', 'Demo command')
337+
338+
result = special_main.show_keyword_help(cast(Any, None), 'demo+')[0]
339+
340+
assert result.header == ['name', 'description', 'example']
341+
assert result.rows == [('demo', 'demo <arg>\nDemo command', '')]
342+
343+
344+
def test_show_keyword_help_for_case_sensitive_special_alias() -> None:
345+
result = special_main.show_keyword_help(cast(Any, None), r'\e')[0]
346+
347+
assert result.header == ['name', 'description', 'example']
348+
assert result.rows == [
349+
(
350+
r'\e',
351+
'<query>\\edit | \\edit <filename>\nEdit query with editor (uses $VISUAL or $EDITOR).',
352+
'',
353+
)
354+
]
355+
356+
268357
def test_show_keyword_help_exact_match() -> None:
269358
cur = FakeHelpCursor([
270359
{'description': [('name', None)], 'rowcount': 1},

0 commit comments

Comments
 (0)