diff --git a/changelog.md b/changelog.md index 816176b8..bbda1ac7 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,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. +* Add `/source --page` to display all output in one pager session. Bug Fixes diff --git a/mycli/client_commands.py b/mycli/client_commands.py index a7d7b1f1..a0e04673 100644 --- a/mycli/client_commands.py +++ b/mycli/client_commands.py @@ -63,19 +63,22 @@ def _render_config_value(value: Any) -> str: return str(value) -def _parse_source_arguments(arg: str) -> tuple[str, bool, bool]: +def _parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool]: allow_special = False show_queries = False + page_output = False filename = arg while arguments := filename.split(maxsplit=1): if arguments[0] == '--special': allow_special = True elif arguments[0] == '--show': show_queries = True + elif arguments[0] == '--page': + page_output = True else: break filename = arguments[1] if len(arguments) == 2 else '' - return filename, allow_special, show_queries + return filename, allow_special, show_queries, page_output def _registered_special_command(query: str) -> tuple[str, str] | None: @@ -226,7 +229,7 @@ def register_special_commands(self) -> None: special.register_special_command( self.execute_from_file, "source", - "/source [--special] [--show] ", + "/source [--special|--show|--page] ", "Execute queries from a file.", aliases=[SpecialCommandAlias("\\.", case_sensitive=False)], ) @@ -349,7 +352,9 @@ 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, show_queries = _parse_source_arguments(arg) + filename, allow_special, show_queries, page_output = _parse_source_arguments(arg) + if page_output: + yield SQLResult(command={'name': 'source_page'}) if not filename: yield SQLResult(status="Missing required argument: filename.") return @@ -388,14 +393,20 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: ) return if show_queries: - click.secho(f'> {special_query}') + if page_output: + yield SQLResult(command={'name': 'source_show', 'text': special_query}) + else: + 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}') + if page_output: + yield SQLResult(command={'name': 'source_show', 'text': query}) + else: + click.secho(f'> {query}') yield from self.sqlexecute.run(query) def change_prompt_format(self, arg: str, **_) -> list[SQLResult]: diff --git a/mycli/main_modes/repl.py b/mycli/main_modes/repl.py index 8c4c2cdc..917cb07b 100644 --- a/mycli/main_modes/repl.py +++ b/mycli/main_modes/repl.py @@ -1,12 +1,13 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Generator, Iterable from dataclasses import dataclass from datetime import datetime import functools from functools import partial import html from importlib import resources +import itertools import os import random import re @@ -441,6 +442,27 @@ def _output_results( sqlexecute = mycli.sqlexecute assert sqlexecute is not None + result_iterator = iter(results) + try: + first_result = next(result_iterator) + except StopIteration: + return + + if first_result.command is not None and first_result.command['name'] == 'source_page': + if special.is_redirected(): + _output_results(mycli, state, result_iterator, start) + return + paged_output = _single_paged_output_results(mycli, state, result_iterator, start) + try: + click.echo_via_pager(paged_output) + except KeyboardInterrupt: + pass + finally: + paged_output.close() + return + + results = itertools.chain([first_result], result_iterator) + result_count = 0 watch_count = 0 for result in results: @@ -450,6 +472,9 @@ def _output_results( mycli.logger.debug('status: %r', result.status) mycli.logger.debug('command: %r', result.command) threshold = 1000 + if result.command is not None and result.command['name'] == 'source_show': + click.secho(f"> {result.command['text']}") + continue if result.command is not None and result.command['name'] == 'set_buffer': state.buffer_text = str(result.command['text']) continue @@ -534,6 +559,126 @@ def _output_results( mycli.output_timing(f'Time: {warnings_duration:0.03f}s', is_warnings_style=True) +def _single_paged_output_results( + mycli: 'MyCli', + state: ReplState, + results: Iterable[SQLResult], + start: float, +) -> Generator[str, None, None]: + """Render results lazily through one pager session.""" + sqlexecute = mycli.sqlexecute + assert sqlexecute is not None + result_iterator = iter(results) + result_count = 0 + watch_count = 0 + try: + for result in result_iterator: + mycli.logger.debug('preamble: %r', result.preamble) + mycli.logger.debug('header: %r', result.header) + mycli.logger.debug('rows: %r', result.rows) + mycli.logger.debug('status: %r', result.status) + mycli.logger.debug('command: %r', result.command) + + if result.command is not None and result.command['name'] == 'source_show': + yield f"> {result.command['text']}\n" + continue + if result.command is not None and result.command['name'] == 'set_buffer': + state.buffer_text = str(result.command['text']) + continue + if result.command is not None and result.command['name'] == 'watch': + if watch_count > 0: + try: + start += float(result.command['seconds']) + except ValueError as error: + message = f'Invalid watch sleep time provided ({error}).' + mycli.log_output(message) + yield f'{message}\n' + return + else: + watch_count += 1 + + if mycli.auto_vertical_output: + if mycli.prompt_session is not None: + max_width = mycli.prompt_session.output.get_size().columns + else: + max_width = DEFAULT_WIDTH + else: + max_width = None + + formatted = mycli.format_sqlresult( + result, + is_expanded=special.is_expanded_output(), + is_redirected=False, + null_string=mycli.null_string, + numeric_alignment=mycli.numeric_alignment, + binary_display=mycli.binary_display, + max_width=max_width, + ) + duration = time.time() - start + + if result_count > 0: + mycli.log_output('') + yield '\n' + for line in formatted: + mycli.log_output(line) + special.write_tee(line) + special.write_once(line) + special.write_pipe_once(line) + yield f'{line}\n' + if result.status: + mycli.log_output(result.status_plain) + yield f'{result.status_plain}\n' + + if mycli.beep_after_seconds > 0 and duration >= mycli.beep_after_seconds: + assert mycli.prompt_session is not None + mycli.prompt_session.output.bell() + if special.is_timing_enabled(): + timing = f'Time: {duration:0.03f}s' + mycli.log_output(timing) + yield f'{timing}\n' + + start = time.time() + result_count += 1 + state.mutating = state.mutating or is_mutating(result.status_plain) + + if special.is_show_warnings_enabled() and isinstance(result.rows, Cursor) and result.rows.warning_count > 0: + warnings = sqlexecute.run('SHOW WARNINGS') + warnings_duration = time.time() - start + saw_warning = False + for warning in warnings: + saw_warning = True + warning_output = mycli.format_sqlresult( + warning, + is_expanded=special.is_expanded_output(), + is_redirected=False, + null_string=mycli.null_string, + numeric_alignment=mycli.numeric_alignment, + binary_display=mycli.binary_display, + max_width=max_width, + is_warnings_style=True, + ) + mycli.log_output('') + yield '\n' + for line in warning_output: + mycli.log_output(line) + special.write_tee(line) + special.write_once(line) + special.write_pipe_once(line) + yield f'{line}\n' + if warning.status: + mycli.log_output(warning.status_plain) + yield f'{warning.status_plain}\n' + + if saw_warning and special.is_timing_enabled(): + timing = f'Time: {warnings_duration:0.03f}s' + mycli.log_output(timing) + yield f'{timing}\n' + finally: + close = getattr(result_iterator, 'close', None) + if close is not None: + close() + + def _keepalive_hook( mycli: 'MyCli', _context: Any, diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 2c6246fc..1a6576e6 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -812,7 +812,7 @@ def suggest_special(text: str) -> list[dict[str, Any]]: 'source', '/source', ]: - source_options = ['--special', '--show'] + source_options = ['--special', '--show', '--page'] source_arguments = _arg.split() if not source_arguments: return [ diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index b9b3a929..fc89562c 100644 --- a/test/features/fixture_data/help_commands.txt +++ b/test/features/fixture_data/help_commands.txt @@ -1,42 +1,42 @@ -+-----------------+----------+----------------------------------------+-------------------------------------------------------------+ -| Command | Shortcut | Usage | Description | -+-----------------+----------+----------------------------------------+-------------------------------------------------------------+ -| /bug | | /bug | File a bug on GitHub. | -| /clip | | /clip | \clip | Copy query to the system clipboard. | -| /config | | /config [key] | Inspect settings from config files. | -| /connect | /r | /connect [database] | Reconnect to the server, optionally switching databases. | -| /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). | -| /exit | /q | /exit | Exit. | -| /f | | /f [name [args..] [--key=value]] | List or execute favorite queries. | -| /favorite | | /favorite | Alternative favorite query interface. See /favorite help. | -| /fd | | /fd | Delete a favorite query. | -| /fs | | /fs | Save a favorite query. | -| \g | | \g | Display query results (mnemonic: go). | -| \G | | \G | Display query results vertically. | -| /help | /? | /help [term] | Show this table, or search for help on a term. | -| /l | | /l | List databases. | -| /llm | /ai | /llm [arguments] | Interrogate an LLM. See "/llm help". | -| /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). | -| /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] [--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). | -| /timing | /t | /timing | Toggle timing of queries. | -| /use | /u | /use | Change to a new database. | -| /warnings | /W | /warnings | Enable automatic warnings display. | -| /watch | | /watch [seconds] [-c] | Execute query every [seconds] seconds (5 by default). | -| \x | | \x | Display query results in an explorer rather than a pager. | -+-----------------+----------+----------------------------------------+-------------------------------------------------------------+ ++-----------------+----------+------------------------------------------+-------------------------------------------------------------+ +| Command | Shortcut | Usage | Description | ++-----------------+----------+------------------------------------------+-------------------------------------------------------------+ +| /bug | | /bug | File a bug on GitHub. | +| /clip | | /clip | \clip | Copy query to the system clipboard. | +| /config | | /config [key] | Inspect settings from config files. | +| /connect | /r | /connect [database] | Reconnect to the server, optionally switching databases. | +| /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). | +| /exit | /q | /exit | Exit. | +| /f | | /f [name [args..] [--key=value]] | List or execute favorite queries. | +| /favorite | | /favorite | Alternative favorite query interface. See /favorite help. | +| /fd | | /fd | Delete a favorite query. | +| /fs | | /fs | Save a favorite query. | +| \g | | \g | Display query results (mnemonic: go). | +| \G | | \G | Display query results vertically. | +| /help | /? | /help [term] | Show this table, or search for help on a term. | +| /l | | /l | List databases. | +| /llm | /ai | /llm [arguments] | Interrogate an LLM. See "/llm help". | +| /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). | +| /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|--show|--page] | 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). | +| /timing | /t | /timing | Toggle timing of queries. | +| /use | /u | /use | Change to a new database. | +| /warnings | /W | /warnings | Enable automatic warnings display. | +| /watch | | /watch [seconds] [-c] | Execute query every [seconds] seconds (5 by default). | +| \x | | \x | Display query results in an explorer rather than a pager. | ++-----------------+----------+------------------------------------------+-------------------------------------------------------------+ diff --git a/test/pytests/test_client_commands.py b/test/pytests/test_client_commands.py index dc7c31c4..c6d71caf 100644 --- a/test/pytests/test_client_commands.py +++ b/test/pytests/test_client_commands.py @@ -98,16 +98,18 @@ def result_statuses(results: Any) -> list[str | None]: @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)), + ('query.sql', ('query.sql', False, False, False)), + ('--special query.sql', ('query.sql', True, False, False)), + ('--show query.sql', ('query.sql', False, True, False)), + ('--page query.sql', ('query.sql', False, False, True)), + ('--special --show --page query file.sql', ('query file.sql', True, True, True)), + ('--page --show --special query file.sql', ('query file.sql', True, True, True)), + ('--show --show query.sql', ('query.sql', False, True, False)), + ('--page --page query.sql', ('query.sql', False, False, True)), + ('--show', ('', False, True, False)), ], ) -def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool]) -> None: +def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool, bool]) -> None: assert client_commands._parse_source_arguments(arg) == expected @@ -134,7 +136,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] [--show] ', 'Execute queries from a file.') + assert calls[5][2:4] == ('/source [--special|--show|--page] ', '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 @@ -635,6 +637,23 @@ def test_execute_from_file_runs_file_query(tmp_path: Path) -> None: assert client.sqlexecute.runs == ['select 1;'] +def test_execute_from_file_emits_page_and_show_commands_lazily(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() + results = client.execute_from_file(f'--show --page {sql_file}') + + assert next(results) == SQLResult(command={'name': 'source_page'}) + assert client.sqlexecute.runs == [] + assert next(results) == SQLResult(command={'name': 'source_show', 'text': 'select 1;'}) + assert client.sqlexecute.runs == [] + assert next(results) == SQLResult(status='ran select 1;') + 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' @@ -692,11 +711,14 @@ def open_file(path: str) -> IteratedFile: assert opened_paths == ['query file.sql'] -@pytest.mark.parametrize('options', ['--special', '--show', '--special --show']) +@pytest.mark.parametrize('options', ['--special', '--show', '--page', '--special --show --page']) def test_execute_from_file_reports_missing_filename_after_options(options: str) -> None: client = DummyClient() - assert list(client.execute_from_file(options)) == [SQLResult(status='Missing required argument: filename.')] + expected = [SQLResult(status='Missing required argument: filename.')] + if '--page' in options: + expected.insert(0, SQLResult(command={'name': 'source_page'})) + assert list(client.execute_from_file(options)) == expected def test_execute_from_file_runs_permitted_special_commands(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: @@ -716,6 +738,22 @@ def test_execute_from_file_runs_permitted_special_commands(capsys: pytest.Captur assert client.sqlexecute.runs == ['select 1;', '/status', 'select 2;'] +def test_execute_from_file_pages_shown_special_command(tmp_path: Path) -> None: + client = DummyClient() + sql_file = tmp_path / 'query.sql' + sql_file.write_text('/status;', encoding='utf-8') + client.destructive_warning = False + client.destructive_keywords = set() + client.sqlexecute = FakeSQLExecute() + + assert list(client.execute_from_file(f'--special --show --page {sql_file}')) == [ + SQLResult(command={'name': 'source_page'}), + SQLResult(command={'name': 'source_show', 'text': '/status'}), + SQLResult(status='ran /status'), + ] + assert client.sqlexecute.runs == ['/status'] + + 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' diff --git a/test/pytests/test_completion_engine.py b/test/pytests/test_completion_engine.py index 9209f921..e6374082 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -889,25 +889,29 @@ 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', '--show']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, {'type': 'file_name'}], ), ( 'source ', - [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, {'type': 'file_name'}], ), - ('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}]), + ('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}]), ('source --special', []), ( 'source --special ', - [{'type': 'special_subcommand', 'subcommands': ['--show']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--show', '--page']}, {'type': 'file_name'}], ), - ('source --special --s', [{'type': 'special_subcommand', 'subcommands': ['--show']}]), + ('source --special --s', [{'type': 'special_subcommand', 'subcommands': ['--show', '--page']}]), ('source --show', []), ( 'source --show ', - [{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}], + [{'type': 'special_subcommand', 'subcommands': ['--special', '--page']}, {'type': 'file_name'}], ), - ('source --show --special ', [{'type': 'file_name'}]), + ( + 'source --show --special ', + [{'type': 'special_subcommand', 'subcommands': ['--page']}, {'type': 'file_name'}], + ), + ('source --show --special --page ', [{'type': 'file_name'}]), ('source --special query.sql', [{'type': 'file_name'}]), ('source query.sql', [{'type': 'file_name'}]), ('\\o ', [{'type': 'file_name'}]), @@ -1864,7 +1868,7 @@ def test_source_is_file(expression): ) suggestions = suggest_type(expression, expression) assert suggestions == [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, {'type': 'file_name'}, ] diff --git a/test/pytests/test_main_modes_repl.py b/test/pytests/test_main_modes_repl.py index c7c1734f..b39cbf93 100644 --- a/test/pytests/test_main_modes_repl.py +++ b/test/pytests/test_main_modes_repl.py @@ -198,6 +198,7 @@ def make_repl_cli(sqlexecute: Any | None = None) -> Any: cli.echo_calls = echo_calls cli.timing_calls = timing_calls cli.log_queries = log_queries + cli.logged_output = [] cli.title_calls = 0 cli.sqlexecute = sqlexecute cli.get_reserved_space = lambda: 3 @@ -220,6 +221,7 @@ def log_query(text: str) -> None: cli.log_queries.append(text) cli.log_query = log_query + cli.log_output = lambda output: cli.logged_output.append(output) cli.reconnect = lambda database='': False def echo(message: Any, **kwargs: Any) -> None: @@ -833,9 +835,349 @@ def test_output_results_moves_set_buffer_command_to_repl_state() -> None: assert state.buffer_text == 'select 1' assert cli.output_calls == [] + + +def test_output_results_pages_entire_source_with_show_and_timing(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + cli.format_sqlresult = lambda result, **kwargs: iter([f'table:{result.status_plain}']) + state = repl_mode.ReplState() + pager_calls: list[list[str]] = [] + monkeypatch.setattr(repl_mode.click, 'echo_via_pager', lambda output: pager_calls.append(list(output))) + monkeypatch.setattr(repl_mode.special, 'is_redirected', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_expanded_output', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: True) + monkeypatch.setattr(repl_mode.special, 'is_show_warnings_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'write_tee', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_once', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_pipe_once', lambda line: None) + monkeypatch.setattr(repl_mode, 'is_select', lambda status: False) + monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: status == 'second') + times = iter([1.0, 2.0, 3.0, 4.0]) + monkeypatch.setattr(repl_mode.time, 'time', lambda: next(times)) + + results = sqlresult_generator( + SQLResult(command={'name': 'source_page'}), + SQLResult(command={'name': 'source_show', 'text': 'select 1;'}), + SQLResult(status='first'), + SQLResult(command={'name': 'source_show', 'text': 'select 2;'}), + SQLResult(status='second'), + ) + repl_mode._output_results(cli, state, results, start=0.0) + + assert pager_calls == [ + [ + '> select 1;\n', + 'table:first\n', + 'first\n', + 'Time: 1.000s\n', + '> select 2;\n', + '\n', + 'table:second\n', + 'second\n', + 'Time: 1.000s\n', + ] + ] + assert state.mutating is True + assert cli.output_calls == [] + + +def test_output_results_stops_source_when_pager_exits_early(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + cli.format_sqlresult = lambda result, **kwargs: iter([result.status_plain or '']) + executed: list[int] = [] + closed: list[bool] = [] + + def source_results() -> Generator[SQLResult, None, None]: + try: + yield SQLResult(command={'name': 'source_page'}) + executed.append(1) + yield SQLResult(status='first') + executed.append(2) + yield SQLResult(status='second') + finally: + closed.append(True) + + def stop_pager(output: Generator[str, None, None]) -> None: + assert next(output) == 'first\n' + + monkeypatch.setattr(repl_mode.click, 'echo_via_pager', stop_pager) + monkeypatch.setattr(repl_mode.special, 'is_redirected', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_expanded_output', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_show_warnings_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'write_tee', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_once', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_pipe_once', lambda line: None) + monkeypatch.setattr(repl_mode, 'is_select', lambda status: False) + monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: False) + + repl_mode._output_results(cli, repl_mode.ReplState(), source_results(), start=0.0) + + assert executed == [1] + assert closed == [True] + + +def test_output_results_redirect_bypasses_source_pager(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + monkeypatch.setattr(repl_mode.special, 'is_redirected', lambda: True) + monkeypatch.setattr(repl_mode.special, 'is_expanded_output', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_show_warnings_enabled', lambda: False) + monkeypatch.setattr(repl_mode, 'is_select', lambda status: False) + monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: False) + monkeypatch.setattr( + repl_mode.click, + 'echo_via_pager', + lambda output: (_ for _ in ()).throw(AssertionError('pager should not run')), + ) + + repl_mode._output_results( + cli, + repl_mode.ReplState(), + sqlresult_generator(SQLResult(command={'name': 'source_page'}), SQLResult(status='result')), + start=0.0, + ) + + assert cli.output_calls == [(['None', 'result'], SQLResult(status='result'), False)] assert cli.echo_calls == [] +def test_output_results_handles_empty_source_show_and_pager_interrupt(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + shown: list[str] = [] + monkeypatch.setattr(repl_mode.click, 'secho', lambda message: shown.append(message)) + monkeypatch.setattr(repl_mode.special, 'is_redirected', lambda: False) + + repl_mode._output_results(cli, repl_mode.ReplState(), iter([]), start=0.0) + repl_mode._output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(command={'name': 'source_show', 'text': 'select 1;'})]), + start=0.0, + ) + monkeypatch.setattr( + repl_mode.click, + 'echo_via_pager', + lambda output: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + repl_mode._output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(command={'name': 'source_page'})]), + start=0.0, + ) + + assert shown == ['> select 1;'] + + +def patch_single_paged_output_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(repl_mode.special, 'is_expanded_output', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_show_warnings_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'write_tee', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_once', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_pipe_once', lambda line: None) + monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: False) + + +def test_single_paged_output_handles_set_buffer_command() -> None: + cli = make_repl_cli(SimpleNamespace()) + state = repl_mode.ReplState() + + output = list( + repl_mode._single_paged_output_results( + cli, + state, + iter([SQLResult(command={'name': 'set_buffer', 'text': 'select 1'})]), + start=0.0, + ) + ) + + assert state.buffer_text == 'select 1' + assert output == [] + + +def test_single_paged_output_handles_watch_commands(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + patch_single_paged_output_runtime(monkeypatch) + monkeypatch.setattr(repl_mode.time, 'time', lambda: 2.0) + + output = list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([ + SQLResult(status='first watch', command={'name': 'watch', 'seconds': '1'}), + SQLResult(status='second watch', command={'name': 'watch', 'seconds': '1'}), + SQLResult(status='bad watch', command={'name': 'watch', 'seconds': 'bad'}), + ]), + start=0.0, + ) + ) + + assert output[-1].startswith('Invalid watch sleep time provided') + + +def test_single_paged_output_uses_terminal_width(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + cli.auto_vertical_output = True + cli.prompt_session = FakePromptSession(columns=91) + widths: list[int | None] = [] + + def format_sqlresult(result: SQLResult, **kwargs: Any) -> Iterator[str]: + widths.append(kwargs.get('max_width')) + return iter([result.status_plain or 'row']) + + cli.format_sqlresult = format_sqlresult + patch_single_paged_output_runtime(monkeypatch) + monkeypatch.setattr(repl_mode.time, 'time', lambda: 0.0) + + list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='result')]), + start=0.0, + ) + ) + + assert widths == [91] + + +def test_single_paged_output_uses_default_width_without_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + cli.auto_vertical_output = True + widths: list[int | None] = [] + + def format_sqlresult(result: SQLResult, **kwargs: Any) -> Iterator[str]: + widths.append(kwargs.get('max_width')) + return iter([result.status_plain or 'row']) + + cli.format_sqlresult = format_sqlresult + patch_single_paged_output_runtime(monkeypatch) + monkeypatch.setattr(repl_mode.time, 'time', lambda: 0.0) + + list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='default width')]), + start=0.0, + ) + ) + + assert widths[-1] == repl_mode.DEFAULT_WIDTH + + +def test_single_paged_output_beeps_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_repl_cli(SimpleNamespace()) + cli.prompt_session = FakePromptSession() + cli.beep_after_seconds = 0.5 + patch_single_paged_output_runtime(monkeypatch) + monkeypatch.setattr(repl_mode.time, 'time', lambda: 1.0) + + list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='result')]), + start=0.0, + ) + ) + + assert cli.prompt_session.output.bell_count == 1 + + +def make_single_paged_warning_cli(monkeypatch: pytest.MonkeyPatch) -> tuple[Any, Any]: + class FakeSQLExecute: + def run(self, query: str) -> list[SQLResult]: + assert query == 'SHOW WARNINGS' + return [SQLResult(status='warning')] + + cli = make_repl_cli(FakeSQLExecute()) + + def format_sqlresult(result: SQLResult, **kwargs: Any) -> Iterator[str]: + prefix = 'warning' if kwargs.get('is_warnings_style') else 'result' + return iter([f'{prefix} row']) + + cli.format_sqlresult = format_sqlresult + times = iter([1.0, 2.0, 3.0]) + monkeypatch.setattr(repl_mode.time, 'time', lambda: next(times)) + monkeypatch.setattr(repl_mode.special, 'is_expanded_output', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: False) + monkeypatch.setattr(repl_mode.special, 'is_show_warnings_enabled', lambda: True) + monkeypatch.setattr(repl_mode.special, 'write_tee', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_once', lambda line: None) + monkeypatch.setattr(repl_mode.special, 'write_pipe_once', lambda line: None) + monkeypatch.setattr(repl_mode, 'Cursor', FakeCursorBase) + monkeypatch.setattr(repl_mode, 'is_mutating', lambda status: False) + rows = cast(Any, FakeCursorBase(rowcount=1, warning_count=1)) + return cli, rows + + +def test_single_paged_output_renders_warnings(monkeypatch: pytest.MonkeyPatch) -> None: + cli, rows = make_single_paged_warning_cli(monkeypatch) + + output = list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='result', rows=rows)]), + start=0.0, + ) + ) + + assert output == [ + 'result row\n', + 'result\n', + '\n', + 'warning row\n', + 'warning\n', + ] + + +def test_single_paged_output_reports_warning_timing(monkeypatch: pytest.MonkeyPatch) -> None: + cli, rows = make_single_paged_warning_cli(monkeypatch) + monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: True) + + output = list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='result', rows=rows)]), + start=0.0, + ) + ) + + assert output[-1] == 'Time: 1.000s\n' + + +def test_single_paged_output_writes_warning_rows_to_output_sinks(monkeypatch: pytest.MonkeyPatch) -> None: + cli, rows = make_single_paged_warning_cli(monkeypatch) + writes: list[tuple[str, str]] = [] + monkeypatch.setattr(repl_mode.special, 'write_tee', lambda line: writes.append(('tee', line))) + monkeypatch.setattr(repl_mode.special, 'write_once', lambda line: writes.append(('once', line))) + monkeypatch.setattr(repl_mode.special, 'write_pipe_once', lambda line: writes.append(('pipe', line))) + + list( + repl_mode._single_paged_output_results( + cli, + repl_mode.ReplState(), + iter([SQLResult(status='result', rows=rows)]), + start=0.0, + ) + ) + + assert writes == [ + ('tee', 'result row'), + ('once', 'result row'), + ('pipe', 'result row'), + ('tee', 'warning row'), + ('once', 'warning row'), + ('pipe', 'warning row'), + ] + + def test_one_iteration_prefills_and_clears_pending_buffer_text(monkeypatch: pytest.MonkeyPatch) -> None: patch_repl_runtime_defaults(monkeypatch) cli = make_repl_cli(SimpleNamespace()) diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 7e24b13d..3e4a0ca9 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -720,11 +720,12 @@ def dummy_list_path(dir_name): @pytest.mark.parametrize( "text,expected", [ - ('source ', [('--special', 0), ('--show', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source ', [('--special', 0), ('--show', 0), ('--page', 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 --special ', [('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --show ', [('--special', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --special --show ', [('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ('source --special --show --page ', [('/', 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)]),