From 29cff5914cb287d39aea8b4e6451c00a77d7f9b1 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Tue, 18 Aug 2026 06:17:26 -0400 Subject: [PATCH] add "/dsn edit" subcommand to edit an existing DSN By analogy to "/favorite edit". But there is no need currently for "/dsn reload" or "/dsn eval", since making a connection implies a restart of mycli. Though we could imagine separating making a connection from restarting mycli in the future. --- changelog.md | 1 + mycli/packages/completion_engine.py | 8 +- mycli/packages/special/dsn_aliases.py | 5 +- mycli/packages/special/iocommands.py | 29 ++++++- test/features/fixture_data/help_commands.txt | 84 +++++++++---------- test/pytests/test_completion_engine.py | 3 + ...est_smart_completion_public_schema_only.py | 7 +- test/pytests/test_special_iocommands.py | 84 +++++++++++++++++++ 8 files changed, 169 insertions(+), 52 deletions(-) diff --git a/changelog.md b/changelog.md index b9bb207a3..20dbc8e63 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Upcoming (TBD) Features --------- * Add alternative interface `/favorite` for favorite queries. +* Add `/dsn edit` subcommand to edit an existing DSN. Bug Fixes diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index d445adbd6..716ce959d 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -879,10 +879,10 @@ def suggest_special(text: str) -> list[dict[str, Any]]: if cmd.lower() in (r'\dsn', '/dsn'): dsn_arguments = _arg.split(maxsplit=1) - completing_delete_target = (len(dsn_arguments) == 1 and text[-1].isspace()) or (len(dsn_arguments) == 2 and not text[-1].isspace()) - if dsn_arguments and dsn_arguments[0].lower() == 'delete' and completing_delete_target: + completing_alias_target = (len(dsn_arguments) == 1 and text[-1].isspace()) or (len(dsn_arguments) == 2 and not text[-1].isspace()) + if dsn_arguments and dsn_arguments[0].lower() in ('edit', 'delete') and completing_alias_target: return [{'type': 'dsn_alias'}] - if dsn_arguments and dsn_arguments[0].lower() == 'delete' and len(dsn_arguments) == 2: + if dsn_arguments and dsn_arguments[0].lower() in ('edit', 'delete') and len(dsn_arguments) == 2: return [] if dsn_arguments and dsn_arguments[0].lower() == 'save': completing_option = (len(dsn_arguments) == 1 and text[-1].isspace()) or ( @@ -903,7 +903,7 @@ def suggest_special(text: str) -> list[dict[str, Any]]: if completing_option: return [{'type': 'special_subcommand', 'subcommands': [option]}] return [] - if dsn_arguments and dsn_arguments[0].lower() in DSN_SUBCOMMANDS - {'delete'}: + if dsn_arguments and dsn_arguments[0].lower() in DSN_SUBCOMMANDS - {'edit', 'delete'}: return [] return [{'type': 'special_subcommand', 'subcommands': list(DSN_SUBCOMMANDS)}] diff --git a/mycli/packages/special/dsn_aliases.py b/mycli/packages/special/dsn_aliases.py index c038452ec..773b484a1 100644 --- a/mycli/packages/special/dsn_aliases.py +++ b/mycli/packages/special/dsn_aliases.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from mycli.client import MyCli -DSN_SUBCOMMANDS = {'help', 'list', 'show', 'save', 'delete'} +DSN_SUBCOMMANDS = {'help', 'list', 'show', 'save', 'edit', 'delete'} INVALID_DSN_ALIAS_ERROR = 'Error: DSN aliases cannot start with a dash.' MISSING = object() @@ -84,6 +84,9 @@ class DsnAliases: │ rocks │ mysql://mycli@localhost/mysql?prompt=%5Cd%3E%5C_ │ └───────┴──────────────────────────────────────────────────┘ + # Edit a DSN saved alias in an external editor. + mysql> /dsn edit rocks + # Delete a DSN alias. mysql> /dsn delete rocks """ diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 1bbcee1b0..8e3120659 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -654,8 +654,8 @@ def _delete_favorite_query(arg: str, usage: str) -> list[SQLResult]: @special_command( r'\dsn', - '/dsn ', - 'Manage saved DSNs.', + '/dsn ', + 'Manage saved DSNs. See /dsn help.', arg_type=ArgType.PARSED_QUERY, case_sensitive=False, ) @@ -685,6 +685,13 @@ def dsn( dsn = DsnAliases.instance.dsn_more(dsn) status = DsnAliases.instance.save(alias, dsn) return [SQLResult(status=status)] + elif args and args[0].lower() == 'edit': + if len(args) != 2: + return [SQLResult(status='Error: a single alias-name argument is required to edit.')] + alias = args[1] + if not is_valid_dsn_alias(alias): + return [SQLResult(status=INVALID_DSN_ALIAS_ERROR)] + return _edit_dsn_alias(alias) elif args and args[0].lower() == 'delete': if len(args) != 2: return [SQLResult(status='Error: a single alias-name argument is required to delete.')] @@ -705,6 +712,24 @@ def dsn( return [SQLResult(preamble=DsnAliases.instance.usage)] +def _edit_dsn_alias(alias: str) -> list[SQLResult]: + dsn = DsnAliases.instance.get(alias) + if dsn is None: + return [SQLResult(status=f'No DSN alias: {alias}')] + + try: + edited_dsn = click.edit(dsn) + if edited_dsn is None: + return [SQLResult(status=f'{alias}: Not Changed.')] + DsnAliases.instance.save(alias, edited_dsn.strip()) + except KeyboardInterrupt: + return [SQLResult(status=f'{alias}: Edit Cancelled.')] + except (click.ClickException, OSError) as error: + return [SQLResult(status=f'Unable to edit DSN alias "{alias}": {error}')] + + return [SQLResult(status=f'{alias}: Edited.')] + + @special_command( "system", "/system [-r] ", diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index 9882dd066..b17340992 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. | -| /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 | 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 | 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_completion_engine.py b/test/pytests/test_completion_engine.py index 5412338b9..8bff2a59d 100644 --- a/test/pytests/test_completion_engine.py +++ b/test/pytests/test_completion_engine.py @@ -960,6 +960,9 @@ def test_suggest_type_handles_parser_results_shorter_than_cursor(monkeypatch): ('/dsn delete ', [{'type': 'dsn_alias'}]), ('/dsn delete pro', [{'type': 'dsn_alias'}]), ('/dsn delete prod ', []), + ('/dsn edit ', [{'type': 'dsn_alias'}]), + ('/dsn edit pro', [{'type': 'dsn_alias'}]), + ('/dsn edit prod ', []), ('/dsn show', []), ('/dsn show ', [{'type': 'special_subcommand', 'subcommands': ['--more']}]), ('/dsn show --m', [{'type': 'special_subcommand', 'subcommands': ['--more']}]), diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 1d1e48d93..c03ce8d5b 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -110,10 +110,11 @@ def test_dsn_subcommand_completion(completer, complete_event): text = '/dsn ' result = completer.get_completions(Document(text=text, cursor_position=len(text)), complete_event) - assert {completion.text for completion in result} == {'help', 'list', 'show', 'save', 'delete'} + assert {completion.text for completion in result} == {'help', 'list', 'show', 'save', 'edit', 'delete'} -def test_dsn_delete_alias_completion(completer, complete_event, monkeypatch): +@pytest.mark.parametrize('command', ['edit', 'delete']) +def test_dsn_alias_completion(completer, complete_event, monkeypatch, command): import mycli.sqlcompleter as sqlcompleter monkeypatch.setattr( @@ -122,7 +123,7 @@ def test_dsn_delete_alias_completion(completer, complete_event, monkeypatch): SimpleNamespace(list=lambda: ['prod', 'staging']), raising=False, ) - text = '/dsn delete pro' + text = f'/dsn {command} pro' result = completer.get_completions(Document(text=text, cursor_position=len(text)), complete_event) assert list(result) == [Completion(text='prod', start_position=-3)] diff --git a/test/pytests/test_special_iocommands.py b/test/pytests/test_special_iocommands.py index a73e2a1c5..ff40736b9 100644 --- a/test/pytests/test_special_iocommands.py +++ b/test/pytests/test_special_iocommands.py @@ -1341,6 +1341,90 @@ def test_dsn_command_rejects_save_without_single_alias(monkeypatch) -> None: assert iocommands.dsn(cur=FakeCursor(), arg=arg)[0].status == error +def test_dsn_command_edits_alias(monkeypatch) -> None: + aliases = FakeDsnAliases({'prod': 'mysql://prod/db'}) + monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False) + monkeypatch.setattr(iocommands.click, 'edit', lambda dsn: ' mysql://new/db\n') + + result = iocommands.dsn(cur=FakeCursor(), arg='edit prod')[0] + + assert result.status == 'prod: Edited.' + assert aliases.saved == [('prod', 'mysql://new/db')] + + +def test_dsn_command_reports_missing_edit_alias(monkeypatch) -> None: + aliases = FakeDsnAliases() + monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False) + + assert iocommands.dsn(cur=FakeCursor(), arg='edit unknown')[0].status == 'No DSN alias: unknown' + + +@pytest.mark.parametrize( + ('editor_result', 'expected_status', 'expected_saved'), + [ + (None, 'prod: Not Changed.', []), + ('', 'prod: Edited.', [('prod', '')]), + ], +) +def test_dsn_command_handles_unchanged_and_empty_edits( + monkeypatch, + editor_result: str | None, + expected_status: str, + expected_saved: list[tuple[str, str]], +) -> None: + aliases = FakeDsnAliases({'prod': 'mysql://prod/db'}) + monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False) + monkeypatch.setattr(iocommands.click, 'edit', lambda dsn: editor_result) + + result = iocommands.dsn(cur=FakeCursor(), arg='edit prod')[0] + + assert result.status == expected_status + assert aliases.saved == expected_saved + + +@pytest.mark.parametrize( + ('error', 'expected_status'), + [ + (KeyboardInterrupt(), 'prod: Edit Cancelled.'), + (OSError('editor failed'), 'Unable to edit DSN alias "prod": editor failed'), + (iocommands.click.ClickException('editor failed'), 'Unable to edit DSN alias "prod": editor failed'), + ], +) +def test_dsn_command_reports_edit_errors(monkeypatch, error: BaseException, expected_status: str) -> None: + aliases = FakeDsnAliases({'prod': 'mysql://prod/db'}) + monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False) + + def fail_edit(dsn: str) -> None: + raise error + + monkeypatch.setattr(iocommands.click, 'edit', fail_edit) + + assert iocommands.dsn(cur=FakeCursor(), arg='edit prod')[0].status == expected_status + assert aliases.saved == [] + + +def test_dsn_command_reports_edit_save_error(monkeypatch) -> None: + aliases = FakeDsnAliases({'prod': 'mysql://prod/db'}) + + def fail_save(alias: str, dsn: str) -> str: + raise OSError('write failed') + + aliases.save = fail_save + monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False) + monkeypatch.setattr(iocommands.click, 'edit', lambda dsn: 'mysql://new/db') + + assert iocommands.dsn(cur=FakeCursor(), arg='edit prod')[0].status == 'Unable to edit DSN alias "prod": write failed' + + +def test_dsn_command_rejects_edit_without_single_valid_alias(monkeypatch) -> None: + monkeypatch.setattr(iocommands.DsnAliases, 'instance', FakeDsnAliases(), raising=False) + + error = 'Error: a single alias-name argument is required to edit.' + assert iocommands.dsn(cur=FakeCursor(), arg='edit')[0].status == error + assert iocommands.dsn(cur=FakeCursor(), arg='edit one two')[0].status == error + assert iocommands.dsn(cur=FakeCursor(), arg='edit -legacy')[0].status == iocommands.INVALID_DSN_ALIAS_ERROR + + def test_dsn_command_deletes_alias(monkeypatch) -> None: aliases = FakeDsnAliases({'prod': 'mysql://prod/db'}) monkeypatch.setattr(iocommands.DsnAliases, 'instance', aliases, raising=False)