From 4316fe0e8704f0c780ea75ccaba133f0832d87c1 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 22 Aug 2026 12:00:38 -0400 Subject: [PATCH] add /source --page to route through a single pager Typically under /source, each query runs separately, and the decision on whether the output is large enough to page is limited to each separate query output. So you either get multiple pager invocations, or perhaps none, even if the global output of all of the /source script is long enough to look pageable. This introduces /source --page to route the output of multiple query outputs through a single pager. Notes and limitations * interactive confirmations are not possible within the single pager * exiting the pager before viewing the end-of-file might lead to some queries not executing * some code is duplicated --- changelog.md | 1 + mycli/client_commands.py | 23 +- mycli/main_modes/repl.py | 147 +++++++- mycli/packages/completion_engine.py | 2 +- test/features/fixture_data/help_commands.txt | 84 ++--- test/pytests/test_client_commands.py | 60 ++- test/pytests/test_completion_engine.py | 20 +- test/pytests/test_main_modes_repl.py | 342 ++++++++++++++++++ ...est_smart_completion_public_schema_only.py | 9 +- 9 files changed, 615 insertions(+), 73 deletions(-) diff --git a/changelog.md b/changelog.md index 816176b8e..bbda1ac74 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 a7d7b1f19..a0e046730 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 8c4c2cdc6..917cb07bb 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 2c6246fc9..1a6576e6b 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 b9b3a9298..fc89562cb 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 dc7c31c4c..c6d71cafa 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 9209f9211..e63740828 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 c7c1734f3..b39cbf937 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 7e24b13d6..3e4a0ca98 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)]),