From d5282a46f43a03f72f83cbd183ab3b0b136ca1cd Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 18 Jul 2026 10:48:44 -0400 Subject: [PATCH] make fallback part of explicit password sources Include "fallback" as an explicit password source, which means it can be removed from the list of sources. It will always be treated as if it was last in the precedence list. The "fallback" source refers to the interactive prompt after an initial failed connection, which is distinct from the interactive prompt explicitly requested by --password at the end of the CLI argument list. Incidentally remove a needless line unsetting keyring_retrieved_cleanly. --- changelog.md | 1 + mycli/client_connection.py | 14 ++++++++------ mycli/myclirc | 6 ++++-- mycli/password_sources.py | 2 ++ test/myclirc | 6 ++++-- test/pytests/test_client_connection.py | 25 +++++++++++++++++++++++++ test/pytests/test_password_sources.py | 11 +++++++++++ 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/changelog.md b/changelog.md index e003db3b..e666696e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Upcoming (TBD) Features --------- * Make password sources and precedence configurable. +* Ability to deselect fallback interactive password prompt. Bug Fixes diff --git a/mycli/client_connection.py b/mycli/client_connection.py index f07f6b18..6ee8169f 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -192,7 +192,6 @@ def connect( # prompt for password if requested by user if passwd == EMPTY_PASSWORD_FLAG_SENTINEL: passwd = click.prompt(f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True) - keyring_retrieved_cleanly = False # should not fail, but will help the typechecker assert not isinstance(passwd, int) @@ -286,15 +285,18 @@ def _connect( _connect( retry_ssl=True, keyring_retrieved_cleanly=keyring_retrieved_cleanly, keyring_save_eligible=keyring_save_eligible ) - elif e1.args[0] == ACCESS_DENIED_ERROR and connection_info["password"] is None: + elif e1.args[0] == ACCESS_DENIED_ERROR and connection_info["password"] is None and 'fallback' in password_source_precedence: # if we already tried and failed to connect with a new password, raise the error if retry_password: raise e1 - # ask the user for a new password and try to connect again - new_password = click.prompt( - f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True + password_candidates.add_loader( + 'fallback', + lambda: click.prompt( + f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True + ), ) - connection_info["password"] = new_password + fallback_password = password_candidates.resolve(['fallback']) + connection_info["password"] = fallback_password.value if fallback_password is not None else None keyring_retrieved_cleanly = False _connect( retry_password=True, diff --git a/mycli/myclirc b/mycli/myclirc index f3d470c4..39d55fad 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -207,7 +207,7 @@ pager = 'less' # Comma-separated sources to respect for a password, in order of precedence. # Valid choices: -# * prompt # interactive fallback, or plain trailing --password at the CLI +# * prompt # plain trailing --password at the CLI # * literal # --password= at the CLI # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable @@ -215,7 +215,9 @@ pager = 'less' # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring -password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring +# * fallback # interactive prompt after a passwordless connection fails +# If "fallback" is given, it must come last. +password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring, fallback # Whether to store and retrieve passwords from the system keyring. # * False - never use keyring diff --git a/mycli/password_sources.py b/mycli/password_sources.py index be8df3fa..94339be7 100644 --- a/mycli/password_sources.py +++ b/mycli/password_sources.py @@ -15,6 +15,7 @@ 'vault', 'login_path', 'keyring', + 'fallback', ] PasswordValue = str | int PasswordLoader = Callable[[], PasswordValue | None] @@ -28,6 +29,7 @@ 'vault', 'login_path', 'keyring', + 'fallback', ] diff --git a/test/myclirc b/test/myclirc index 7945eefd..050d902d 100644 --- a/test/myclirc +++ b/test/myclirc @@ -207,7 +207,7 @@ pager = python test/features/wrappager.py ---boundary--- # Comma-separated sources to respect for a password, in order of precedence. # Valid choices: -# * prompt # interactive fallback, or plain trailing --password at the CLI +# * prompt # plain trailing --password at the CLI # * literal # --password= at the CLI # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable @@ -215,7 +215,9 @@ pager = python test/features/wrappager.py ---boundary--- # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring -password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring +# * fallback # interactive prompt after a passwordless connection fails +# If "fallback" is given, it must come last. +password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring, fallback # Whether to store and retrieve passwords from the system keyring. # * False - never use keyring diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index 82c3b565..fdf9b7e4 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -695,6 +695,31 @@ def test_connect_prompts_and_retries_after_access_denied_without_password( assert [call['password'] for call in FakeSQLExecute.calls] == [None, 'new-secret'] +def test_connect_does_not_prompt_after_access_denied_when_fallback_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = DummyClient( + config={ + 'main': {'password_sources': [source for source in KNOWN_PASSWORD_SOURCES if source != 'fallback']}, + 'connection': {}, + } + ) + FakeSQLExecute.effects = [op_error(client_connection.ACCESS_DENIED_ERROR)] + prompts: list[str] = [] + monkeypatch.setattr( + client_connection.click, + 'prompt', + lambda text, **_kwargs: prompts.append(text) or 'new-secret', # type: ignore[func-returns-value] + ) + + with pytest.raises(SystemExit) as excinfo: + client.connect(user='alice', host='db', port=3307) + + assert excinfo.value.code == 1 + assert prompts == [] + assert [call['password'] for call in FakeSQLExecute.calls] == [None] + + def test_connect_exits_when_password_retry_fails(monkeypatch: pytest.MonkeyPatch) -> None: client = DummyClient() FakeSQLExecute.effects = [ diff --git a/test/pytests/test_password_sources.py b/test/pytests/test_password_sources.py index 2d637735..d76df268 100644 --- a/test/pytests/test_password_sources.py +++ b/test/pytests/test_password_sources.py @@ -63,6 +63,17 @@ def test_resolve_falls_back_when_a_loader_returns_none() -> None: assert selected.value == 'mylogin-secret' +def test_resolve_fallback_loader() -> None: + candidates = PasswordCandidates() + candidates.add_loader('fallback', lambda: 'fallback-secret') + + selected = candidates.resolve(['fallback']) + + assert selected is not None + assert selected.source == 'fallback' + assert selected.value == 'fallback-secret' + + def test_resolve_skips_unknown_source_and_returns_none(capsys: pytest.CaptureFixture[str]) -> None: candidates = PasswordCandidates()