Skip to content
Open
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
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Upcoming (TBD)
==============

Features
---------
* Add a `/dsn` command to manage persisted DSN aliases.


2.0.0 (2026/07/03)
==============

Expand Down
2 changes: 2 additions & 0 deletions mycli/TIPS
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ the /watch command executes a query every N seconds!

use /bug to file a bug on GitHub!

use /dsn to manage saved DSNs!

###
### environment variables
###
Expand Down
2 changes: 2 additions & 0 deletions mycli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from mycli.main_modes import repl as repl_package
from mycli.output import OutputMixin
from mycli.packages import special
from mycli.packages.special.dsn_aliases import DsnAliases
from mycli.packages.special.favoritequeries import FavoriteQueries
from mycli.packages.tabular_output import sql_format
from mycli.schema_prefetcher import SchemaPrefetcher
Expand Down Expand Up @@ -106,6 +107,7 @@ def __init__(
self.default_keepalive_ticks = c['connection'].as_int('default_keepalive_ticks')

FavoriteQueries.instance = FavoriteQueries.from_config(self.config)
DsnAliases.instance = DsnAliases.from_config(self.config)

self.dsn_alias: str | None = None
self.main_formatter = TabularOutputFormatter(format_name=c["main"]["table_format"])
Expand Down
60 changes: 60 additions & 0 deletions mycli/packages/special/dsn_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations


class DsnAliases:
section_name: str = 'alias_dsn'

usage = """
DSN aliases are a way to save frequently used connections
with a short alias. You can manage them by editing ~/.myclirc
directly or by using this command.

Examples:

# List all DSN aliases
mysql> /dsn list
┌───────┬───────────────────────────────┐
│ Alias │ DSN │
├───────┼───────────────────────────────┤
│ rocks │ mysql://mycli@localhost/mysql │
└───────┴───────────────────────────────┘

# Save a new DSN alias based on the current connection.
# The password will not be included!
mysql> /dsn save connection_1

# Delete a DSN alias.
mysql> /dsn delete connection_1
"""

# Class-level variable, for convenience to use as a singleton.
instance: DsnAliases

def __init__(self, config) -> None:
self.config = config

@classmethod
def from_config(cls, config):
return DsnAliases(config)

def list(self) -> list[str]:
return list(self.config.get(self.section_name, {}))

def get(self, alias: str) -> str | None:
return self.config.get(self.section_name, {}).get(alias, None)

def save(self, alias: str, dsn: str) -> str:
self.config.encoding = 'utf-8'
if self.section_name not in self.config:
self.config[self.section_name] = {}
self.config[self.section_name][alias] = dsn
self.config.write()
return f'Saved: {alias}'

def delete(self, alias: str) -> str:
try:
del self.config[self.section_name][alias]
except KeyError:
return f'Not Found: {alias}'
self.config.write()
return f'Deleted: {alias}'
48 changes: 47 additions & 1 deletion mycli/packages/special/iocommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
from mycli.compat import WIN
from mycli.packages.interactive_utils import confirm_destructive_query
from mycli.packages.special.delimitercommand import DelimiterCommand
from mycli.packages.special.dsn_aliases import DsnAliases
from mycli.packages.special.favoritequeries import FavoriteQueries
from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS
from mycli.packages.special.main import ArgType, SpecialCommandAlias, special_command
from mycli.packages.special.main import execute as special_execute
from mycli.packages.special.utils import handle_cd_command
from mycli.packages.special.utils import compute_current_dsn, handle_cd_command
from mycli.packages.sqlresult import SQLResult

sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment]
Expand All @@ -45,6 +46,7 @@
}
delimiter_command = DelimiterCommand()
favoritequeries = FavoriteQueries(ConfigObj())
dsn_aliases = DsnAliases(ConfigObj())
DESTRUCTIVE_KEYWORDS: list[str] = []
SHOW_WARNINGS_ENABLED: bool = False

Expand Down Expand Up @@ -439,6 +441,50 @@ def delete_favorite_query(arg: str, **_) -> list[SQLResult]:
return [SQLResult(status=status)]


@special_command(
r'\dsn',
'/dsn <help|list|show|save|delete>',
'Manage saved DSNs.',
arg_type=ArgType.PARSED_QUERY,
case_sensitive=False,
)
def dsn(
cur: Cursor,
arg: str | None = None,
arg_type: ArgType = ArgType.PARSED_QUERY,
command_verbosity: bool = False,
) -> list[SQLResult]:
args = shlex.split(arg or '')
if len(args) == 1 and args[0].lower() == 'show':
dsn = compute_current_dsn(cur)
header = ['Current Connection']
show_rows = [(dsn,)]
return [SQLResult(header=header, rows=show_rows)]
elif args and args[0].lower() == 'save':
if len(args) != 2:
return [SQLResult(status='Error: a single alias-name argument is required to save.')]
dsn = compute_current_dsn(cur)
alias = args[1]
status = DsnAliases.instance.save(alias, dsn)
return [SQLResult(status=status)]
elif args and args[0].lower() == 'delete':
if len(args) != 2:
return [SQLResult(status='Error: a single alias-name argument is required to delete.')]
alias = args[1]
status = DsnAliases.instance.delete(alias)
return [SQLResult(status=status)]
elif args and args[0].lower() == 'list':
header = ['Alias', 'DSN']
list_rows = [(r, DsnAliases.instance.get(r)) for r in DsnAliases.instance.list()]
if not list_rows:
status = 'No DSN Aliases found.'
else:
status = None
return [SQLResult(status=status, header=header, rows=list_rows)]
else:
return [SQLResult(preamble=DsnAliases.instance.usage)]


@special_command(
"system",
"/system [-r] <command>",
Expand Down
22 changes: 22 additions & 0 deletions mycli/packages/special/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import logging
import os
from typing import Any
from urllib.parse import quote as urlquote
from urllib.parse import urlencode

import click
import pymysql
Expand Down Expand Up @@ -143,3 +145,23 @@ def get_server_timezone(variables: dict[str, Any]) -> str:

def get_local_timezone() -> str:
return datetime.datetime.now().astimezone().tzname() or ''


def compute_current_dsn(cur: Cursor) -> str:
conn = cur.connection
user = urlquote(conn.user or '')
host = conn.host or 'localhost'
port = f':{conn.port}'
db = urlquote(conn.db.decode() if isinstance(conn.db, bytes) else (conn.db or ''))
if db:
db = f'/{db}'
query_part = {}
if getattr(conn, 'unix_socket', None):
query_part['socket'] = conn.unix_socket
port = ''
if getattr(conn, 'charset', None) and conn.charset != 'utf8mb4':
query_part['character_set'] = conn.charset
dsn = f'mysql://{user}@{host}{port}{db}'
if query_part:
dsn += '?' + urlencode(query_part)
return dsn
77 changes: 39 additions & 38 deletions test/features/fixture_data/help_commands.txt
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
+-----------------+----------+---------------------------------+-------------------------------------------------------------+
| Command | Shortcut | Usage | Description |
+-----------------+----------+---------------------------------+-------------------------------------------------------------+
| /bug | <null> | /bug | File a bug on GitHub. |
| /clip | <null> | /clip | <query>\clip | Copy query to the system clipboard. |
| /dt | <null> | /dt[+] [table] | List or describe tables. |
| /edit | /e | /edit <filename> | <query>\edit | Edit query with editor (uses $VISUAL or $EDITOR). |
| /f | <null> | /f [name [args..]] | List or execute favorite queries. |
| /fd | <null> | /fd <name> | Delete a favorite query. |
| /fs | <null> | /fs <name> <query> | Save a favorite query. |
| \g | <null> | <query>\g | Display query results (mnemonic: go). |
| \G | <null> | <query>\G | Display query results vertically. |
| /l | <null> | /l | List databases. |
| /llm | /ai | /llm [arguments] | Interrogate an LLM. See "/llm help". |
| /once | /o | /once [-o] <filename> | Append next result to an output file (overwrite using -o). |
| /pipe_once | /| | /pipe_once <command> | Send next result to a subprocess. |
| /timing | /t | /timing | Toggle timing of queries. |
| /connect | /r | /connect [database] | Reconnect to the server, optionally switching databases. |
| /delimiter | <null> | /delimiter <string> | Change end-of-statement delimiter. |
| /exit | /q | /exit | Exit. |
| /help | /? | /help [term] | Show this table, or search for help on a term. |
| /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. |
| /pager | /P | /pager [command] | Set pager to [command]. Print query results via pager. |
| /prompt | /R | /prompt <string> | 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 <filename> | 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). |
| /use | /u | /use <database> | Change to a new database. |
| /warnings | /W | /warnings | Enable automatic warnings display. |
| /watch | <null> | /watch [seconds] [-c] <query> | Execute query every [seconds] seconds (5 by default). |
+-----------------+----------+---------------------------------+-------------------------------------------------------------+
+-----------------+----------+-----------------------------------+-------------------------------------------------------------+
| Command | Shortcut | Usage | Description |
+-----------------+----------+-----------------------------------+-------------------------------------------------------------+
| /bug | <null> | /bug | File a bug on GitHub. |
| /clip | <null> | /clip | <query>\clip | Copy query to the system clipboard. |
| /dsn | <null> | /dsn <help|list|show|save|delete> | Manage saved DSNs. |
| /dt | <null> | /dt[+] [table] | List or describe tables. |
| /edit | /e | /edit <filename> | <query>\edit | Edit query with editor (uses $VISUAL or $EDITOR). |
| /f | <null> | /f [name [args..]] | List or execute favorite queries. |
| /fd | <null> | /fd <name> | Delete a favorite query. |
| /fs | <null> | /fs <name> <query> | Save a favorite query. |
| \g | <null> | <query>\g | Display query results (mnemonic: go). |
| \G | <null> | <query>\G | Display query results vertically. |
| /l | <null> | /l | List databases. |
| /llm | /ai | /llm [arguments] | Interrogate an LLM. See "/llm help". |
| /once | /o | /once [-o] <filename> | Append next result to an output file (overwrite using -o). |
| /pipe_once | /| | /pipe_once <command> | Send next result to a subprocess. |
| /timing | /t | /timing | Toggle timing of queries. |
| /connect | /r | /connect [database] | Reconnect to the server, optionally switching databases. |
| /delimiter | <null> | /delimiter <string> | Change end-of-statement delimiter. |
| /exit | /q | /exit | Exit. |
| /help | /? | /help [term] | Show this table, or search for help on a term. |
| /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. |
| /pager | /P | /pager [command] | Set pager to [command]. Print query results via pager. |
| /prompt | /R | /prompt <string> | 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 <filename> | 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). |
| /use | /u | /use <database> | Change to a new database. |
| /warnings | /W | /warnings | Enable automatic warnings display. |
| /watch | <null> | /watch [seconds] [-c] <query> | Execute query every [seconds] seconds (5 by default). |
+-----------------+----------+-----------------------------------+-------------------------------------------------------------+
102 changes: 102 additions & 0 deletions test/pytests/test_dsn_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from collections.abc import Mapping

from mycli.packages.special.dsn_aliases import DsnAliases


class DummyConfig(dict):
def __init__(self, initial: Mapping[str, object] | None = None) -> None:
super().__init__(initial or {})
self.encoding: str | None = None
self.write_calls = 0

def write(self) -> None:
self.write_calls += 1


def test_from_config_returns_instance_with_same_config() -> None:
config = DummyConfig()

aliases = DsnAliases.from_config(config)

assert isinstance(aliases, DsnAliases)
assert aliases.config is config


def test_list_and_get_use_alias_dsn_section() -> None:
config = DummyConfig({
'alias_dsn': {
'prod': 'mysql://prod/db',
'staging': 'mysql://staging/db',
},
})
aliases = DsnAliases(config)

assert aliases.list() == ['prod', 'staging']
assert aliases.get('prod') == 'mysql://prod/db'
assert aliases.get('missing') is None


def test_list_returns_empty_list_when_section_is_missing() -> None:
aliases = DsnAliases(DummyConfig())

assert aliases.list() == []


def test_save_creates_section_sets_encoding_and_writes_config() -> None:
config = DummyConfig()
aliases = DsnAliases(config)

result = aliases.save('prod', 'mysql://prod/db')

assert result == 'Saved: prod'
assert config.encoding == 'utf-8'
assert config == {'alias_dsn': {'prod': 'mysql://prod/db'}}
assert config.write_calls == 1


def test_save_updates_existing_section_and_writes_config() -> None:
config = DummyConfig({'alias_dsn': {'prod': 'mysql://prod/db'}})
aliases = DsnAliases(config)

result = aliases.save('staging', 'mysql://staging/db')

assert result == 'Saved: staging'
assert config.encoding == 'utf-8'
assert config['alias_dsn'] == {
'prod': 'mysql://prod/db',
'staging': 'mysql://staging/db',
}
assert config.write_calls == 1


def test_delete_removes_existing_alias_and_writes_config() -> None:
config = DummyConfig({'alias_dsn': {'prod': 'mysql://prod/db'}})
aliases = DsnAliases(config)

result = aliases.delete('prod')

assert result == 'Deleted: prod'
assert config['alias_dsn'] == {}
assert config.write_calls == 1


def test_delete_returns_not_found_without_writing_config() -> None:
config = DummyConfig({'alias_dsn': {'prod': 'mysql://prod/db'}})
aliases = DsnAliases(config)

result = aliases.delete('missing')

assert result == 'Not Found: missing'
assert config['alias_dsn'] == {'prod': 'mysql://prod/db'}
assert config.write_calls == 0


def test_delete_returns_not_found_when_section_is_missing() -> None:
config = DummyConfig()
aliases = DsnAliases(config)

result = aliases.delete('missing')

assert result == 'Not Found: missing'
assert config == {}
assert config.write_calls == 0
Loading
Loading