From b46d294fe5fd5316f22432a4017775af5a0555b9 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 22 Aug 2026 11:03:19 -0400 Subject: [PATCH] add /source --show to display queries before run Incidentally change use uses of to to tighten up the width of the /help table. --- changelog.md | 1 + mycli/TIPS | 8 +- mycli/client_commands.py | 26 ++++-- mycli/packages/completion_engine.py | 27 ++++-- mycli/packages/special/iocommands.py | 4 +- mycli/packages/special/main.py | 2 +- test/features/fixture_data/help_commands.txt | 8 +- test/pytests/test_client_commands.py | 90 ++++++++++++++++--- test/pytests/test_completion_engine.py | 22 +++-- ...est_smart_completion_public_schema_only.py | 15 ++-- test/pytests/test_special_main.py | 2 +- 11 files changed, 154 insertions(+), 51 deletions(-) diff --git a/changelog.md b/changelog.md index 5b89d10c0..816176b8e 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Features --------- * Advertise beta `--boundary-id` option in helpdoc and completions. * Add `/source --special` to allow executing some special commands. +* Add `/source --show` to display each query before executing it. Bug Fixes diff --git a/mycli/TIPS b/mycli/TIPS index 07b65be4c..d078d57a9 100644 --- a/mycli/TIPS +++ b/mycli/TIPS @@ -54,7 +54,7 @@ copy the previous query to the clipboard with /clip! edit a query in an external editor using \edit! -edit a query in an external editor using /edit ! +edit a query in an external editor using /edit ! /f lists favorite queries; /f executes a favorite! @@ -68,7 +68,7 @@ Manage favorite queries with /favorite! /l lists databases! -/once appends the next result to ! +/once appends the next result to ! /| sends the next result to a subprocess! @@ -306,9 +306,9 @@ customize password sources/precedence with "password_sources" in ~/.myclirc! redirect query output to a shell command with "$| "! -redirect query output to a CSV file with "$> "! +redirect query output to a CSV file with "$> "! -append query output to a CSV file with "$>> "! +append query output to a CSV file with "$>> "! run a command after shell redirects with "post_redirect_command" in ~/.myclirc! diff --git a/mycli/client_commands.py b/mycli/client_commands.py index a4ca46d62..a7d7b1f19 100644 --- a/mycli/client_commands.py +++ b/mycli/client_commands.py @@ -63,11 +63,19 @@ def _render_config_value(value: Any) -> str: return str(value) -def _parse_source_arguments(arg: str) -> tuple[str, bool]: - arguments = arg.split(maxsplit=1) - if arguments and arguments[0] == '--special': - return (arguments[1] if len(arguments) == 2 else '', True) - return (arg, False) +def _parse_source_arguments(arg: str) -> tuple[str, bool, bool]: + allow_special = False + show_queries = False + filename = arg + while arguments := filename.split(maxsplit=1): + if arguments[0] == '--special': + allow_special = True + elif arguments[0] == '--show': + show_queries = True + else: + break + filename = arguments[1] if len(arguments) == 2 else '' + return filename, allow_special, show_queries def _registered_special_command(query: str) -> tuple[str, str] | None: @@ -218,7 +226,7 @@ def register_special_commands(self) -> None: special.register_special_command( self.execute_from_file, "source", - "/source [--special] ", + "/source [--special] [--show] ", "Execute queries from a file.", aliases=[SpecialCommandAlias("\\.", case_sensitive=False)], ) @@ -341,7 +349,7 @@ def change_db(self, arg: str, **_) -> Generator[SQLResult, None, None]: yield SQLResult(status=msg) def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: - filename, allow_special = _parse_source_arguments(arg) + filename, allow_special, show_queries = _parse_source_arguments(arg) if not filename: yield SQLResult(status="Missing required argument: filename.") return @@ -379,11 +387,15 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: is_error=True, ) return + if show_queries: + click.secho(f'> {special_query}') yield from self.sqlexecute.run(special_query) continue if self.destructive_warning and confirm_destructive_query(self.destructive_keywords, query) is False: continue + if show_queries: + click.secho(f'> {query}') yield from self.sqlexecute.run(query) def change_prompt_format(self, arg: str, **_) -> list[SQLResult]: diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 534e67737..2c6246fc9 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -812,17 +812,32 @@ def suggest_special(text: str) -> list[dict[str, Any]]: 'source', '/source', ]: - source_arguments = _arg.split(maxsplit=1) + source_options = ['--special', '--show'] + source_arguments = _arg.split() if not source_arguments: return [ - {'type': 'special_subcommand', 'subcommands': ['--special']}, + {'type': 'special_subcommand', 'subcommands': source_options}, {'type': 'file_name'}, ] - if source_arguments[0].startswith('-') and source_arguments[0] != '--special': - return [{'type': 'special_subcommand', 'subcommands': ['--special']}] - if source_arguments[0] == '--special' and len(source_arguments) == 1 and not text[-1].isspace(): + + used_options: set[str] = set() + argument_index = 0 + while argument_index < len(source_arguments) and source_arguments[argument_index] in source_options: + used_options.add(source_arguments[argument_index]) + argument_index += 1 + remaining_options = [option for option in source_options if option not in used_options] + + if argument_index < len(source_arguments): + if source_arguments[argument_index].startswith('-'): + return [{'type': 'special_subcommand', 'subcommands': remaining_options}] + return [{'type': 'file_name'}] + if not text[-1].isspace(): return [] - return [{'type': 'file_name'}] + suggestions = [] + if remaining_options: + suggestions.append({'type': 'special_subcommand', 'subcommands': remaining_options}) + suggestions.append({'type': 'file_name'}) + return suggestions if cmd.lower() in [ r'\o', diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 8e3120659..5cf440cef 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -813,7 +813,7 @@ def parseargfile(arg: str) -> tuple[str, str]: @special_command( "tee", - "/tee [-o] ", + "/tee [-o] ", "Append all results to an output file (overwrite using -o).", ) def set_tee(arg: str, **_) -> list[SQLResult]: @@ -856,7 +856,7 @@ def write_tee(output: str | ANSI | FormattedText, nl: bool = True) -> None: @special_command( "\\once", - "/once [-o] ", + "/once [-o] ", "Append next result to an output file (overwrite using -o).", aliases=[SpecialCommandAlias("\\o", case_sensitive=False)], ) diff --git a/mycli/packages/special/main.py b/mycli/packages/special/main.py index f40ac018b..1d95fbc7c 100644 --- a/mycli/packages/special/main.py +++ b/mycli/packages/special/main.py @@ -314,7 +314,7 @@ def quit_(*_args): @special_command( "\\edit", - "/edit | \\edit", + "/edit | \\edit", "Edit query with editor (uses $VISUAL or $EDITOR).", arg_type=ArgType.NO_ARGUMENT, case_sensitive=True, diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index 44d68a22a..b9b3a9298 100644 --- a/test/features/fixture_data/help_commands.txt +++ b/test/features/fixture_data/help_commands.txt @@ -8,7 +8,7 @@ | /delimiter | | /delimiter | Change end-of-statement delimiter. | | /dsn | | /dsn | Manage saved DSNs. See /dsn help. | | /dt | | /dt[+] [table] | List or describe tables. | -| /edit | /e | /edit | \edit | Edit query with editor (uses $VISUAL or $EDITOR). | +| /edit | /e | /edit | \edit | Edit query with editor (uses $VISUAL or $EDITOR). | | /exit | /q | /exit | Exit. | | /f | | /f [name [args..] [--key=value]] | List or execute favorite queries. | | /favorite | | /favorite | Alternative favorite query interface. See /favorite help. | @@ -22,18 +22,18 @@ | /nopager | /n | /nopager | Disable pager; print to stdout. | | /notee | | /notee | Stop writing results to an output file. | | /nowarnings | /w | /nowarnings | Disable automatic warnings display. | -| /once | /o | /once [-o] | Append next result to an output file (overwrite using -o). | +| /once | /o | /once [-o] | Append next result to an output file (overwrite using -o). | | /pager | /P | /pager [command] | Set pager to [command]. Print query results via pager. | | /pipe_once | /| | /pipe_once | Send next result to a subprocess. | | /prompt | /R | /prompt [string] | Show or change prompt format. | | /quit | /q | /quit | Quit. | | /redirectformat | /Tr | /redirectformat | Change the table format used to output redirected results. | | /rehash | /# | /rehash | Refresh auto-completions. | -| /source | /. | /source [--special] | Execute queries from a file. | +| /source | /. | /source [--special] [--show] | Execute queries from a file. | | /status | /s | /status | Get status information from the server. | | /system | | /system [-r] | Execute a system shell command (raw mode with -r). | | /tableformat | /T | /tableformat | Change the table format used to output interactive results. | -| /tee | | /tee [-o] | Append all results to an output file (overwrite using -o). | +| /tee | | /tee [-o] | Append all results to an output file (overwrite using -o). | | /timing | /t | /timing | Toggle timing of queries. | | /use | /u | /use | Change to a new database. | | /warnings | /W | /warnings | Enable automatic warnings display. | diff --git a/test/pytests/test_client_commands.py b/test/pytests/test_client_commands.py index 449b09531..dc7c31c4c 100644 --- a/test/pytests/test_client_commands.py +++ b/test/pytests/test_client_commands.py @@ -95,6 +95,22 @@ def result_statuses(results: Any) -> list[str | None]: return [result.status for result in list(results)] +@pytest.mark.parametrize( + ('arg', 'expected'), + [ + ('query.sql', ('query.sql', False, False)), + ('--special query.sql', ('query.sql', True, False)), + ('--show query.sql', ('query.sql', False, True)), + ('--special --show query file.sql', ('query file.sql', True, True)), + ('--show --special query file.sql', ('query file.sql', True, True)), + ('--show --show query.sql', ('query.sql', False, True)), + ('--show', ('', False, True)), + ], +) +def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool]) -> None: + assert client_commands._parse_source_arguments(arg) == expected + + def test_register_special_commands_registers_expected_commands(monkeypatch: pytest.MonkeyPatch) -> None: client = DummyClient() calls: list[tuple[Any, ...]] = [] @@ -118,7 +134,7 @@ def test_register_special_commands_registers_expected_commands(monkeypatch: pyte assert calls[3][0] == client.change_table_format assert calls[4][0] == client.change_redirect_format assert calls[5][0] == client.execute_from_file - assert calls[5][2:4] == ('/source [--special] ', 'Execute queries from a file.') + assert calls[5][2:4] == ('/source [--special] [--show] ', 'Execute queries from a file.') assert calls[6][0] == client.change_prompt_format assert calls[6][2:4] == ('/prompt [string]', 'Show or change prompt format.') assert calls[7][0] == client.config_command @@ -498,6 +514,7 @@ def test_execute_from_file_reports_open_errors() -> None: def test_execute_from_file_skips_rejected_destructive_query( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -515,7 +532,8 @@ def confirm_destructive_query(keywords: set[str], query: str) -> bool: monkeypatch.setattr(client_commands, 'confirm_destructive_query', confirm_destructive_query) - assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='ran select 1;')] + assert list(client.execute_from_file(f'--show {sql_file}')) == [SQLResult(status='ran select 1;')] + assert capsys.readouterr().out == '> select 1;\n' assert client.sqlexecute.runs == ['select 1;'] assert confirmation_queries == ['drop table users;', 'select 1;'] @@ -617,6 +635,44 @@ def test_execute_from_file_runs_file_query(tmp_path: Path) -> None: assert client.sqlexecute.runs == ['select 1;'] +def test_execute_from_file_shows_query_before_execution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + client = DummyClient() + sql_file = tmp_path / 'query.sql' + sql_file.write_text('select 1;', encoding='utf-8') + client.destructive_warning = False + client.destructive_keywords = set() + client.sqlexecute = FakeSQLExecute() + events: list[tuple[str, str]] = [] + monkeypatch.setattr(client_commands.click, 'secho', lambda query: events.append(('show', query))) + + def run(query: str) -> list[SQLResult]: + events.append(('run', query)) + return [SQLResult(status=f'ran {query}')] + + client.sqlexecute.run = run # type: ignore[method-assign] + results = client.execute_from_file(f'--show {sql_file}') + + assert next(results) == SQLResult(status='ran select 1;') + assert events == [('show', '> select 1;'), ('run', 'select 1;')] + with pytest.raises(StopIteration): + next(results) + + +def test_execute_from_file_shows_each_query(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + client = DummyClient() + sql_file = tmp_path / 'query.sql' + sql_file.write_text('select 1; select 2;', encoding='utf-8') + client.destructive_warning = False + client.destructive_keywords = set() + client.sqlexecute = FakeSQLExecute() + + assert list(client.execute_from_file(f'--show {sql_file}')) == [ + SQLResult(status='ran select 1;'), + SQLResult(status='ran select 2;'), + ] + assert capsys.readouterr().out == '> select 1;\n> select 2;\n' + + def test_execute_from_file_parses_special_option_and_preserves_filename( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -636,13 +692,14 @@ def open_file(path: str) -> IteratedFile: assert opened_paths == ['query file.sql'] -def test_execute_from_file_reports_missing_filename_after_special_option() -> None: +@pytest.mark.parametrize('options', ['--special', '--show', '--special --show']) +def test_execute_from_file_reports_missing_filename_after_options(options: str) -> None: client = DummyClient() - assert list(client.execute_from_file('--special')) == [SQLResult(status='Missing required argument: filename.')] + assert list(client.execute_from_file(options)) == [SQLResult(status='Missing required argument: filename.')] -def test_execute_from_file_runs_permitted_special_commands(tmp_path: Path) -> None: +def test_execute_from_file_runs_permitted_special_commands(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: client = DummyClient() sql_file = tmp_path / 'query.sql' sql_file.write_text('select 1; /status; select 2;', encoding='utf-8') @@ -650,15 +707,16 @@ def test_execute_from_file_runs_permitted_special_commands(tmp_path: Path) -> No client.destructive_keywords = set() client.sqlexecute = FakeSQLExecute() - assert result_statuses(client.execute_from_file(f'--special {sql_file}')) == [ - 'ran select 1;', - 'ran /status', - 'ran select 2;', + assert list(client.execute_from_file(f'--show --special {sql_file}')) == [ + SQLResult(status='ran select 1;'), + SQLResult(status='ran /status'), + SQLResult(status='ran select 2;'), ] + assert capsys.readouterr().out == '> select 1;\n> /status\n> select 2;\n' assert client.sqlexecute.runs == ['select 1;', '/status', 'select 2;'] -def test_execute_from_file_stops_at_disallowed_special_command(tmp_path: Path) -> None: +def test_execute_from_file_stops_at_disallowed_special_command(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: client = DummyClient() sql_file = tmp_path / 'query.sql' sql_file.write_text('select 1; /pager; select 2;', encoding='utf-8') @@ -666,12 +724,16 @@ def test_execute_from_file_stops_at_disallowed_special_command(tmp_path: Path) - client.destructive_keywords = set() client.sqlexecute = FakeSQLExecute() - results = list(client.execute_from_file(f'--special {sql_file}')) + results = list(client.execute_from_file(f'--show --special {sql_file}')) - assert result_statuses(iter(results)) == [ - 'ran select 1;', - 'Special command is never permitted in source files: /pager.', + assert results == [ + SQLResult(status='ran select 1;'), + SQLResult( + status='Special command is never permitted in source files: /pager.', + is_error=True, + ), ] + assert capsys.readouterr().out == '> select 1;\n' assert results[-1].is_error is True assert client.sqlexecute.runs == ['select 1;'] diff --git a/test/pytests/test_completion_engine.py b/test/pytests/test_completion_engine.py index 25ac19beb..9209f9211 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -889,15 +889,25 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): ('\\dt+ ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]), ( '\\. ', - [{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}], ), ( 'source ', - [{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}], ), - ('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special']}]), + ('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}]), ('source --special', []), - ('source --special ', [{'type': 'file_name'}]), + ( + 'source --special ', + [{'type': 'special_subcommand', 'subcommands': ['--show']}, {'type': 'file_name'}], + ), + ('source --special --s', [{'type': 'special_subcommand', 'subcommands': ['--show']}]), + ('source --show', []), + ( + 'source --show ', + [{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}], + ), + ('source --show --special ', [{'type': 'file_name'}]), ('source --special query.sql', [{'type': 'file_name'}]), ('source query.sql', [{'type': 'file_name'}]), ('\\o ', [{'type': 'file_name'}]), @@ -1848,13 +1858,13 @@ def test_source_is_file(expression): special.register_special_command( ..., 'source', - '\\. ', + '\\. ', 'Execute commands from file.', aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)], ) suggestions = suggest_type(expression, expression) assert suggestions == [ - {'type': 'special_subcommand', 'subcommands': ['--special']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}, ] diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 8a6e475a2..7e24b13d6 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -720,11 +720,14 @@ def dummy_list_path(dir_name): @pytest.mark.parametrize( "text,expected", [ - ('source ', [('--special', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), - ('source --s', [('--special', -3)]), - ('source --special ', [('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source ', [('--special', 0), ('--show', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --s', [('--show', -3), ('--special', -3)]), + ('source --special ', [('--show', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --show ', [('--special', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --special --show ', [('/', 0), ('~', 0), ('.', 0), ('..', 0)]), ("source /", [("dir1", 0), ("file1.sql", 0), ("file2.sql", 0)]), ('source --special /', [('dir1', 0), ('file1.sql', 0), ('file2.sql', 0)]), + ('source --show /', [('dir1', 0), ('file1.sql', 0), ('file2.sql', 0)]), ("source /dir1/", [("subdir1", 0), ("subfile1.sql", 0), ("subfile2.sql", 0)]), ("source /dir1/subdir1/", [("lastfile.sql", 0)]), ], @@ -735,7 +738,7 @@ def test_file_name_completion(completer, complete_event, text, expected): special.register_special_command( ..., 'source', - '\\. ', + '\\. ', 'Execute commands from file.', aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)], ) @@ -778,7 +781,7 @@ def test_source_eager_completion(completer, complete_event, tmp_path, monkeypatc special.register_special_command( ..., 'source', - '\\. ', + '\\. ', 'Execute commands from file.', aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)], ) @@ -810,7 +813,7 @@ def test_source_leading_dot_suggestions_completion(completer, complete_event, tm special.register_special_command( ..., 'source', - '\\. ', + '\\. ', 'Execute commands from file.', aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)], ) diff --git a/test/pytests/test_special_main.py b/test/pytests/test_special_main.py index dd51438f6..ab17c8e9c 100644 --- a/test/pytests/test_special_main.py +++ b/test/pytests/test_special_main.py @@ -366,7 +366,7 @@ def test_show_keyword_help_for_case_sensitive_special_alias() -> None: assert result.rows == [ ( r'/e', - '/edit | \\edit\nEdit query with editor (uses $VISUAL or $EDITOR).', + '/edit | \\edit\nEdit query with editor (uses $VISUAL or $EDITOR).', '', ) ]