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 @@ -6,6 +6,7 @@ Features
* "Eager" completions for the `source` command, limited to `*.sql` files.
* Suggest column names from all tables in the current database after SELECT (#212)
* Put fuzzy completions more often to the bottom of the suggestion list.
* Store and retrieve passwords using the system keyring.


Bug Fixes
Expand Down
39 changes: 39 additions & 0 deletions mycli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from cli_helpers.utils import strip_ansi
import click
from configobj import ConfigObj
import keyring
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import Completion, DynamicCompleter
from prompt_toolkit.document import Document
Expand Down Expand Up @@ -480,6 +481,8 @@ def connect(
ssh_key_filename: str | None = "",
init_command: str | None = "",
unbuffered: bool | None = None,
use_keyring: bool | None = None,
reset_keyring: bool | None = None,
) -> None:
cnf = {
"database": None,
Expand Down Expand Up @@ -537,11 +540,27 @@ def connect(
# 3. envvar (MYSQL_PWD)
# 4. DSN (mysql://user:password)
# 5. cnf (.my.cnf / etc)
# 6. keyring

keychain_user = f'{user}@{host}'
keychain_domain = 'mycli.net'
keychain_retrieved = False

if passwd is None and use_keyring and not reset_keyring:
passwd = keyring.get_password(keychain_domain, keychain_user)
keychain_retrieved = True

# if no password was found from all of the above sources, ask for a password
if passwd is None:
passwd = click.prompt("Enter password", hide_input=True, show_default=False, default='', type=str, err=True)

if reset_keyring or (use_keyring and not keychain_retrieved):
try:
keyring.set_password(keychain_domain, keychain_user, passwd)
click.secho('Password saved to the system keychain', err=True)
except Exception as e:
click.secho(f'Password not saved to the system keychain: {e}', err=True, fg='red')

# Connect to the database.
def _connect() -> None:
try:
Expand Down Expand Up @@ -1538,6 +1557,13 @@ def get_last_query(self) -> str | None:
'--format', 'batch_format', type=click.Choice(['default', 'csv', 'tsv', 'table']), help='Format for batch or --execute output.'
)
@click.option('--throttle', type=float, default=0.0, help='Pause in seconds between queries in batch mode.')
@click.option(
'--use-keyring',
'use_keyring_cli_opt',
type=click.Choice(['true', 'false', 'reset']),
default=None,
help='Store and retrieve passwords from the system keyring: true/false/reset.',
)
@click.pass_context
def cli(
ctx: click.Context,
Expand Down Expand Up @@ -1590,6 +1616,7 @@ def cli(
noninteractive: bool,
batch_format: str | None,
throttle: float,
use_keyring_cli_opt: str | None,
) -> None:
"""A MySQL terminal client with auto-completion and syntax highlighting.

Expand Down Expand Up @@ -1863,6 +1890,16 @@ def get_password_from_file(password_file: str | None) -> str | None:
if show_warnings:
mycli.show_warnings = show_warnings

if use_keyring_cli_opt is not None and use_keyring_cli_opt.lower() == 'reset':
use_keyring = True
reset_keyring = True
elif use_keyring_cli_opt is None:
use_keyring = str_to_bool(mycli.config['main'].get('use_keyring', 'False'))
reset_keyring = False
else:
use_keyring = str_to_bool(use_keyring_cli_opt)
reset_keyring = False

mycli.connect(
database=database,
user=user,
Expand All @@ -1880,6 +1917,8 @@ def get_password_from_file(password_file: str | None) -> str | None:
init_command=combined_init_cmd,
unbuffered=unbuffered,
charset=charset,
use_keyring=use_keyring,
reset_keyring=reset_keyring,
)

if combined_init_cmd:
Expand Down
7 changes: 7 additions & 0 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ pager = 'less'
# character set for connections without --charset being set at the CLI
default_character_set = utf8mb4

# Whether to store and retrieve passwords from the system keyring.
# See the documentation for https://pypi.org/project/keyring/ for your OS.
# Note that the hostname is considered to be different if short or qualified.
# This can be overridden with --use-keyring= at the CLI.
# A password can be reset with --use-keyring=reset at the CLI.
use_keyring = False

[keys]
# possible values: auto, fzf, reverse_isearch
control_r = auto
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
"pycryptodomex",
"pyfzf >= 0.3.1",
"rapidfuzz ~= 3.14.3",
"keyring ~= 25.7.0",
]

[build-system]
Expand Down
7 changes: 7 additions & 0 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ pager = less
# character set for connections without --charset being set at the CLI
default_character_set = utf8mb4

# Whether to store and retrieve passwords from the system keyring.
# See the documentation for https://pypi.org/project/keyring/ for your OS.
# Note that the hostname is considered to be different if short or qualified.
# This can be overridden with --use-keyring= at the CLI.
# A password can be reset with --use-keyring=reset at the CLI.
use_keyring = False

[keys]
# possible values: auto, fzf, reverse_isearch
control_r = auto
Expand Down
20 changes: 16 additions & 4 deletions test/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,10 @@ def warning(self, *args, **args_dict):
pass

class MockMyCli:
config = {"alias_dsn": {}}
config = {
"main": {},
"alias_dsn": {},
}

def __init__(self, **args):
self.logger = Logger()
Expand Down Expand Up @@ -718,7 +721,10 @@ def run_query(self, query, new_line=True):
and MockMyCli.connect_args["database"] == "arg_database"
)

MockMyCli.config = {"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}}
MockMyCli.config = {
"main": {},
"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"},
}
MockMyCli.connect_args = None

# When a user uses a DSN from the configuration file (alias_dsn),
Expand All @@ -733,7 +739,10 @@ def run_query(self, query, new_line=True):
and MockMyCli.connect_args["database"] == "alias_dsn_database"
)

MockMyCli.config = {"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}}
MockMyCli.config = {
"main": {},
"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"},
}
MockMyCli.connect_args = None

# When a user uses a DSN from the configuration file (alias_dsn)
Expand Down Expand Up @@ -821,7 +830,10 @@ def warning(self, *args, **args_dict):
pass

class MockMyCli:
config = {"alias_dsn": {}}
config = {
"main": {},
"alias_dsn": {},
}

def __init__(self, **args):
self.logger = Logger()
Expand Down