From 1fe534aeae05f3145295245f884f05a86dac2fea Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 11 Apr 2026 16:14:20 -0400 Subject: [PATCH] allow CLI passwords starting with dash characters Allow the argument to --password (and aliases) to start with a dash character, by stopping to pass the "flag_value" parameter to click. Now --password (and aliases) always take a value at the CLI, unless given in the final position, in which case the flag works as a request for an interactive prompt. This retains the mechanism of EMPTY_PASSWORD_FLAG_SENTINEL, just does not use "flag_value" to set the sentinel value. There still remains the special-casing of DSNs after --password flags, which, while not related to the dash-character bug, still defines some special strings which can't be used as CLI password arguments. --- changelog.md | 4 ++- mycli/constants.py | 2 ++ mycli/main.py | 13 ++++---- mycli/packages/cli_utils.py | 15 +++++++-- test/pytests/test_cli_utils.py | 8 +++++ test/pytests/test_main.py | 60 +--------------------------------- 6 files changed, 33 insertions(+), 69 deletions(-) diff --git a/changelog.md b/changelog.md index 74d16069..8ee5d3ab 100644 --- a/changelog.md +++ b/changelog.md @@ -7,7 +7,8 @@ Features * Make `--progress` and `--checkpoint` strictly by statement. * Allow more characters in passwords read from a file. * Show sponsors and contributors separately in startup messages. -* Add support for expired password (sandbox) mode (#440) +* Add support for expired password (sandbox) mode (#440). +* Limit `--password` without an argument to the final position. Bug Fixes @@ -22,6 +23,7 @@ Bug Fixes * Run empty `--execute` arguments instead of ignoring the flag. * Exit with error when the `--batch` argument is an empty string. * Avoid logging SSH passwords. +* Allow passwords defined at the CLI to start with dash. Internal diff --git a/mycli/constants.py b/mycli/constants.py index f6ef1900..9454f26e 100644 --- a/mycli/constants.py +++ b/mycli/constants.py @@ -17,3 +17,5 @@ # MySQL error codes not available in pymysql.constants.ER ER_MUST_CHANGE_PASSWORD_LOGIN = 1862 ER_MUST_CHANGE_PASSWORD = 1820 + +EMPTY_PASSWORD_FLAG_SENTINEL = -1 diff --git a/mycli/main.py b/mycli/main.py index ae6ca3c5..36092c7a 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -56,6 +56,7 @@ DEFAULT_HOST, DEFAULT_PORT, DEFAULT_WIDTH, + EMPTY_PASSWORD_FLAG_SENTINEL, ER_MUST_CHANGE_PASSWORD_LOGIN, ISSUES_URL, REPO_URL, @@ -87,8 +88,6 @@ sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None # type: ignore[assignment] sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None # type: ignore[assignment] -EMPTY_PASSWORD_FLAG_SENTINEL = -1 - class IntOrStringClickParamType(click.ParamType): name = 'text' # display as TEXT in helpdoc @@ -1221,9 +1220,11 @@ class CliArgs: '--password', 'password', type=INT_OR_STRING_CLICK_TYPE, - is_flag=False, - flag_value=EMPTY_PASSWORD_FLAG_SENTINEL, - help='Prompt for (or pass in cleartext) the password to connect to the database.', + help=dedent( + """Password to connect to the database. + Use with a value to set the password at the CLI, or alone in the last position to request a prompt. + """ + ), ) password_file: str | None = clickdc.option( type=click.Path(), @@ -1918,7 +1919,7 @@ def get_password_from_file(password_file: str | None) -> str | None: def main() -> int | None: try: result = click_entrypoint.main( - filtered_sys_argv(), + filtered_sys_argv(), # type: ignore[arg-type] standalone_mode=False, # disable builtin exception handling prog_name='mycli', ) diff --git a/mycli/packages/cli_utils.py b/mycli/packages/cli_utils.py index 65950130..78cd5bc7 100644 --- a/mycli/packages/cli_utils.py +++ b/mycli/packages/cli_utils.py @@ -1,13 +1,22 @@ from __future__ import annotations import sys +from typing import Sequence +from mycli.constants import EMPTY_PASSWORD_FLAG_SENTINEL + + +def filtered_sys_argv() -> Sequence[str | int]: + args: Sequence[str | int] = sys.argv[1:] + password_flag_forms = ['-p', '--pass', '--password'] -def filtered_sys_argv() -> list[str]: - args = sys.argv[1:] if args == ['-h']: args = ['--help'] - return args + + if args and args[-1] in password_flag_forms: + args = list(args) + [EMPTY_PASSWORD_FLAG_SENTINEL] + + return list(args) def is_valid_connection_scheme(text: str) -> tuple[bool, str | None]: diff --git a/test/pytests/test_cli_utils.py b/test/pytests/test_cli_utils.py index 1d01d3e6..1fbba948 100644 --- a/test/pytests/test_cli_utils.py +++ b/test/pytests/test_cli_utils.py @@ -2,6 +2,7 @@ import pytest +from mycli.constants import EMPTY_PASSWORD_FLAG_SENTINEL from mycli.packages import cli_utils from mycli.packages.cli_utils import ( filtered_sys_argv, @@ -22,6 +23,13 @@ def test_filtered_sys_argv(monkeypatch, argv, expected): assert filtered_sys_argv() == expected +@pytest.mark.parametrize('password_flag', ['-p', '--pass', '--password']) +def test_filtered_sys_argv_appends_empty_password_sentinel(monkeypatch, password_flag): + monkeypatch.setattr(cli_utils.sys, 'argv', ['mycli', 'database', password_flag]) + + assert filtered_sys_argv() == ['database', password_flag, EMPTY_PASSWORD_FLAG_SENTINEL] + + @pytest.mark.parametrize( ('text', 'is_valid', 'invalid_scheme'), [ diff --git a/test/pytests/test_main.py b/test/pytests/test_main.py index 84019590..ad6868dc 100644 --- a/test/pytests/test_main.py +++ b/test/pytests/test_main.py @@ -25,7 +25,7 @@ DEFAULT_USER, TEST_DATABASE, ) -from mycli.main import EMPTY_PASSWORD_FLAG_SENTINEL, MyCli, click_entrypoint +from mycli.main import MyCli, click_entrypoint import mycli.main_modes.repl as repl_mode import mycli.packages.special from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS @@ -1218,64 +1218,6 @@ def run_query(self, query, new_line=True): ) -def test_password_flag_uses_sentinel(monkeypatch): - class Formatter: - format_name = None - - class Logger: - def debug(self, *args, **args_dict): - pass - - def warning(self, *args, **args_dict): - pass - - class MockMyCli: - config = { - 'main': {}, - 'alias_dsn': {}, - 'connection': { - 'default_keepalive_ticks': 0, - }, - } - - def __init__(self, **_args): - self.logger = Logger() - self.destructive_warning = False - self.main_formatter = Formatter() - self.redirect_formatter = Formatter() - self.ssl_mode = 'auto' - self.my_cnf = {'client': {}, 'mysqld': {}} - self.default_keepalive_ticks = 0 - - def connect(self, **args): - MockMyCli.connect_args = args - - def run_query(self, query, new_line=True): - pass - - import mycli.main - - monkeypatch.setattr(mycli.main, 'MyCli', MockMyCli) - runner = CliRunner() - - result = runner.invoke( - mycli.main.click_entrypoint, - args=[ - '--user', - 'user', - '--host', - DEFAULT_HOST, - '--port', - f'{DEFAULT_PORT}', - '--database', - 'database', - '--password', - ], - ) - assert result.exit_code == 0, result.output + ' ' + str(result.exception) - assert MockMyCli.connect_args['passwd'] == EMPTY_PASSWORD_FLAG_SENTINEL - - def test_password_option_uses_cleartext_value(monkeypatch): class Formatter: format_name = None