From 1948b2619b11f7378db701cccd3a7423d42a9e7b Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Tue, 23 Jun 2026 07:45:48 -0400 Subject: [PATCH] migrate connection settings from myclirc [main] * migrate main.default_character_set to the [connection] section if present * migrate main.ssl_mode to connection.default_ssl_mode if present These migrations will affect a modest number of users who installed mycli for the first time during the periods in which the given connection-level settings were in [main] in the default myclirc file. For default_ssl_mode, that is fresh installs between 2026-01-02 and 2026-02-09. For default_character_set, that is fresh installs between 2026-01-22 and 2026-02-02. Users who ran "mycli --checkup" since then were already advised to migrate the settings to the [connection] section. The --checkup instructions for migrating the settings are not removed, though they are somewhat duplicative, to keep the checkup instructions more coherent. Writes to the config file should probably be used sparingly, since some comments and formatting in the user's file can be lost, but this already happens if a favorite query is saved. Motivation: general simplification; bundling all breaking changes into the 2.0 release. The logic for determining whether the user or package configuration controls is too subtle when there are multiple possible locations for the setting. Preparation for release 2.0 --- changelog.md | 2 + mycli/app_state.py | 36 +++++++++++-- mycli/client.py | 2 +- mycli/client_connection.py | 32 ++++++++--- test/pytests/test_app_state.py | 49 +++++++++++++++-- test/pytests/test_checkup.py | 29 ++++++++-- test/pytests/test_client.py | 4 +- test/pytests/test_client_connection.py | 73 +++++++++++++++++++++++++- 8 files changed, 202 insertions(+), 25 deletions(-) diff --git a/changelog.md b/changelog.md index 146bc907..ddec8fcd 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,8 @@ Breaking Changes * Remove support for `my.cnf` vendor MySQL option files. * Remove support for `.myclirc` files in the current working directory. * Make `--batch` and `--execute` non-interactive by default. +* Migrate `default_character_set` out of the `[main]` section of `~/.myclirc`. +* Migrate `ssl_mode` out of the `[main]` section of `~/.myclirc`. Features diff --git a/mycli/app_state.py b/mycli/app_state.py index 4907951c..d8381ea7 100644 --- a/mycli/app_state.py +++ b/mycli/app_state.py @@ -11,11 +11,39 @@ from mycli.client import MyCli -def normalize_ssl_mode(config: ConfigObj) -> tuple[str | None, str | None]: - ssl_mode = config['main'].get('ssl_mode', None) or config['connection'].get('default_ssl_mode', None) +def normalize_ssl_mode( + config: ConfigObj, + config_without_package_defaults: ConfigObj, +) -> tuple[str | None, str | None]: + error_notice: str | None = None + ssl_mode: str | None = None + + if 'main' in config_without_package_defaults and 'ssl_mode' in config_without_package_defaults['main']: + # migration with notice added with mycli 2.0.0 in 2026-07 + # todo: entirely remove support for ssl_mode in [main] + error_notice = ( + 'Mycli 2.0 migration: automatically moving ssl_mode under [main] to default_ssl_mode under [connection] in ~/.myclirc .' + ) + + ssl_mode = config_without_package_defaults['main']['ssl_mode'] + + config_without_package_defaults.encoding = 'utf-8' + if 'connection' not in config_without_package_defaults: + config_without_package_defaults['connection'] = {} + if config_without_package_defaults['connection'].get('default_ssl_mode', None) in (None, ''): + config_without_package_defaults['connection']['default_ssl_mode'] = ssl_mode + else: + ssl_mode = config_without_package_defaults['connection'].get('default_ssl_mode') + error_notice += f'\nBut connection.default_ssl_mode already existed, with the value: "{ssl_mode}".' + del config_without_package_defaults['main']['ssl_mode'] + config_without_package_defaults.write() + + if not ssl_mode and 'default_ssl_mode' in config['connection']: + ssl_mode = config['connection']['default_ssl_mode'] if ssl_mode not in ('auto', 'on', 'off', None): - return None, f'Invalid config option provided for ssl_mode ({ssl_mode}); ignoring.' - return ssl_mode, None + error_notice = f'Invalid config option provided for ssl_mode ({ssl_mode}); ignoring.' + return None, error_notice + return ssl_mode, error_notice def configure_prompt_state( diff --git a/mycli/client.py b/mycli/client.py index 1244593a..320d2fa8 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -132,7 +132,7 @@ def __init__( self.binary_display = c['main'].get('binary_display') self.llm_prompt_field_truncate, self.llm_prompt_section_truncate = llm_prompt_truncation(c) - self.ssl_mode, ssl_mode_error = normalize_ssl_mode(c) + self.ssl_mode, ssl_mode_error = normalize_ssl_mode(c, self.config_without_package_defaults) if ssl_mode_error: self.echo(ssl_mode_error, err=True, fg="red") diff --git a/mycli/client_connection.py b/mycli/client_connection.py index aa8cfd7c..4625ad55 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -76,14 +76,34 @@ def connect( passwd = passwd if isinstance(passwd, (str, int)) else mylogin_cnf["password"] - # default_character_set doesn't check in self.config_without_package_defaults, because the - # option already existed before the my.cnf deprecation. For the same reason, - # default_character_set can be in [connection] or [main]. if not character_set: - if 'default_character_set' in self.config['connection']: + if 'main' in self.config_without_package_defaults and 'default_character_set' in self.config_without_package_defaults['main']: + # migration with notice added with mycli 2.0.0 in 2026-07 + # todo: entirely remove support for default_character_set in [main] + click.secho( + 'Mycli 2.0 migration: automatically moving default_character_set from [main] to [connection] in ~/.myclirc .', + err=True, + fg='red', + ) + character_set = self.config_without_package_defaults['main']['default_character_set'] + + self.config_without_package_defaults.encoding = 'utf-8' + if 'connection' not in self.config_without_package_defaults: + self.config_without_package_defaults['connection'] = {} + if self.config_without_package_defaults['connection'].get('default_character_set', None) in (None, ''): + self.config_without_package_defaults['connection']['default_character_set'] = character_set + else: + character_set = self.config_without_package_defaults["connection"].get("default_character_set") + click.secho( + f'But connection.default_character_set already existed, with the value: "{character_set}".', + err=True, + fg='red', + ) + del self.config_without_package_defaults['main']['default_character_set'] + self.config_without_package_defaults.write() + + if not character_set and 'default_character_set' in self.config['connection']: character_set = self.config['connection']['default_character_set'] - elif 'default_character_set' in self.config['main']: - character_set = self.config['main']['default_character_set'] if not character_set: character_set = DEFAULT_CHARSET diff --git a/test/pytests/test_app_state.py b/test/pytests/test_app_state.py index dee5363c..f97bf957 100644 --- a/test/pytests/test_app_state.py +++ b/test/pytests/test_app_state.py @@ -18,21 +18,60 @@ def __init__(self, login_path: str | None = None) -> None: @pytest.mark.parametrize('ssl_mode', ['auto', 'on', 'off']) def test_normalize_ssl_mode_accepts_known_values(ssl_mode: str) -> None: - config = ConfigObj({'main': {'ssl_mode': ssl_mode}, 'connection': {'default_ssl_mode': 'off'}}) + config = ConfigObj({'main': {'ssl_mode': ssl_mode}, 'connection': {'default_ssl_mode': ssl_mode}}) + config_wo = ConfigObj({'main': {}, 'connection': {}}) - assert normalize_ssl_mode(config) == (ssl_mode, None) + assert normalize_ssl_mode(config, config_wo) == (ssl_mode, None) def test_normalize_ssl_mode_falls_back_to_connection_default() -> None: config = ConfigObj({'main': {'ssl_mode': ''}, 'connection': {'default_ssl_mode': 'on'}}) + config_wo = ConfigObj({'main': {}, 'connection': {}}) - assert normalize_ssl_mode(config) == ('on', None) + assert normalize_ssl_mode(config, config_wo) == ('on', None) + + +def test_normalize_ssl_mode_returns_none_when_not_configured() -> None: + config = ConfigObj({'main': {}, 'connection': {}}) + config_wo = ConfigObj({'main': {}, 'connection': {}}) + + assert normalize_ssl_mode(config, config_wo) == (None, None) + + +def test_normalize_ssl_mode_migrates_deprecated_main_value() -> None: + config = ConfigObj({'main': {}, 'connection': {'default_ssl_mode': 'off'}}) + config_wo = ConfigObj({'main': {'ssl_mode': 'on'}}) + + ssl_mode, warning = normalize_ssl_mode(config, config_wo) + + assert ssl_mode == 'on' + assert ( + warning == 'Mycli 2.0 migration: automatically moving ssl_mode under [main] to default_ssl_mode under [connection] in ~/.myclirc .' + ) + assert config_wo['connection']['default_ssl_mode'] == 'on' + assert 'ssl_mode' not in config_wo['main'] + + +def test_normalize_ssl_mode_uses_existing_connection_value_when_migrating() -> None: + config = ConfigObj({'main': {}, 'connection': {'default_ssl_mode': 'off'}}) + config_wo = ConfigObj({'main': {'ssl_mode': 'on'}, 'connection': {'default_ssl_mode': 'off'}}) + + ssl_mode, warning = normalize_ssl_mode(config, config_wo) + + assert ssl_mode == 'off' + assert warning == ( + 'Mycli 2.0 migration: automatically moving ssl_mode under [main] to default_ssl_mode under [connection] in ~/.myclirc .' + '\nBut connection.default_ssl_mode already existed, with the value: "off".' + ) + assert config_wo['connection']['default_ssl_mode'] == 'off' + assert 'ssl_mode' not in config_wo['main'] def test_normalize_ssl_mode_reports_invalid_values() -> None: - config = ConfigObj({'main': {'ssl_mode': 'required'}, 'connection': {'default_ssl_mode': 'off'}}) + config = ConfigObj({'main': {'ssl_mode': 'required'}, 'connection': {'default_ssl_mode': 'required'}}) + config_wo = ConfigObj() - ssl_mode, warning = normalize_ssl_mode(config) + ssl_mode, warning = normalize_ssl_mode(config, config_wo) assert ssl_mode is None assert warning == 'Invalid config option provided for ssl_mode (required); ignoring.' diff --git a/test/pytests/test_checkup.py b/test/pytests/test_checkup.py index b1789147..c0d65f59 100644 --- a/test/pytests/test_checkup.py +++ b/test/pytests/test_checkup.py @@ -133,7 +133,6 @@ def test_configuration_checkup_reports_missing_unsupported_and_deprecated(capsys 'main': { 'present': '', 'unsupported_item': '', - 'default_character_set': '', }, 'unsupported_section': { 'anything': '', @@ -162,9 +161,6 @@ def test_configuration_checkup_reports_missing_unsupported_and_deprecated(capsys assert '### Unsupported in user ~/.myclirc:' in output assert 'The entire section:\n\n [unsupported_section]\n' in output assert 'The item:\n\n [main]\n unsupported_item =' in output - assert '### Deprecated in user ~/.myclirc:' in output - assert ' [main]\n default_character_set' in output - assert ' [connection]\n default_character_set' in output assert f'{checkup.REPO_URL}/blob/main/mycli/myclirc' in output @@ -200,10 +196,33 @@ def test_configuration_checkup_skips_transitioned_and_free_entry_items(capsys) - assert 'The entire section:\n\n [extra_section]\n' in output assert 'Unsupported in user ~/.myclirc:' in output assert 'The entire section:\n\n [unsupported_section]\n' in output - assert '[connection]\n default_character_set =' not in output assert '[favorite_queries]' not in output +def test_configuration_checkup_reports_deprecated_transition(capsys) -> None: + mycli = SimpleNamespace( + config={ + 'main': {}, + }, + config_without_package_defaults={ + 'main': { + 'ssl_mode': '', + }, + }, + config_without_user_options={ + 'main': {}, + }, + ) + + checkup._configuration_checkup(mycli) + output = capsys.readouterr().out + + assert '### Deprecated in user ~/.myclirc:' in output + assert 'It is recommended to transition:\n\n [main]\n ssl_mode\n\nto\n\n [connection]\n default_ssl_mode' in output + assert '### Unsupported in user ~/.myclirc:' not in output + assert f'{checkup.REPO_URL}/blob/main/mycli/myclirc' in output + + def test_configuration_checkup_up_to_date(capsys) -> None: mycli = SimpleNamespace( config={ diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index dd31fb46..537cccf8 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -31,8 +31,8 @@ def test_init_reports_invalid_ssl_mode(monkeypatch: pytest.MonkeyPatch, tmp_path myclirc = write_myclirc( tmp_path, """ - [main] - ssl_mode = invalid + [connection] + default_ssl_mode = invalid """, ) diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index c2fa0c27..765b69c1 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -52,6 +52,17 @@ def echo(self, *args: Any, **kwargs: Any) -> None: self.echo_calls.append((args, kwargs)) +class WritableConfig(dict[str, Any]): + encoding: str | None = None + + def __init__(self, value: dict[str, Any]) -> None: + super().__init__(value) + self.write_calls = 0 + + def write(self) -> None: + self.write_calls += 1 + + class FakeSQLExecute: calls: list[dict[str, Any]] = [] effects: list[Any] = [] @@ -148,12 +159,70 @@ def test_connect_uses_character_set_from_connection_config() -> None: assert FakeSQLExecute.calls[-1]['character_set'] == 'utf16' -def test_connect_uses_character_set_from_main_config() -> None: - client = DummyClient(config={'main': {'default_character_set': 'utf32'}, 'connection': {}}) +def test_connect_migrates_deprecated_character_set_from_main_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_wo = WritableConfig({'main': {'default_character_set': 'utf32'}}) + client = DummyClient( + config={'main': {}, 'connection': {'default_character_set': 'utf16'}}, + config_without_package_defaults=config_wo, + ) + secho_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr( + client_connection.click, + 'secho', + lambda message, **kwargs: secho_calls.append((message, kwargs)), + ) client.connect(host='db', port=3307) assert FakeSQLExecute.calls[-1]['character_set'] == 'utf32' + assert config_wo.encoding == 'utf-8' + assert config_wo['connection']['default_character_set'] == 'utf32' + assert 'default_character_set' not in config_wo['main'] + assert config_wo.write_calls == 1 + assert secho_calls == [ + ( + 'Mycli 2.0 migration: automatically moving default_character_set from [main] to [connection] in ~/.myclirc .', + {'err': True, 'fg': 'red'}, + ) + ] + + +def test_connect_uses_existing_connection_character_set_when_migrating( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_wo = WritableConfig({ + 'main': {'default_character_set': 'utf32'}, + 'connection': {'default_character_set': 'utf16'}, + }) + client = DummyClient( + config={'main': {}, 'connection': {'default_character_set': 'latin1'}}, + config_without_package_defaults=config_wo, + ) + secho_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr( + client_connection.click, + 'secho', + lambda message, **kwargs: secho_calls.append((message, kwargs)), + ) + + client.connect(host='db', port=3307) + + assert FakeSQLExecute.calls[-1]['character_set'] == 'utf16' + assert config_wo['connection']['default_character_set'] == 'utf16' + assert 'default_character_set' not in config_wo['main'] + assert config_wo.write_calls == 1 + assert secho_calls == [ + ( + 'Mycli 2.0 migration: automatically moving default_character_set from [main] to [connection] in ~/.myclirc .', + {'err': True, 'fg': 'red'}, + ), + ( + 'But connection.default_character_set already existed, with the value: "utf16".', + {'err': True, 'fg': 'red'}, + ), + ] def test_connect_uses_default_character_set_when_none_configured() -> None: