Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Features
---------
* Advertise beta `--boundary-id` option in helpdoc and completions.
* Add `/source --special` to allow executing some special commands.
* Add `/source --show` to display each query before executing it.


Bug Fixes
Expand Down
8 changes: 4 additions & 4 deletions mycli/TIPS
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ copy the previous query to the clipboard with /clip!

edit a query in an external editor using <query>\edit!

edit a query in an external editor using /edit <filename>!
edit a query in an external editor using /edit <file>!

/f lists favorite queries; /f <name> executes a favorite!

Expand All @@ -68,7 +68,7 @@ Manage favorite queries with /favorite!

/l lists databases!

/once <filename> appends the next result to <filename>!
/once <file> appends the next result to <file>!

/| <command> sends the next result to a subprocess!

Expand Down Expand Up @@ -306,9 +306,9 @@ customize password sources/precedence with "password_sources" in ~/.myclirc!

redirect query output to a shell command with "$| <command>"!

redirect query output to a CSV file with "$> <filename>"!
redirect query output to a CSV file with "$> <file>"!

append query output to a CSV file with "$>> <filename>"!
append query output to a CSV file with "$>> <file>"!

run a command after shell redirects with "post_redirect_command" in ~/.myclirc!

Expand Down
26 changes: 19 additions & 7 deletions mycli/client_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ def _render_config_value(value: Any) -> str:
return str(value)


def _parse_source_arguments(arg: str) -> tuple[str, bool]:
arguments = arg.split(maxsplit=1)
if arguments and arguments[0] == '--special':
return (arguments[1] if len(arguments) == 2 else '', True)
return (arg, False)
def _parse_source_arguments(arg: str) -> tuple[str, bool, bool]:
allow_special = False
show_queries = False
filename = arg
while arguments := filename.split(maxsplit=1):
if arguments[0] == '--special':
allow_special = True
elif arguments[0] == '--show':
show_queries = True
else:
break
filename = arguments[1] if len(arguments) == 2 else ''
return filename, allow_special, show_queries


def _registered_special_command(query: str) -> tuple[str, str] | None:
Expand Down Expand Up @@ -218,7 +226,7 @@ def register_special_commands(self) -> None:
special.register_special_command(
self.execute_from_file,
"source",
"/source [--special] <filename>",
"/source [--special] [--show] <file>",
"Execute queries from a file.",
aliases=[SpecialCommandAlias("\\.", case_sensitive=False)],
)
Expand Down Expand Up @@ -341,7 +349,7 @@ def change_db(self, arg: str, **_) -> Generator[SQLResult, None, None]:
yield SQLResult(status=msg)

def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]:
filename, allow_special = _parse_source_arguments(arg)
filename, allow_special, show_queries = _parse_source_arguments(arg)
if not filename:
yield SQLResult(status="Missing required argument: filename.")
return
Expand Down Expand Up @@ -379,11 +387,15 @@ def execute_from_file(self, arg: str, **_) -> Generator[SQLResult, None, None]:
is_error=True,
)
return
if show_queries:
click.secho(f'> {special_query}')
yield from self.sqlexecute.run(special_query)
continue

if self.destructive_warning and confirm_destructive_query(self.destructive_keywords, query) is False:
continue
if show_queries:
click.secho(f'> {query}')
yield from self.sqlexecute.run(query)

def change_prompt_format(self, arg: str, **_) -> list[SQLResult]:
Expand Down
27 changes: 21 additions & 6 deletions mycli/packages/completion_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,17 +812,32 @@ def suggest_special(text: str) -> list[dict[str, Any]]:
'source',
'/source',
]:
source_arguments = _arg.split(maxsplit=1)
source_options = ['--special', '--show']
source_arguments = _arg.split()
if not source_arguments:
return [
{'type': 'special_subcommand', 'subcommands': ['--special']},
{'type': 'special_subcommand', 'subcommands': source_options},
{'type': 'file_name'},
]
if source_arguments[0].startswith('-') and source_arguments[0] != '--special':
return [{'type': 'special_subcommand', 'subcommands': ['--special']}]
if source_arguments[0] == '--special' and len(source_arguments) == 1 and not text[-1].isspace():

used_options: set[str] = set()
argument_index = 0
while argument_index < len(source_arguments) and source_arguments[argument_index] in source_options:
used_options.add(source_arguments[argument_index])
argument_index += 1
remaining_options = [option for option in source_options if option not in used_options]

if argument_index < len(source_arguments):
if source_arguments[argument_index].startswith('-'):
return [{'type': 'special_subcommand', 'subcommands': remaining_options}]
return [{'type': 'file_name'}]
if not text[-1].isspace():
return []
return [{'type': 'file_name'}]
suggestions = []
if remaining_options:
suggestions.append({'type': 'special_subcommand', 'subcommands': remaining_options})
suggestions.append({'type': 'file_name'})
return suggestions

if cmd.lower() in [
r'\o',
Expand Down
4 changes: 2 additions & 2 deletions mycli/packages/special/iocommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ def parseargfile(arg: str) -> tuple[str, str]:

@special_command(
"tee",
"/tee [-o] <filename>",
"/tee [-o] <file>",
"Append all results to an output file (overwrite using -o).",
)
def set_tee(arg: str, **_) -> list[SQLResult]:
Expand Down Expand Up @@ -856,7 +856,7 @@ def write_tee(output: str | ANSI | FormattedText, nl: bool = True) -> None:

@special_command(
"\\once",
"/once [-o] <filename>",
"/once [-o] <file>",
"Append next result to an output file (overwrite using -o).",
aliases=[SpecialCommandAlias("\\o", case_sensitive=False)],
)
Expand Down
2 changes: 1 addition & 1 deletion mycli/packages/special/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def quit_(*_args):

@special_command(
"\\edit",
"/edit <filename> | <query>\\edit",
"/edit <file> | <query>\\edit",
"Edit query with editor (uses $VISUAL or $EDITOR).",
arg_type=ArgType.NO_ARGUMENT,
case_sensitive=True,
Expand Down
8 changes: 4 additions & 4 deletions test/features/fixture_data/help_commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
| /delimiter | <null> | /delimiter <string> | Change end-of-statement delimiter. |
| /dsn | <null> | /dsn <help|list|show|save|edit|delete> | Manage saved DSNs. See /dsn help. |
| /dt | <null> | /dt[+] [table] | List or describe tables. |
| /edit | /e | /edit <filename> | <query>\edit | Edit query with editor (uses $VISUAL or $EDITOR). |
| /edit | /e | /edit <file> | <query>\edit | Edit query with editor (uses $VISUAL or $EDITOR). |
| /exit | /q | /exit | Exit. |
| /f | <null> | /f [name [args..] [--key=value]] | List or execute favorite queries. |
| /favorite | <null> | /favorite <command> | Alternative favorite query interface. See /favorite help. |
Expand All @@ -22,18 +22,18 @@
| /nopager | /n | /nopager | Disable pager; print to stdout. |
| /notee | <null> | /notee | Stop writing results to an output file. |
| /nowarnings | /w | /nowarnings | Disable automatic warnings display. |
| /once | /o | /once [-o] <filename> | Append next result to an output file (overwrite using -o). |
| /once | /o | /once [-o] <file> | 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 <command> | Send next result to a subprocess. |
| /prompt | /R | /prompt [string] | Show or change prompt format. |
| /quit | /q | /quit | Quit. |
| /redirectformat | /Tr | /redirectformat <format> | Change the table format used to output redirected results. |
| /rehash | /# | /rehash | Refresh auto-completions. |
| /source | /. | /source [--special] <filename> | Execute queries from a file. |
| /source | /. | /source [--special] [--show] <file> | Execute queries from a file. |
| /status | /s | /status | Get status information from the server. |
| /system | <null> | /system [-r] <command> | Execute a system shell command (raw mode with -r). |
| /tableformat | /T | /tableformat <format> | Change the table format used to output interactive results. |
| /tee | <null> | /tee [-o] <filename> | Append all results to an output file (overwrite using -o). |
| /tee | <null> | /tee [-o] <file> | Append all results to an output file (overwrite using -o). |
| /timing | /t | /timing | Toggle timing of queries. |
| /use | /u | /use <database> | Change to a new database. |
| /warnings | /W | /warnings | Enable automatic warnings display. |
Expand Down
90 changes: 76 additions & 14 deletions test/pytests/test_client_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ def result_statuses(results: Any) -> list[str | None]:
return [result.status for result in list(results)]


@pytest.mark.parametrize(
('arg', 'expected'),
[
('query.sql', ('query.sql', False, False)),
('--special query.sql', ('query.sql', True, False)),
('--show query.sql', ('query.sql', False, True)),
('--special --show query file.sql', ('query file.sql', True, True)),
('--show --special query file.sql', ('query file.sql', True, True)),
('--show --show query.sql', ('query.sql', False, True)),
('--show', ('', False, True)),
],
)
def test_parse_source_arguments(arg: str, expected: tuple[str, bool, bool]) -> None:
assert client_commands._parse_source_arguments(arg) == expected


def test_register_special_commands_registers_expected_commands(monkeypatch: pytest.MonkeyPatch) -> None:
client = DummyClient()
calls: list[tuple[Any, ...]] = []
Expand All @@ -118,7 +134,7 @@ def test_register_special_commands_registers_expected_commands(monkeypatch: pyte
assert calls[3][0] == client.change_table_format
assert calls[4][0] == client.change_redirect_format
assert calls[5][0] == client.execute_from_file
assert calls[5][2:4] == ('/source [--special] <filename>', 'Execute queries from a file.')
assert calls[5][2:4] == ('/source [--special] [--show] <file>', '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
Expand Down Expand Up @@ -498,6 +514,7 @@ def test_execute_from_file_reports_open_errors() -> None:


def test_execute_from_file_skips_rejected_destructive_query(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
Expand All @@ -515,7 +532,8 @@ def confirm_destructive_query(keywords: set[str], query: str) -> bool:

monkeypatch.setattr(client_commands, 'confirm_destructive_query', confirm_destructive_query)

assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='ran select 1;')]
assert list(client.execute_from_file(f'--show {sql_file}')) == [SQLResult(status='ran select 1;')]
assert capsys.readouterr().out == '> select 1;\n'
assert client.sqlexecute.runs == ['select 1;']
assert confirmation_queries == ['drop table users;', 'select 1;']

Expand Down Expand Up @@ -617,6 +635,44 @@ def test_execute_from_file_runs_file_query(tmp_path: Path) -> None:
assert client.sqlexecute.runs == ['select 1;']


def test_execute_from_file_shows_query_before_execution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
client = DummyClient()
sql_file = tmp_path / 'query.sql'
sql_file.write_text('select 1;', encoding='utf-8')
client.destructive_warning = False
client.destructive_keywords = set()
client.sqlexecute = FakeSQLExecute()
events: list[tuple[str, str]] = []
monkeypatch.setattr(client_commands.click, 'secho', lambda query: events.append(('show', query)))

def run(query: str) -> list[SQLResult]:
events.append(('run', query))
return [SQLResult(status=f'ran {query}')]

client.sqlexecute.run = run # type: ignore[method-assign]
results = client.execute_from_file(f'--show {sql_file}')

assert next(results) == SQLResult(status='ran select 1;')
assert events == [('show', '> select 1;'), ('run', 'select 1;')]
with pytest.raises(StopIteration):
next(results)


def test_execute_from_file_shows_each_query(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
client = DummyClient()
sql_file = tmp_path / 'query.sql'
sql_file.write_text('select 1; select 2;', encoding='utf-8')
client.destructive_warning = False
client.destructive_keywords = set()
client.sqlexecute = FakeSQLExecute()

assert list(client.execute_from_file(f'--show {sql_file}')) == [
SQLResult(status='ran select 1;'),
SQLResult(status='ran select 2;'),
]
assert capsys.readouterr().out == '> select 1;\n> select 2;\n'


def test_execute_from_file_parses_special_option_and_preserves_filename(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand All @@ -636,42 +692,48 @@ def open_file(path: str) -> IteratedFile:
assert opened_paths == ['query file.sql']


def test_execute_from_file_reports_missing_filename_after_special_option() -> None:
@pytest.mark.parametrize('options', ['--special', '--show', '--special --show'])
def test_execute_from_file_reports_missing_filename_after_options(options: str) -> None:
client = DummyClient()

assert list(client.execute_from_file('--special')) == [SQLResult(status='Missing required argument: filename.')]
assert list(client.execute_from_file(options)) == [SQLResult(status='Missing required argument: filename.')]


def test_execute_from_file_runs_permitted_special_commands(tmp_path: Path) -> None:
def test_execute_from_file_runs_permitted_special_commands(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
client = DummyClient()
sql_file = tmp_path / 'query.sql'
sql_file.write_text('select 1; /status; select 2;', encoding='utf-8')
client.destructive_warning = False
client.destructive_keywords = set()
client.sqlexecute = FakeSQLExecute()

assert result_statuses(client.execute_from_file(f'--special {sql_file}')) == [
'ran select 1;',
'ran /status',
'ran select 2;',
assert list(client.execute_from_file(f'--show --special {sql_file}')) == [
SQLResult(status='ran select 1;'),
SQLResult(status='ran /status'),
SQLResult(status='ran select 2;'),
]
assert capsys.readouterr().out == '> select 1;\n> /status\n> select 2;\n'
assert client.sqlexecute.runs == ['select 1;', '/status', 'select 2;']


def test_execute_from_file_stops_at_disallowed_special_command(tmp_path: Path) -> None:
def test_execute_from_file_stops_at_disallowed_special_command(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
client = DummyClient()
sql_file = tmp_path / 'query.sql'
sql_file.write_text('select 1; /pager; select 2;', encoding='utf-8')
client.destructive_warning = False
client.destructive_keywords = set()
client.sqlexecute = FakeSQLExecute()

results = list(client.execute_from_file(f'--special {sql_file}'))
results = list(client.execute_from_file(f'--show --special {sql_file}'))

assert result_statuses(iter(results)) == [
'ran select 1;',
'Special command is never permitted in source files: /pager.',
assert results == [
SQLResult(status='ran select 1;'),
SQLResult(
status='Special command is never permitted in source files: /pager.',
is_error=True,
),
]
assert capsys.readouterr().out == '> select 1;\n'
assert results[-1].is_error is True
assert client.sqlexecute.runs == ['select 1;']

Expand Down
22 changes: 16 additions & 6 deletions test/pytests/test_completion_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -889,15 +889,25 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch):
('\\dt+ ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]),
(
'\\. ',
[{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}],
[{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}],
),
(
'source ',
[{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}],
[{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}, {'type': 'file_name'}],
),
('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special']}]),
('source --s', [{'type': 'special_subcommand', 'subcommands': ['--special', '--show']}]),
('source --special', []),
('source --special ', [{'type': 'file_name'}]),
(
'source --special ',
[{'type': 'special_subcommand', 'subcommands': ['--show']}, {'type': 'file_name'}],
),
('source --special --s', [{'type': 'special_subcommand', 'subcommands': ['--show']}]),
('source --show', []),
(
'source --show ',
[{'type': 'special_subcommand', 'subcommands': ['--special']}, {'type': 'file_name'}],
),
('source --show --special ', [{'type': 'file_name'}]),
('source --special query.sql', [{'type': 'file_name'}]),
('source query.sql', [{'type': 'file_name'}]),
('\\o ', [{'type': 'file_name'}]),
Expand Down Expand Up @@ -1848,13 +1858,13 @@ def test_source_is_file(expression):
special.register_special_command(
...,
'source',
'\\. <filename>',
'\\. <file>',
'Execute commands from file.',
aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)],
)
suggestions = suggest_type(expression, expression)
assert suggestions == [
{'type': 'special_subcommand', 'subcommands': ['--special']},
{'type': 'special_subcommand', 'subcommands': ['--special', '--show']},
{'type': 'file_name'},
]

Expand Down
Loading
Loading