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 @@ -26,6 +26,7 @@ Internal
* Replace `pyfzf` dependency with internal implementation.
* Use the `uv` outside of the `tox` environment when testing.
* Use forward-slash forms of special commands in the behave test suite.
* Use forward-slash forms of special commands in the pytest test suite.


v2.27.0 (2026/09/29)
Expand Down
4 changes: 2 additions & 2 deletions mycli/key_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ def print_f1_help():
app.print_text('\n')
app.print_text([
('', 'Inline help — type "'),
('bold', 'help'),
('bold', '/help'),
('', '" or "'),
('bold', r'\?'),
('bold', r'/?'),
('', '"\n'),
])
app.print_text([
Expand Down
4 changes: 2 additions & 2 deletions mycli/main_modes/checkup.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ def _environment_checkup() -> None:
print('\n### Environment variables:\n')
table = []
for variable, purpose in [
('EDITOR', r'optional for \edit and C-x C-e'),
('VISUAL', r'optional for \edit and C-x C-e'),
('EDITOR', r'optional for /edit and C-x C-e'),
('VISUAL', r'optional for /edit and C-x C-e'),
]:
if value := os.environ.get(variable):
table.append([f'${variable}', value, purpose])
Expand Down
2 changes: 1 addition & 1 deletion mycli/main_modes/repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,7 +1144,7 @@ def _tips_picker() -> str:
except FileNotFoundError:
pass

return random.choice(tips) if tips else r'\? or "help" for help!'
return random.choice(tips) if tips else r'/? or /help for help!'


def _configure_editor(mycli: 'MyCli') -> None:
Expand Down
8 changes: 4 additions & 4 deletions mycli/packages/special/iocommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ def subst_favorite_query_args(query: str, args: list[str]) -> list[str | None]:
def save_favorite_query(arg: str, **_) -> list[SQLResult]:
"""Save a new favorite query."""

usage = "Syntax: \\fs name query.\n\n" + FavoriteQueries.instance.usage
usage = "Syntax: /fs name query.\n\n" + FavoriteQueries.instance.usage
return _save_favorite_query(arg, usage)


Expand Down Expand Up @@ -704,7 +704,7 @@ def is_favorite_save_command(statement: str) -> bool:
)
def delete_favorite_query(arg: str, **_) -> list[SQLResult]:
"""Delete an existing favorite query."""
usage = "Syntax: \\fd name.\n\n" + FavoriteQueries.instance.usage
usage = "Syntax: /fd name.\n\n" + FavoriteQueries.instance.usage
return _delete_favorite_query(arg, usage)


Expand Down Expand Up @@ -804,7 +804,7 @@ def _edit_dsn_alias(alias: str) -> list[SQLResult]:
)
def execute_system_command(arg: str, **_) -> list[SQLResult]:
"""Execute a system shell command."""
usage = "Syntax: system [-r] [command].\n-r denotes \"raw\" mode, in which output is passed through without formatting."
usage = "Syntax: /system [-r] [command].\n-r denotes \"raw\" mode, in which output is passed through without formatting."

IMPLICIT_RAW_MODE_COMMANDS = {
'clear',
Expand Down Expand Up @@ -1088,7 +1088,7 @@ def flush_pipe_once_if_written(
completion_snippet='run query every N seconds',
)
def watch_query(arg: str, **kwargs) -> Generator[SQLResult, None, None]:
usage = """Syntax: watch [sec] [-c] query.
usage = """Syntax: /watch [sec] [-c] query.
* sec: The interval at the query will be repeated, in seconds.
By default: 5.
* -c: Clears the screen between every iteration.
Expand Down
4 changes: 2 additions & 2 deletions test/pytests/test_checkup.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ def test_environment_checkup(monkeypatch, capsys) -> None:

assert '### Environment variables:' in output
rows = [line.split() for line in output.splitlines()]
assert ['$EDITOR', 'vim', 'optional', 'for', r'\edit', 'and', 'C-x', 'C-e'] in rows
assert ['$VISUAL', 'UNSET', 'optional', 'for', r'\edit', 'and', 'C-x', 'C-e'] in rows
assert ['$EDITOR', 'vim', 'optional', 'for', r'/edit', 'and', 'C-x', 'C-e'] in rows
assert ['$VISUAL', 'UNSET', 'optional', 'for', r'/edit', 'and', 'C-x', 'C-e'] in rows


def test_configuration_checkup_missing_file(capsys) -> None:
Expand Down
2 changes: 1 addition & 1 deletion test/pytests/test_client_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ def test_config_command_returns_unquoted_configobj_value(monkeypatch: pytest.Mon
assert special.execute(None, '/config search SHOW_WARNINGS') == config_result
assert special.execute(None, r'\config search SHOW_WARNINGS') == config_result
with pytest.raises(special.CommandNotFound, match='Command not found: select'):
special.execute(None, r'select 1 \config get main.show_warnings')
special.execute(None, r'select 1 /config get main.show_warnings')


def test_config_edit_opens_user_config_for_slash_and_backslash_commands(
Expand Down
62 changes: 39 additions & 23 deletions test/pytests/test_completion_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,21 +939,21 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch):
@pytest.mark.parametrize(
('text', 'expected'),
[
('\\', [{'type': 'special'}]),
('/', [{'type': 'special'}]),
('use ', [{'type': 'database'}]),
('connect ', [{'type': 'database'}]),
('\\u ', [{'type': 'database'}]),
('\\r ', [{'type': 'database'}]),
('/use ', [{'type': 'database'}]),
('/connect ', [{'type': 'database'}]),
('tableformat ', [{'type': 'table_format'}]),
('redirectformat ', [{'type': 'table_format'}]),
('\\T ', [{'type': 'table_format'}]),
('\\Tr ', [{'type': 'table_format'}]),
('\\f ', [{'type': 'favoritequery'}]),
('\\fs ', [{'type': 'favoritequery'}]),
('\\fd ', [{'type': 'favoritequery'}]),
('/tableformat ', [{'type': 'table_format'}]),
('/redirectformat ', [{'type': 'table_format'}]),
('/f ', [{'type': 'favoritequery'}]),
('/fs ', [{'type': 'favoritequery'}]),
('/fd ', [{'type': 'favoritequery'}]),
('/f report', [{'type': 'favoritequery'}]),
('/f report ', [{'type': 'favoritequery_template_key', 'name': 'report', 'used_keys': set()}]),
('\\f report --u', [{'type': 'favoritequery_template_key', 'name': 'report', 'used_keys': set()}]),
('/f report --u', [{'type': 'favoritequery_template_key', 'name': 'report', 'used_keys': set()}]),
('/f report -', [{'type': 'favoritequery_template_key', 'name': 'report', 'used_keys': set()}]),
('/f report --', [{'type': 'favoritequery_template_key', 'name': 'report', 'used_keys': set()}]),
('/f report -- ', []),
Expand All @@ -976,10 +976,10 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch):
),
('/f report -- --user', []),
('/f report --user="henry', []),
('\\dt ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]),
('\\dt+ ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]),
('/dt ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]),
('/dt+ ', [{'type': 'table', 'schema': []}, {'type': 'view', 'schema': []}, {'type': 'schema'}]),
(
'\\. ',
'/. ',
[
{
'type': 'special_subcommand',
Expand Down Expand Up @@ -1123,10 +1123,10 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch):
('/once ', [{'type': 'file_name', 'all_files': True}]),
('tee ', [{'type': 'file_name', 'all_files': True}]),
('/tee ', [{'type': 'file_name', 'all_files': True}]),
('\\e ', [{'type': 'file_name'}]),
('\\edit ', [{'type': 'file_name'}]),
('\\llm ', [{'type': 'llm'}]),
('\\ai ', [{'type': 'llm'}]),
('/e ', [{'type': 'file_name'}]),
('/edit ', [{'type': 'file_name'}]),
('/llm ', [{'type': 'llm'}]),
('/ai ', [{'type': 'llm'}]),
('/config ', [{'type': 'special_subcommand', 'subcommands': ['help', 'get', 'search', 'edit']}]),
('/config g', [{'type': 'special_subcommand', 'subcommands': ['help', 'get', 'search', 'edit']}]),
('/config s', [{'type': 'special_subcommand', 'subcommands': ['help', 'get', 'search', 'edit']}]),
Expand Down Expand Up @@ -1220,6 +1220,22 @@ def test_suggest_special(text, expected):
assert suggest_special(text) == expected


@pytest.mark.parametrize('command', [r'\u', '/u', r'\r', '/r'])
@pytest.mark.parametrize('argument', ['', 'my'])
def test_short_connection_commands_suggest_databases(command: str, argument: str) -> None:
text = f'{command} {argument}'

assert suggest_type(text, text) == [{'type': 'database'}]


@pytest.mark.parametrize('command', [r'\T', '/T', r'\Tr', '/Tr'])
@pytest.mark.parametrize('argument', ['', 'cs'])
def test_short_format_commands_suggest_table_formats(command: str, argument: str) -> None:
text = f'{command} {argument}'

assert suggest_type(text, text) == [{'type': 'table_format'}]


@pytest.mark.parametrize(
('text', 'query'),
[
Expand Down Expand Up @@ -2053,10 +2069,10 @@ def test_after_as(expression):
@pytest.mark.parametrize(
"expression",
[
"\\. ",
"select 1; \\. ",
"select 1;\\. ",
"select 1 ; \\. ",
"/. ",
"select 1; /. ",
"select 1;/. ",
"select 1 ; /. ",
"source ",
"truncate table test; source ",
"truncate table test ; source ",
Expand All @@ -2068,9 +2084,9 @@ def test_source_is_file(expression):
special.register_special_command(
...,
'source',
'\\. <file>',
'/. <file>',
'Execute commands from file.',
aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)],
aliases=[special.SpecialCommandAlias('/.', case_sensitive=False)],
)
suggestions = suggest_type(expression, expression)
assert suggestions == [
Expand All @@ -2082,7 +2098,7 @@ def test_source_is_file(expression):
@pytest.mark.parametrize(
"expression",
[
"\\f ",
"/f ",
],
)
def test_favorite_name_suggestion(expression):
Expand Down
2 changes: 1 addition & 1 deletion test/pytests/test_hybrid_redirection.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def test_file_redirect_ignores_trailing_sql_comments(operator: str, filename: st
'SELECT 1',
None,
operator[1:],
filename.strip('\'"'),
filename.strip('"').strip("'"),
)


Expand Down
4 changes: 2 additions & 2 deletions test/pytests/test_key_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ def test_print_f1_help_prints_inline_help_and_docs_url(monkeypatch) -> None:
'\n',
[
('', 'Inline help — type "'),
('bold', 'help'),
('bold', '/help'),
('', '" or "'),
('bold', r'\?'),
('bold', r'/?'),
('', '"\n'),
],
[
Expand Down
12 changes: 6 additions & 6 deletions test/pytests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ def test_reconnect_no_database(executor, capsys):
None,
None,
)
sql = "\\r"
sql = "/connect"
result = next(mycli.packages.special.execute(executor, sql))
stdout, _stderr = capsys.readouterr()
assert result.status is None
Expand All @@ -417,7 +417,7 @@ def test_reconnect_with_different_database(executor):
database_1 = TEST_DATABASE
database_2 = DEFAULT_DATABASE
sql_1 = f"use {database_1}"
sql_2 = f"\\r {database_2}"
sql_2 = f"/connect {database_2}"
_result_1 = next(mycli.packages.special.execute(executor, sql_1))
result_2 = next(mycli.packages.special.execute(executor, sql_2))
expected = f'You are now connected to database "{database_2}" as user "{USER}"'
Expand All @@ -441,9 +441,9 @@ def test_reconnect_with_same_database(executor):
None,
)
database = DEFAULT_DATABASE
sql = f"\\u {database}"
sql = f"/use {database}"
result = next(mycli.packages.special.execute(executor, sql))
sql = f"\\r {database}"
sql = f"/connect {database}"
result = next(mycli.packages.special.execute(executor, sql))
expected = f'You are already connected to database "{database}" as user "{USER}"'
assert expected in result.status
Expand Down Expand Up @@ -501,7 +501,7 @@ def test_prompt_socket_short_host(executor):
def test_enable_show_warnings(executor):
mycli = MyCli()
mycli.register_special_commands()
sql = "\\W"
sql = "/warnings"
result = run(executor, sql)
assert result[0]["status"] == "Show warnings enabled."

Expand All @@ -510,7 +510,7 @@ def test_enable_show_warnings(executor):
def test_disable_show_warnings(executor):
mycli = MyCli()
mycli.register_special_commands()
sql = "\\w"
sql = "/nowarnings"
result = run(executor, sql)
assert result[0]["status"] == "Show warnings disabled."

Expand Down
22 changes: 11 additions & 11 deletions test/pytests/test_main_modes_repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,10 @@ def test_complete_while_typing_filter_covers_threshold_and_word_rules(monkeypatc
monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='source x/')))
assert repl_mode.complete_while_typing_filter() is False

monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='\\. abc')))
monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='/. abc')))
assert repl_mode.complete_while_typing_filter() is True

monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='\\. a/')))
monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='/. a/')))
assert repl_mode.complete_while_typing_filter() is False

monkeypatch.setattr(repl_mode, 'get_app', lambda: SimpleNamespace(current_buffer=SimpleNamespace(text='select abc')))
Expand Down Expand Up @@ -425,7 +425,7 @@ def test_repl_picker_helpers_cover_present_and_missing_resources(monkeypatch: py
monkeypatch.setattr(repl_mode.resources, 'files', lambda package: FakeResourceTree({}))
assert repl_mode._contributors_picker() == 'our contributors'
assert repl_mode._sponsors_picker() == 'our sponsors'
assert repl_mode._tips_picker() == r'\? or "help" for help!'
assert repl_mode._tips_picker() == r'/? or /help for help!'


def test_configure_editor_uses_configured_editor(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down Expand Up @@ -1583,7 +1583,7 @@ def test_one_iteration_covers_llm_paths(monkeypatch: pytest.MonkeyPatch) -> None
click_output: list[str] = []
monkeypatch.setattr(repl_mode.click, 'echo', lambda message='', **kwargs: click_output.append(str(message)))
monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: True)
monkeypatch.setattr(repl_mode.special, 'is_llm_command', lambda text: text.startswith('\\llm'))
monkeypatch.setattr(repl_mode.special, 'is_llm_command', lambda text: text.startswith('/llm'))

class FakeSQLExecute:
def __init__(self) -> None:
Expand All @@ -1599,7 +1599,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
lambda text, cur, dbname, field_truncate, section_truncate: ('context', 'select 1', 1.25),
)
cli = make_repl_cli(FakeSQLExecute())
cli.prompt_session = FakePromptSession(['\\llm ask', 'select 1'])
cli.prompt_session = FakePromptSession(['/llm ask', 'select 1'])
repl_mode._one_iteration(
cli,
repl_mode.ReplState(),
Expand All @@ -1608,7 +1608,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
assert cli.output_calls[0][0] == ['None', 'ran:select 1']

cli_finish = make_repl_cli(FakeSQLExecute())
cli_finish.prompt_session = FakePromptSession(['\\llm finish'])
cli_finish.prompt_session = FakePromptSession(['/llm finish'])
cli_finish.format_sqlresult = lambda result, **kwargs: iter([result.status_plain or 'row'])
monkeypatch.setattr(
repl_mode.special,
Expand All @@ -1619,7 +1619,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
assert cli_finish.output_calls[0][0] == ['done']

cli_empty = make_repl_cli(FakeSQLExecute())
cli_empty.prompt_session = FakePromptSession(['\\llm empty'])
cli_empty.prompt_session = FakePromptSession(['/llm empty'])
monkeypatch.setattr(
repl_mode.special,
'handle_llm',
Expand All @@ -1629,7 +1629,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
assert cli_empty.output_calls == []

cli_err = make_repl_cli(FakeSQLExecute())
cli_err.prompt_session = FakePromptSession(['\\llm err'])
cli_err.prompt_session = FakePromptSession(['/llm err'])
monkeypatch.setattr(
repl_mode.special,
'handle_llm',
Expand All @@ -1639,7 +1639,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
assert 'llm boom' in cli_err.echo_calls[-1]

cli_interrupt = make_repl_cli(FakeSQLExecute())
cli_interrupt.prompt_session = FakePromptSession(['\\llm stop'])
cli_interrupt.prompt_session = FakePromptSession(['/llm stop'])
monkeypatch.setattr(
repl_mode.special,
'handle_llm',
Expand All @@ -1649,7 +1649,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
assert cli_interrupt.output_calls == []

cli_quiet = make_repl_cli(FakeSQLExecute())
cli_quiet.prompt_session = FakePromptSession(['\\llm quiet', 'select 2'])
cli_quiet.prompt_session = FakePromptSession(['/llm quiet', 'select 2'])
monkeypatch.setattr(repl_mode.special, 'is_timing_enabled', lambda: False)
monkeypatch.setattr(
repl_mode.special,
Expand All @@ -1671,7 +1671,7 @@ def run(self, text: str) -> Iterator[SQLResult]:
("set password = 'newpass'", True),
('quit', True),
('exit', True),
('\\q', True),
('/q', True),
('SELECT 1', False),
('DROP TABLE t', False),
('USE mydb', False),
Expand Down
Loading
Loading