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: