diff --git a/changelog.md b/changelog.md index ae52dda6..2f19a7f0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,11 @@ Upcoming (TBD) ============== +Features +--------- +* Respond to `help ` on builtin special commands. + + Internal --------- * Remove unused fixture data. diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 2547286e..2678a511 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -190,6 +190,9 @@ def editor_command(command: str) -> bool: Is this an external editor command? :param command: string """ + # special case: allow help on the \edit command + if re.match(r'^([Hh][Ee][Ll][Pp])\s+(\\e|\\edit)\s*(;|\\G|\\g)?\s*$', command): + return False # It is possible to have `\e filename` or `SELECT * FROM \e`. So we check # for both conditions. return ( diff --git a/mycli/packages/special/main.py b/mycli/packages/special/main.py index 82f306f2..28782b75 100644 --- a/mycli/packages/special/main.py +++ b/mycli/packages/special/main.py @@ -22,6 +22,8 @@ logger = logging.getLogger(__name__) COMMANDS = {} +CASE_SENSITIVE_COMMANDS = set() +CASE_INSENSITIVE_COMMANDS = set() SpecialCommand = namedtuple( "SpecialCommand", @@ -111,9 +113,17 @@ def register_special_command( case_sensitive=case_sensitive, shortcut=aliases[0] if aliases else None, ) + if case_sensitive: + CASE_SENSITIVE_COMMANDS.add(command) + else: + CASE_INSENSITIVE_COMMANDS.add(command.lower()) aliases = [] if aliases is None else aliases for alias in aliases: cmd = alias.lower() if not case_sensitive else alias + if case_sensitive: + CASE_SENSITIVE_COMMANDS.add(alias) + else: + CASE_INSENSITIVE_COMMANDS.add(alias.lower()) COMMANDS[cmd] = SpecialCommand( handler, command, @@ -132,7 +142,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]: """ command, command_verbosity, arg = parse_special_command(sql) - if (command not in COMMANDS) and (command.lower() not in COMMANDS): + if (command not in CASE_SENSITIVE_COMMANDS) and (command.lower() not in CASE_INSENSITIVE_COMMANDS): raise CommandNotFound(f'Command not found: {command}') try: @@ -144,7 +154,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]: # "help is a special case. We want built-in help, not # mycli help here. - if command == "help" and arg: + if command.lower() == "help" and arg: return show_keyword_help(cur=cur, arg=arg) if special_cmd.arg_type == ArgType.NO_QUERY: @@ -157,9 +167,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]: raise CommandNotFound(f"Command type not found: {command}") -@special_command( - "help", "help [term]", "Show this help, or search for a term on the server.", arg_type=ArgType.NO_QUERY, aliases=["\\?", "?"] -) +@special_command("help", "help [term]", "Show this table, or search for help on a term.", arg_type=ArgType.NO_QUERY, aliases=["\\?", "?"]) def show_help(*_args) -> list[SQLResult]: header = ["Command", "Shortcut", "Usage", "Description"] result = [] @@ -170,14 +178,20 @@ def show_help(*_args) -> list[SQLResult]: return [SQLResult(header=header, rows=result, postamble=f'Docs index — {DOCS_URL}')] -def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]: +def _show_special_help(keyword: str) -> list[SQLResult]: + header = ['name', 'description', 'example'] + description = '\n'.join(COMMANDS[keyword][2:4]) + rows = [(keyword, description, '')] + return [SQLResult(header=header, rows=rows)] + + +def _show_mysql_help(cur: Cursor, keyword: str) -> list[SQLResult]: """ Call the built-in "show ", to display help for an SQL keyword. :param cur: cursor :param arg: string :return: list """ - keyword = arg.strip().strip('"\'') query = 'help %s' logger.debug(query) cur.execute(query, keyword) @@ -193,6 +207,17 @@ def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]: return [SQLResult(status=f'No help found for "{keyword}".')] +def show_keyword_help(cur: Cursor, arg: str) -> list[SQLResult]: + keyword = arg.strip().strip('"').strip("'").rstrip('+-') + + if keyword in CASE_SENSITIVE_COMMANDS: + return _show_special_help(keyword) + elif keyword.lower() in CASE_INSENSITIVE_COMMANDS: + return _show_special_help(keyword.lower()) + + return _show_mysql_help(cur, keyword) + + @special_command('\\bug', '\\bug', 'File a bug on GitHub.', arg_type=ArgType.NO_QUERY) def file_bug(*_args) -> list[SQLResult]: webbrowser.open_new_tab(ISSUES_URL) diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index 0d317eda..248f767f 100644 --- a/test/features/fixture_data/help_commands.txt +++ b/test/features/fixture_data/help_commands.txt @@ -17,7 +17,7 @@ | connect | \r | connect [database] | Reconnect to the server, optionally switching databases. | | delimiter | | delimiter | Change end-of-statement delimiter. | | exit | \q | exit | Exit. | -| help | \? | help [term] | Show this help, or search for a term on the server. | +| help | \? | help [term] | Show this table, or search for help on a term. | | nopager | \n | nopager | Disable pager; print to stdout. | | notee | | notee | Stop writing results to an output file. | | nowarnings | \w | nowarnings | Disable automatic warnings display. | diff --git a/test/pytests/test_special_iocommands.py b/test/pytests/test_special_iocommands.py index bbc6f408..c4f6a53e 100644 --- a/test/pytests/test_special_iocommands.py +++ b/test/pytests/test_special_iocommands.py @@ -174,6 +174,8 @@ def test_editor_command(monkeypatch): assert mycli.packages.special.editor_command(r"\e hello") assert mycli.packages.special.editor_command(r"\edit hello") + assert not mycli.packages.special.editor_command(r"HELP \e") + assert not mycli.packages.special.editor_command(r"help \edit\g") assert not mycli.packages.special.editor_command(r"hello") assert not mycli.packages.special.editor_command(r"\ehello") assert not mycli.packages.special.editor_command(r"\edithello") @@ -464,6 +466,11 @@ def test_simple_setters_and_toggle_timing() -> None: iocommands.set_show_favorite_query(False) assert iocommands.is_show_favorite_query() is False + iocommands.set_show_warnings_enabled(True) + assert iocommands.is_show_warnings_enabled() is True + iocommands.set_show_warnings_enabled(False) + assert iocommands.is_show_warnings_enabled() is False + iocommands.set_destructive_keywords(['drop']) assert iocommands.DESTRUCTIVE_KEYWORDS == ['drop'] diff --git a/test/pytests/test_special_main.py b/test/pytests/test_special_main.py index bd6ed9a4..42fcf4b7 100644 --- a/test/pytests/test_special_main.py +++ b/test/pytests/test_special_main.py @@ -16,11 +16,17 @@ @pytest.fixture def restore_commands() -> Iterator[None]: original_commands = special_main.COMMANDS.copy() + original_case_sensitive_commands = special_main.CASE_SENSITIVE_COMMANDS.copy() + original_case_insensitive_commands = special_main.CASE_INSENSITIVE_COMMANDS.copy() try: yield finally: special_main.COMMANDS.clear() special_main.COMMANDS.update(original_commands) + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.update(original_case_sensitive_commands) + special_main.CASE_INSENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.update(original_case_insensitive_commands) class FakeHelpCursor: @@ -100,14 +106,35 @@ def handler() -> None: ) +def test_register_special_command_tracks_case_insensitive_commands(restore_commands: None) -> None: + special_main.COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.clear() + + special_main.register_special_command( + lambda: None, + 'Demo', + 'demo', + 'Description', + aliases=['\\d'], + ) + + assert special_main.CASE_SENSITIVE_COMMANDS == set() + assert special_main.CASE_INSENSITIVE_COMMANDS == {'demo', '\\d'} + + def test_special_command_decorator_registers_case_sensitive_command(restore_commands: None) -> None: special_main.COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.clear() @special_main.special_command('Camel', 'Camel', 'Description', case_sensitive=True) def handler() -> None: return None assert special_main.COMMANDS['Camel'].handler is handler + assert 'Camel' in special_main.CASE_SENSITIVE_COMMANDS + assert special_main.CASE_INSENSITIVE_COMMANDS == set() assert 'camel' not in special_main.COMMANDS @@ -139,6 +166,26 @@ def test_execute_raises_for_case_sensitive_alias_lookup(restore_commands: None) special_main.execute(cast(Any, None), 'DEMO') +def test_execute_raises_when_case_sensitive_exact_lookup_falls_back_to_lowercase(restore_commands: None) -> None: + special_main.COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.clear() + special_main.COMMANDS['camel'] = special_main.SpecialCommand( + lambda: None, + 'Camel', + 'Camel', + 'Description', + arg_type=special_main.ArgType.NO_QUERY, + hidden=False, + case_sensitive=True, + shortcut=None, + ) + special_main.CASE_SENSITIVE_COMMANDS.add('Camel') + + with pytest.raises(special_main.CommandNotFound, match='Command not found: Camel'): + special_main.execute(cast(Any, None), 'Camel') + + def test_execute_dispatches_no_query_command(restore_commands: None) -> None: calls: list[str] = [] @@ -236,8 +283,24 @@ def fake_show_keyword_help(cur: object, arg: str) -> list[SQLResult]: assert calls == [(cur, 'select')] +def test_execute_routes_uppercase_help_with_argument_to_keyword_help(monkeypatch) -> None: + calls: list[tuple[object, str]] = [] + + def fake_show_keyword_help(cur: object, arg: str) -> list[SQLResult]: + calls.append((cur, arg)) + return [SQLResult(status='keyword')] + + monkeypatch.setattr(special_main, 'show_keyword_help', fake_show_keyword_help) + + cur = object() + assert special_main.execute(cast(Any, cur), 'HELP select') == [SQLResult(status='keyword')] + assert calls == [(cur, 'select')] + + def test_execute_raises_for_unknown_arg_type(restore_commands: None) -> None: special_main.COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.clear() special_main.COMMANDS['demo'] = special_main.SpecialCommand( lambda: None, 'demo', @@ -248,6 +311,7 @@ def test_execute_raises_for_unknown_arg_type(restore_commands: None) -> None: case_sensitive=False, shortcut=None, ) + special_main.CASE_INSENSITIVE_COMMANDS.add('demo') with pytest.raises(special_main.CommandNotFound, match='Command type not found: demo'): special_main.execute(cast(Any, None), 'demo') @@ -265,6 +329,31 @@ def test_show_help_lists_only_visible_commands(restore_commands: None) -> None: assert result.postamble == f'Docs index — {DOCS_URL}' +def test_show_keyword_help_for_special_command(restore_commands: None) -> None: + special_main.COMMANDS.clear() + special_main.CASE_SENSITIVE_COMMANDS.clear() + special_main.CASE_INSENSITIVE_COMMANDS.clear() + special_main.register_special_command(lambda: None, 'demo', 'demo ', 'Demo command') + + result = special_main.show_keyword_help(cast(Any, None), 'demo+')[0] + + assert result.header == ['name', 'description', 'example'] + assert result.rows == [('demo', 'demo \nDemo command', '')] + + +def test_show_keyword_help_for_case_sensitive_special_alias() -> None: + result = special_main.show_keyword_help(cast(Any, None), r'\e')[0] + + assert result.header == ['name', 'description', 'example'] + assert result.rows == [ + ( + r'\e', + '\\edit | \\edit \nEdit query with editor (uses $VISUAL or $EDITOR).', + '', + ) + ] + + def test_show_keyword_help_exact_match() -> None: cur = FakeHelpCursor([ {'description': [('name', None)], 'rowcount': 1},