diff --git a/changelog.md b/changelog.md index 2364af42..6f76ad28 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,11 @@ Upcoming (TBD) ============== +Features +-------- +* Add a `/source --throttle` option to pause between sourced statements. + + Bug Fixes -------- * Allow shell-style redirects with `/source` when the filename is unquoted. diff --git a/mycli/client_commands.py b/mycli/client_commands.py index de90a0a2..b8f030a6 100644 --- a/mycli/client_commands.py +++ b/mycli/client_commands.py @@ -4,6 +4,7 @@ import logging import os import re +import time from typing import TYPE_CHECKING, Any, cast import click @@ -155,7 +156,7 @@ def register_special_commands(self) -> None: special.register_special_command( self.execute_from_file, "source", - "/source [--special|--show|--page] ", + "/source [options] ", "Execute queries from a file.", aliases=[SpecialCommandAlias("\\.", case_sensitive=False)], completion_snippet='execute queries from file', @@ -288,7 +289,11 @@ 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, page_output = parse_source_arguments(arg) + try: + filename, allow_special, show_queries, page_output, throttle = parse_source_arguments(arg) + except ValueError as error: + yield SQLResult(status=str(error), is_error=True) + return if page_output: yield SQLResult(command={'name': 'source_page'}) try: @@ -307,6 +312,7 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: return assert isinstance(self.sqlexecute, SQLExecute) + executed_statement = False with file_h: statements = statements_from_filehandle(file_h) while True: @@ -333,22 +339,28 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]: is_error=True, ) return + if executed_statement and throttle > 0: + time.sleep(throttle) if show_queries: 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) + executed_statement = True continue if self.destructive_warning and confirm_destructive_query(self.destructive_keywords, query) is False: continue + if executed_statement and throttle > 0: + time.sleep(throttle) if show_queries: if page_output: yield SQLResult(command={'name': 'source_show', 'text': query}) else: click.secho(f'> {query}') yield from self.sqlexecute.run(query) + executed_statement = True def change_prompt_format(self, arg: str, **_) -> list[SQLResult]: """ diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index e0083ad5..9bd2f9f6 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -816,7 +816,8 @@ def suggest_special(text: str) -> list[dict[str, Any]]: 'source', '/source', ]: - source_options = ['--special', '--show', '--page'] + source_options = ['--special', '--show', '--page', '--throttle'] + source_boolean_options = source_options[:-1] source_arguments = _arg.split() if not source_arguments: return [ @@ -826,9 +827,28 @@ def suggest_special(text: str) -> list[dict[str, Any]]: 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 + while argument_index < len(source_arguments): + argument = source_arguments[argument_index] + if argument in source_boolean_options: + used_options.add(argument) + argument_index += 1 + continue + if argument == '--throttle': + used_options.add(argument) + argument_index += 1 + if argument_index >= len(source_arguments): + return [] + if argument_index == len(source_arguments) - 1 and not text[-1].isspace(): + return [] + argument_index += 1 + continue + if argument.startswith('--throttle='): + used_options.add('--throttle') + if argument == '--throttle=' or not text[-1].isspace(): + return [] + argument_index += 1 + continue + break remaining_options = [option for option in source_options if option not in used_options] source_filename = _arg for _index in range(argument_index): diff --git a/mycli/packages/hybrid_redirection.py b/mycli/packages/hybrid_redirection.py index 14dd310b..3e22a6f3 100644 --- a/mycli/packages/hybrid_redirection.py +++ b/mycli/packages/hybrid_redirection.py @@ -16,7 +16,8 @@ delimiter_command = DelimiterCommand() SOURCE_COMMAND_PATTERN = re.compile(r'^([/]?source|[/\\]\.)\s+', re.IGNORECASE) SOURCE_OPTIONS_PATTERN = re.compile( - r'^([/]?source|[/\\]\.)\s+(?P(?:(?:--special|--show|--page)\s+)*)', + r'^([/]?source|[/\\]\.)\s+' + r'(?P(?:(?:--special|--show|--page)\s+|--throttle(?:=[^\s]+|\s+[^\s]+)\s+)*)', re.IGNORECASE, ) @@ -67,7 +68,7 @@ def find_sql_part( if SOURCE_COMMAND_PATTERN.match(sql_part): source_arg_str = SOURCE_COMMAND_PATTERN.sub('', sql_part) try: - filename, _allow_special, _show_queries, _page_output = parse_source_arguments(source_arg_str) + filename, _allow_special, _show_queries, _page_output, _throttle = parse_source_arguments(source_arg_str) filename = parse_source_filename(filename) except ValueError: return '' diff --git a/mycli/packages/special/source.py b/mycli/packages/special/source.py index f45d4d65..5d9d4b5c 100644 --- a/mycli/packages/special/source.py +++ b/mycli/packages/special/source.py @@ -1,3 +1,4 @@ +import math import shlex import sqlparse @@ -72,10 +73,23 @@ def _favorite_source_command_is_safe(arg: str) -> bool: return not any(special.is_special_command(statement.rstrip(';')) for statement in sqlparse.split(query)) -def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool]: +def _parse_throttle(value: str) -> float: + if not value: + raise ValueError('Missing value for --throttle.') + try: + throttle = float(value) + except ValueError: + raise ValueError(f'Invalid --throttle value: {value}. Expected a finite, non-negative number.') from None + if not math.isfinite(throttle) or throttle < 0: + raise ValueError(f'Invalid --throttle value: {value}. Expected a finite, non-negative number.') + return throttle + + +def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool, float]: allow_special = False show_queries = False page_output = False + throttle = 0.0 filename = arg while arguments := filename.split(maxsplit=1): if arguments[0] == '--special': @@ -84,10 +98,19 @@ def parse_source_arguments(arg: str) -> tuple[str, bool, bool, bool]: show_queries = True elif arguments[0] == '--page': page_output = True + elif arguments[0] == '--throttle': + if len(arguments) != 2: + raise ValueError('Missing value for --throttle.') + throttle_arguments = arguments[1].split(maxsplit=1) + throttle = _parse_throttle(throttle_arguments[0]) + filename = throttle_arguments[1] if len(throttle_arguments) == 2 else '' + continue + elif arguments[0].startswith('--throttle='): + throttle = _parse_throttle(arguments[0].partition('=')[2]) else: break filename = arguments[1] if len(arguments) == 2 else '' - return filename, allow_special, show_queries, page_output + return filename, allow_special, show_queries, page_output, throttle def parse_source_filename(filename: str) -> str: diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index 8fa187cb..e3ec62e6 100644 --- a/test/features/fixture_data/help_commands.txt +++ b/test/features/fixture_data/help_commands.txt @@ -1,43 +1,43 @@ -+-----------------+----------+------------------------------------------+-------------------------------------------------------------+ -| 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. | -| /ping | | /ping | Check the connection. | -| /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. | -+-----------------+----------+------------------------------------------+-------------------------------------------------------------+ ++-----------------+----------+----------------------------------------+-------------------------------------------------------------+ +| 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. | +| /ping | | /ping | Check the connection. | +| /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 [options] | 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 2cbd3491..8722fb96 100644 --- a/test/pytests/test_client_commands.py +++ b/test/pytests/test_client_commands.py @@ -120,7 +120,10 @@ 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|--page] ', 'Execute queries from a file.') + assert calls[5][2:4] == ( + '/source [options] ', + '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 @@ -645,6 +648,54 @@ def test_execute_from_file_runs_file_query(tmp_path: Path) -> None: assert client.sqlexecute.runs == ['select 1;'] +def test_execute_from_file_throttles_between_executed_statements(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + client = DummyClient() + sql_file = tmp_path / 'query.sql' + sql_file.write_text('select 1; /status; select 2;', encoding='utf-8') + client.destructive_warning = False + client.destructive_keywords = set() + client.sqlexecute = FakeSQLExecute() + sleep_calls: list[float] = [] + monkeypatch.setattr(client_commands.time, 'sleep', lambda seconds: sleep_calls.append(seconds)) + + results = list(client.execute_from_file(f'--special --throttle 0.25 {sql_file}')) + + assert result_statuses(results) == ['ran select 1;', 'ran /status', 'ran select 2;'] + assert sleep_calls == [0.25, 0.25] + + +def test_execute_from_file_does_not_throttle_after_declined_statement(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + client = DummyClient() + sql_file = tmp_path / 'query.sql' + sql_file.write_text('drop table users; select 1;', encoding='utf-8') + client.destructive_warning = True + client.destructive_keywords = {'drop'} + client.sqlexecute = FakeSQLExecute() + sleep_calls: list[float] = [] + monkeypatch.setattr(client_commands, 'confirm_destructive_query', lambda keywords, query: not query.startswith('drop')) + monkeypatch.setattr(client_commands.time, 'sleep', lambda seconds: sleep_calls.append(seconds)) + + results = list(client.execute_from_file(f'--throttle=0.25 {sql_file}')) + + assert result_statuses(results) == ['ran select 1;'] + assert client.sqlexecute.runs == ['select 1;'] + assert sleep_calls == [] + + +def test_execute_from_file_reports_invalid_throttle_without_opening_file(monkeypatch: pytest.MonkeyPatch) -> None: + client = DummyClient() + opened_paths: list[str] = [] + monkeypatch.setattr(client_commands, 'open', lambda path: opened_paths.append(path), raising=False) + + assert list(client.execute_from_file('--throttle nope query.sql')) == [ + SQLResult( + status='Invalid --throttle value: nope. Expected a finite, non-negative number.', + is_error=True, + ) + ] + assert opened_paths == [] + + def test_execute_from_file_emits_page_and_show_commands_lazily(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 70343e71..6dca008d 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -891,29 +891,82 @@ 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', '--page']}, SOURCE_FILE_SUGGESTION], + [ + { + 'type': 'special_subcommand', + 'subcommands': ['--special', '--show', '--page', '--throttle'], + }, + SOURCE_FILE_SUGGESTION, + ], ), ( 'source ', - [{'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, SOURCE_FILE_SUGGESTION], + [ + { + 'type': 'special_subcommand', + 'subcommands': ['--special', '--show', '--page', '--throttle'], + }, + SOURCE_FILE_SUGGESTION, + ], + ), + ( + 'source --s', + [ + { + 'type': 'special_subcommand', + 'subcommands': ['--special', '--show', '--page', '--throttle'], + } + ], ), - ('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}]), ('source --special', []), ( 'source --special ', - [{'type': 'special_subcommand', 'subcommands': ['--show', '--page']}, SOURCE_FILE_SUGGESTION], + [ + {'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle']}, + SOURCE_FILE_SUGGESTION, + ], + ), + ( + 'source --special --s', + [{'type': 'special_subcommand', 'subcommands': ['--show', '--page', '--throttle']}], ), - ('source --special --s', [{'type': 'special_subcommand', 'subcommands': ['--show', '--page']}]), ('source --show', []), ( 'source --show ', - [{'type': 'special_subcommand', 'subcommands': ['--special', '--page']}, SOURCE_FILE_SUGGESTION], + [ + {'type': 'special_subcommand', 'subcommands': ['--special', '--page', '--throttle']}, + SOURCE_FILE_SUGGESTION, + ], ), ( 'source --show --special ', - [{'type': 'special_subcommand', 'subcommands': ['--page']}, SOURCE_FILE_SUGGESTION], + [ + {'type': 'special_subcommand', 'subcommands': ['--page', '--throttle']}, + SOURCE_FILE_SUGGESTION, + ], + ), + ( + 'source --show --special --page ', + [{'type': 'special_subcommand', 'subcommands': ['--throttle']}, SOURCE_FILE_SUGGESTION], + ), + ('source --throttle', []), + ('source --throttle ', []), + ('source --throttle 0.25', []), + ( + 'source --throttle 0.25 ', + [ + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, + SOURCE_FILE_SUGGESTION, + ], + ), + ('source --throttle=0.25', []), + ( + 'source --throttle=0.25 ', + [ + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, + SOURCE_FILE_SUGGESTION, + ], ), - ('source --show --special --page ', [SOURCE_FILE_SUGGESTION]), ( 'source --special query.sql', [{'type': 'file_name', 'quote_spaces': True, 'source_filename': 'query.sql'}], @@ -1873,7 +1926,7 @@ def test_source_is_file(expression): ) suggestions = suggest_type(expression, expression) assert suggestions == [ - {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page']}, + {'type': 'special_subcommand', 'subcommands': ['--special', '--show', '--page', '--throttle']}, SOURCE_FILE_SUGGESTION, ] diff --git a/test/pytests/test_hybrid_redirection.py b/test/pytests/test_hybrid_redirection.py index 6922d3cf..e866298f 100644 --- a/test/pytests/test_hybrid_redirection.py +++ b/test/pytests/test_hybrid_redirection.py @@ -165,7 +165,7 @@ def test_get_redirect_components_valid_paths_and_logging() -> None: ) -@pytest.mark.parametrize('option', ['--special', '--show', '--page']) +@pytest.mark.parametrize('option', ['--special', '--show', '--page', '--throttle 0.25', '--throttle=0.25']) def test_get_redirect_components_preserves_source_options(option: str) -> None: command = f'/source {option} query.sql $> out.txt' diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 5630a243..00b598bb 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -727,12 +727,36 @@ def dummy_list_path(dir_name): @pytest.mark.parametrize( "text,expected", [ - ('source ', [('--special', 0), ('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)]), + ( + 'source ', + [('--special', 0), ('--show', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), ('source --s', [('--show', -3), ('--special', -3)]), - ('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 --special ', + [('--show', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), + ( + 'source --show ', + [('--special', 0), ('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), + ( + 'source --special --show ', + [('--page', 0), ('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), + ( + 'source --special --show --page ', + [('--throttle', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), + ('source --throttle ', []), + ( + 'source --throttle 0.25 ', + [('--special', 0), ('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), + ( + 'source --throttle=0.25 ', + [('--special', 0), ('--show', 0), ('--page', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)], + ), ("source /", [("/dir1", -1), ("/file1.sql", -1), ("/file2.sql", -1)]), ('source --special /', [('/dir1', -1), ('/file1.sql', -1), ('/file2.sql', -1)]), ('source --show /', [('/dir1', -1), ('/file1.sql', -1), ('/file2.sql', -1)]), diff --git a/test/pytests/test_special_source.py b/test/pytests/test_special_source.py index fe2c8d25..ef6a8196 100644 --- a/test/pytests/test_special_source.py +++ b/test/pytests/test_special_source.py @@ -7,21 +7,40 @@ @pytest.mark.parametrize( ('arg', 'expected'), [ - ('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)), + ('query.sql', ('query.sql', False, False, False, 0.0)), + ('--special query.sql', ('query.sql', True, False, False, 0.0)), + ('--show query.sql', ('query.sql', False, True, False, 0.0)), + ('--page query.sql', ('query.sql', False, False, True, 0.0)), + ('--special --show --page query file.sql', ('query file.sql', True, True, True, 0.0)), + ('--page --show --special query file.sql', ('query file.sql', True, True, True, 0.0)), + ('--show --show query.sql', ('query.sql', False, True, False, 0.0)), + ('--page --page query.sql', ('query.sql', False, False, True, 0.0)), + ('--show', ('', False, True, False, 0.0)), + ('--throttle 0.25 query.sql', ('query.sql', False, False, False, 0.25)), + ('--throttle=1e-2 query.sql', ('query.sql', False, False, False, 0.01)), + ('--throttle 1 --show --throttle=0.5 query.sql', ('query.sql', False, True, False, 0.5)), ], ) -def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool, bool]) -> None: +def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool, bool, float]) -> None: assert source.parse_source_arguments(arg) == expected +@pytest.mark.parametrize( + 'arg', + [ + '--throttle', + '--throttle=', + '--throttle nope query.sql', + '--throttle -1 query.sql', + '--throttle inf query.sql', + '--throttle nan query.sql', + ], +) +def test_parse_source_arguments_rejects_invalid_throttle(arg: str) -> None: + with pytest.raises(ValueError, match='throttle'): + source.parse_source_arguments(arg) + + @pytest.mark.parametrize( ('filename', 'expected'), [