From 339a8b8d95ec08726b4e8c8b91e9b30fb64fcb0b Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 5 Sep 2026 08:17:26 -0400 Subject: [PATCH] add a "/source --help" option since the list of options is now too long to show in the main /help table. If /source arguments became any more complex, it might be better to use argparse to process them. --- changelog.md | 1 + mycli/client_commands.py | 8 +++- mycli/packages/completion_engine.py | 8 ++-- mycli/packages/hybrid_redirection.py | 2 +- mycli/packages/special/source.py | 19 ++++++++- test/pytests/test_client_commands.py | 39 +++++++++++++++++-- test/pytests/test_completion_engine.py | 34 ++++++++++------ ...est_smart_completion_public_schema_only.py | 26 +++++++++---- test/pytests/test_special_source.py | 28 ++++++------- 9 files changed, 123 insertions(+), 42 deletions(-) diff --git a/changelog.md b/changelog.md index 6f76ad28..b8f7cb04 100644 --- a/changelog.md +++ b/changelog.md @@ -14,6 +14,7 @@ Bug Fixes Documentation -------- * Badge color nit in `README.md`. +* Add a `/source --help` option. Internal diff --git a/mycli/client_commands.py b/mycli/client_commands.py index b8f030a6..032852f4 100644 --- a/mycli/client_commands.py +++ b/mycli/client_commands.py @@ -18,6 +18,7 @@ from mycli.packages.ptoolkit.history import FileHistoryWithTimestamp from mycli.packages.special.main import ArgType, SpecialCommandAlias from mycli.packages.special.source import ( + SOURCE_HELP_ROWS, parse_source_arguments, parse_source_filename, source_special_command_is_safe, @@ -290,10 +291,13 @@ def change_db(self, arg: str, **_) -> Generator[SQLResult, None, None]: def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: try: - filename, allow_special, show_queries, page_output, throttle = parse_source_arguments(arg) + filename, allow_special, show_queries, page_output, throttle, show_help = parse_source_arguments(arg) except ValueError as error: yield SQLResult(status=str(error), is_error=True) return + if show_help: + yield SQLResult(header=['Argument', 'Description'], rows=SOURCE_HELP_ROWS) + return if page_output: yield SQLResult(command={'name': 'source_page'}) try: @@ -302,7 +306,7 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: yield SQLResult(status=str(error), is_error=True) return if not filename: - yield SQLResult(status="Missing required argument: filename.", is_error=True) + yield SQLResult(status="Missing required argument: filename. See /source --help.", is_error=True) return try: diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 9bd2f9f6..128dbd86 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -11,6 +11,7 @@ from mycli.packages.special.favoritequeries import FAVORITE_SUBCOMMANDS from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS from mycli.packages.special.main import parse_special_command +from mycli.packages.special.source import SOURCE_BOOLEAN_OPTIONS, SOURCE_OPTIONS from mycli.packages.sql_utils import extract_tables, find_prev_keyword, last_word sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] @@ -816,8 +817,7 @@ def suggest_special(text: str) -> list[dict[str, Any]]: 'source', '/source', ]: - source_options = ['--special', '--show', '--page', '--throttle'] - source_boolean_options = source_options[:-1] + source_options = list(SOURCE_OPTIONS) source_arguments = _arg.split() if not source_arguments: return [ @@ -829,10 +829,12 @@ def suggest_special(text: str) -> list[dict[str, Any]]: argument_index = 0 while argument_index < len(source_arguments): argument = source_arguments[argument_index] - if argument in source_boolean_options: + if argument in SOURCE_BOOLEAN_OPTIONS: used_options.add(argument) argument_index += 1 continue + if argument == '--help': + return [] if argument == '--throttle': used_options.add(argument) argument_index += 1 diff --git a/mycli/packages/hybrid_redirection.py b/mycli/packages/hybrid_redirection.py index 3e22a6f3..9b1bc519 100644 --- a/mycli/packages/hybrid_redirection.py +++ b/mycli/packages/hybrid_redirection.py @@ -68,7 +68,7 @@ def find_sql_part( if SOURCE_COMMAND_PATTERN.match(sql_part): source_arg_str = SOURCE_COMMAND_PATTERN.sub('', sql_part) try: - filename, _allow_special, _show_queries, _page_output, _throttle = parse_source_arguments(source_arg_str) + filename, _allow_special, _show_queries, _page_output, _throttle, _show_help = parse_source_arguments(source_arg_str) filename = parse_source_filename(filename) except ValueError: return '' diff --git a/mycli/packages/special/source.py b/mycli/packages/special/source.py index 5d9d4b5c..e1f4cf08 100644 --- a/mycli/packages/special/source.py +++ b/mycli/packages/special/source.py @@ -9,6 +9,19 @@ from mycli.packages.special.iocommands import expand_favorite_query INVALID_SOURCE_FILENAME = 'Source accepts exactly one filename; filenames containing spaces must be quoted.' +SOURCE_BOOLEAN_OPTIONS = ('--special', '--show', '--page') +SOURCE_OPTIONS = (*SOURCE_BOOLEAN_OPTIONS, '--throttle', '--help') +SOURCE_HELP_ROWS = [ + ('--special', 'Allow supported special /commands in the source file.'), + ('--show', 'Show each statement before executing it.'), + ('--page', 'Display all source output using the pager.'), + ( + '--throttle , --throttle=', + 'Seconds to wait between executing statements.', + ), + ('--help', 'Show this help.'), + ('', 'File containing SQL to execute.'), +] SOURCE_SAFE_SPECIAL_COMMANDS = frozenset({ 'connect', 'fd', @@ -85,7 +98,7 @@ def _parse_throttle(value: str) -> float: return throttle -def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool, float]: +def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool, float, bool]: allow_special = False show_queries = False page_output = False @@ -98,6 +111,8 @@ def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool, float]: show_queries = True elif arguments[0] == '--page': page_output = True + elif arguments[0] == '--help': + return '', allow_special, show_queries, page_output, throttle, True elif arguments[0] == '--throttle': if len(arguments) != 2: raise ValueError('Missing value for --throttle.') @@ -110,7 +125,7 @@ def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool, float]: else: break filename = arguments[1] if len(arguments) == 2 else '' - return filename, allow_special, show_queries, page_output, throttle + return filename, allow_special, show_queries, page_output, throttle, False def parse_source_filename(filename: str) -> str: diff --git a/test/pytests/test_client_commands.py b/test/pytests/test_client_commands.py index 8722fb96..0dd54e65 100644 --- a/test/pytests/test_client_commands.py +++ b/test/pytests/test_client_commands.py @@ -513,7 +513,9 @@ def test_change_db_without_argument_reports_error(monkeypatch: pytest.MonkeyPatc def test_execute_from_file_requires_filename() -> None: client = DummyClient() - assert list(client.execute_from_file('')) == [SQLResult(status='Missing required argument: filename.', is_error=True)] + assert list(client.execute_from_file('')) == [ + SQLResult(status='Missing required argument: filename. See /source --help.', is_error=True) + ] def test_execute_from_file_reports_open_errors() -> None: @@ -648,6 +650,35 @@ def test_execute_from_file_runs_file_query(tmp_path: Path) -> None: assert client.sqlexecute.runs == ['select 1;'] +def test_execute_from_file_help_is_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + client = DummyClient() + client.sqlexecute = FakeSQLExecute() + opened_paths: list[str] = [] + sleep_calls: list[float] = [] + monkeypatch.setattr(client_commands, 'open', lambda path: opened_paths.append(path), raising=False) + monkeypatch.setattr(client_commands.time, 'sleep', lambda seconds: sleep_calls.append(seconds)) + + assert list(client.execute_from_file('--page --help ignored.sql')) == [ + SQLResult( + header=['Argument', 'Description'], + rows=[ + ('--special', 'Allow supported special /commands in the source file.'), + ('--show', 'Show each statement before executing it.'), + ('--page', 'Display all source output using the pager.'), + ( + '--throttle , --throttle=', + 'Seconds to wait between executing statements.', + ), + ('--help', 'Show this help.'), + ('', 'File containing SQL to execute.'), + ], + ) + ] + assert opened_paths == [] + assert client.sqlexecute.runs == [] + assert sleep_calls == [] + + def test_execute_from_file_throttles_between_executed_statements(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: client = DummyClient() sql_file = tmp_path / 'query.sql' @@ -775,7 +806,7 @@ def open_file(path: str) -> IteratedFile: def test_execute_from_file_reports_missing_filename_after_options(options: str) -> None: client = DummyClient() - expected = [SQLResult(status='Missing required argument: filename.', is_error=True)] + expected = [SQLResult(status='Missing required argument: filename. See /source --help.', is_error=True)] if '--page' in options: expected.insert(0, SQLResult(command={'name': 'source_page'})) assert list(client.execute_from_file(options)) == expected @@ -802,7 +833,9 @@ def test_execute_from_file_pages_invalid_filename_error() -> None: def test_execute_from_file_treats_empty_quotes_as_missing_filename() -> None: client = DummyClient() - assert list(client.execute_from_file('""')) == [SQLResult(status='Missing required argument: filename.', is_error=True)] + assert list(client.execute_from_file('""')) == [ + SQLResult(status='Missing required argument: filename. See /source --help.', is_error=True) + ] def test_execute_from_file_runs_permitted_special_commands(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: diff --git a/test/pytests/test_completion_engine.py b/test/pytests/test_completion_engine.py index 6dca008d..22659a3d 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -894,7 +894,7 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): [ { 'type': 'special_subcommand', - 'subcommands': ['--special', '--show', '--page', '--throttle'], + 'subcommands': ['--special', '--show', '--page', '--throttle', '--help'], }, SOURCE_FILE_SUGGESTION, ], @@ -904,7 +904,7 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): [ { 'type': 'special_subcommand', - 'subcommands': ['--special', '--show', '--page', '--throttle'], + 'subcommands': ['--special', '--show', '--page', '--throttle', '--help'], }, SOURCE_FILE_SUGGESTION, ], @@ -914,7 +914,7 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): [ { 'type': 'special_subcommand', - 'subcommands': ['--special', '--show', '--page', '--throttle'], + 'subcommands': ['--special', '--show', '--page', '--throttle', '--help'], } ], ), @@ -922,40 +922,52 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): ( 'source --special ', [ - {'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle']}, + {'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle', '--help']}, SOURCE_FILE_SUGGESTION, ], ), ( 'source --special --s', - [{'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle']}], + [{'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle', '--help']}], ), ('source --show', []), ( 'source --show ', [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--page', '--throttle']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--page', '--throttle', '--help']}, SOURCE_FILE_SUGGESTION, ], ), ( 'source --show --special ', [ - {'type': 'special_subcommand', 'subcommands': ['--page', '--throttle']}, + {'type': 'special_subcommand', 'subcommands': ['--page', '--throttle', '--help']}, SOURCE_FILE_SUGGESTION, ], ), ( 'source --show --special --page ', - [{'type': 'special_subcommand', 'subcommands': ['--throttle']}, SOURCE_FILE_SUGGESTION], + [{'type': 'special_subcommand', 'subcommands': ['--throttle', '--help']}, SOURCE_FILE_SUGGESTION], ), + ( + 'source --h', + [ + { + 'type': 'special_subcommand', + 'subcommands': ['--special', '--show', '--page', '--throttle', '--help'], + } + ], + ), + ('source --help', []), + ('source --help ', []), + ('source --show --help ignored.sql', []), ('source --throttle', []), ('source --throttle ', []), ('source --throttle 0.25', []), ( 'source --throttle 0.25 ', [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page', '--help']}, SOURCE_FILE_SUGGESTION, ], ), @@ -963,7 +975,7 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): ( 'source --throttle=0.25 ', [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page', '--help']}, SOURCE_FILE_SUGGESTION, ], ), @@ -1926,7 +1938,7 @@ def test_source_is_file(expression): ) suggestions = suggest_type(expression, expression) assert suggestions == [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page', '--throttle']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page', '--throttle', '--help']}, SOURCE_FILE_SUGGESTION, ] diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 00b598bb..2f9a9242 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -729,33 +729,45 @@ def dummy_list_path(dir_name): [ ( 'source ', - [('--special', 0), ('--show', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [ + ('--special', 0), + ('--show', 0), + ('--page', 0), + ('--throttle', 0), + ('--help', 0), + ('/', 0), + ('~', 0), + ('.', 0), + ('..', 0), + ], ), ('source --s', [('--show', -3), ('--special', -3)]), + ('source --h', [('--help', -3)]), + ('source --help ', []), ( 'source --special ', - [('--show', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--show', 0), ('--page', 0), ('--throttle', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ( 'source --show ', - [('--special', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--special', 0), ('--page', 0), ('--throttle', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ( 'source --special --show ', - [('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--page', 0), ('--throttle', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ( 'source --special --show --page ', - [('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--throttle', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ('source --throttle ', []), ( 'source --throttle 0.25 ', - [('--special', 0), ('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--special', 0), ('--show', 0), ('--page', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ( 'source --throttle=0.25 ', - [('--special', 0), ('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + [('--special', 0), ('--show', 0), ('--page', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], ), ("source /", [("/dir1", -1), ("/file1.sql", -1), ("/file2.sql", -1)]), ('source --special /', [('/dir1', -1), ('/file1.sql', -1), ('/file2.sql', -1)]), diff --git a/test/pytests/test_special_source.py b/test/pytests/test_special_source.py index ef6a8196..b0626f41 100644 --- a/test/pytests/test_special_source.py +++ b/test/pytests/test_special_source.py @@ -7,21 +7,23 @@ @pytest.mark.parametrize( ('arg', 'expected'), [ - ('query.sql', ('query.sql', False, False, False, 0.0)), - ('--special query.sql', ('query.sql', True, False, False, 0.0)), - ('--show query.sql', ('query.sql', False, True, False, 0.0)), - ('--page query.sql', ('query.sql', False, False, True, 0.0)), - ('--special --show --page query file.sql', ('query file.sql', True, True, True, 0.0)), - ('--page --show --special query file.sql', ('query file.sql', True, True, True, 0.0)), - ('--show --show query.sql', ('query.sql', False, True, False, 0.0)), - ('--page --page query.sql', ('query.sql', False, False, True, 0.0)), - ('--show', ('', False, True, False, 0.0)), - ('--throttle 0.25 query.sql', ('query.sql', False, False, False, 0.25)), - ('--throttle=1e-2 query.sql', ('query.sql', False, False, False, 0.01)), - ('--throttle 1 --show --throttle=0.5 query.sql', ('query.sql', False, True, False, 0.5)), + ('query.sql', ('query.sql', False, False, False, 0.0, False)), + ('--special query.sql', ('query.sql', True, False, False, 0.0, False)), + ('--show query.sql', ('query.sql', False, True, False, 0.0, False)), + ('--page query.sql', ('query.sql', False, False, True, 0.0, False)), + ('--special --show --page query file.sql', ('query file.sql', True, True, True, 0.0, False)), + ('--page --show --special query file.sql', ('query file.sql', True, True, True, 0.0, False)), + ('--show --show query.sql', ('query.sql', False, True, False, 0.0, False)), + ('--page --page query.sql', ('query.sql', False, False, True, 0.0, False)), + ('--show', ('', False, True, False, 0.0, False)), + ('--throttle 0.25 query.sql', ('query.sql', False, False, False, 0.25, False)), + ('--throttle=1e-2 query.sql', ('query.sql', False, False, False, 0.01, False)), + ('--throttle 1 --show --throttle=0.5 query.sql', ('query.sql', False, True, False, 0.5, False)), + ('--help', ('', False, False, False, 0.0, True)), + ('--show --help ignored.sql', ('', False, True, False, 0.0, True)), ], ) -def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool, bool, float]) -> None: +def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool, bool, float, bool]) -> None: assert source.parse_source_arguments(arg) == expected