diff --git a/changelog.md b/changelog.md index d474c8ec..f9f68fc5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +Upcoming (TBD) +============== + +Features +--------- +* Allow reading DSN aliases from a shared configuration file. + + 2.11.0 (2026/08/07) ============== diff --git a/mycli/client.py b/mycli/client.py index 732d520d..ddcf17f4 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -120,7 +120,12 @@ def __init__( myclirc, c['main'].get('shared_favorites_file'), ) - DsnAliases.instance = DsnAliases.from_config(self.config, self, config_file=myclirc) + DsnAliases.instance = DsnAliases.from_config( + self.config, + self, + config_file=myclirc, + shared_dsns_file=c['main'].get('shared_dsns_file'), + ) self.dsn_alias: str | None = None self.main_formatter = TabularOutputFormatter(format_name=c["main"]["table_format"]) diff --git a/mycli/myclirc b/mycli/myclirc index 8c54913e..0e73f04f 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -77,6 +77,12 @@ show_favorite_query = True # Example: /usr/local/etc/mycli/shared-favorites.ini shared_favorites_file = +# Load additional DSN aliases from the [alias_dsn] section of this file. DSN +# aliases in the user's configuration file take precedence over the shared +# file. The path must be absolute after expanding ~. +# Example: /usr/local/etc/mycli/shared-dsns.ini +shared_dsns_file = + # Beep after long-running queries are completed; 0 to disable. beep_after_seconds = 0 diff --git a/mycli/packages/special/dsn_aliases.py b/mycli/packages/special/dsn_aliases.py index 9c5d1421..c038452e 100644 --- a/mycli/packages/special/dsn_aliases.py +++ b/mycli/packages/special/dsn_aliases.py @@ -1,12 +1,15 @@ from __future__ import annotations +import logging import os from typing import TYPE_CHECKING, Any from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from mycli.config import read_config_file, str_to_bool +from mycli.config import log, read_config_file, str_to_bool from mycli.constants import DEFAULT_CHARSET, DEFAULT_PROMPT, KNOWN_DSN_QUERY_PARAMS +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from mycli.client import MyCli @@ -94,8 +97,44 @@ def __init__(self, config: Any, mycli: MyCli | None = None, config_file: str | N self.config_file = config_file @classmethod - def from_config(cls, config: Any, mycli: MyCli | None = None, config_file: str | None = None) -> DsnAliases: - return DsnAliases(config, mycli, config_file) + def from_config( + cls, + config: Any, + mycli: MyCli | None = None, + config_file: str | None = None, + shared_dsns_file: str | None = None, + ) -> DsnAliases: + aliases = cls(config, mycli, config_file) + if not shared_dsns_file: + return aliases + + shared_dsns_file = os.path.expanduser(shared_dsns_file) + if not os.path.isabs(shared_dsns_file): + log( + logger, + logging.WARNING, + f"Shared DSNs file path must be absolute: '{shared_dsns_file}'.", + ) + return aliases + + if not os.path.isfile(shared_dsns_file): + log( + logger, + logging.WARNING, + f"Unable to read shared DSNs file '{shared_dsns_file}'.", + ) + return aliases + + shared_config = read_config_file(shared_dsns_file) + if shared_config is None: + return aliases + + configured_aliases = config.get(cls.section_name, {}) + shared_aliases = shared_config.get(cls.section_name, {}) + config[cls.section_name] = {} + config[cls.section_name].update(shared_aliases) + config[cls.section_name].update(configured_aliases) + return aliases def _config_for_write(self) -> Any: if self.config_file is None: diff --git a/test/myclirc b/test/myclirc index d66196ca..6cbd7e7c 100644 --- a/test/myclirc +++ b/test/myclirc @@ -77,6 +77,12 @@ show_favorite_query = True # Example: /usr/local/etc/mycli/shared-favorites.ini shared_favorites_file = +# Load additional DSN aliases from the [alias_dsn] section of this file. DSN +# aliases in the user's configuration file take precedence over the shared +# file. The path must be absolute after expanding ~. +# Example: /usr/local/etc/mycli/shared-dsns.ini +shared_dsns_file = + # Beep after long-running queries are completed; 0 to disable. beep_after_seconds = 0 diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index 133e25d7..ac8b4b5b 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -226,6 +226,35 @@ def test_init_configures_dsn_aliases_with_user_config_path(monkeypatch: pytest.M assert DsnAliases.instance.config_file == myclirc +def test_init_loads_shared_dsn_aliases(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + """[alias_dsn] +shared = mysql://shared/db +overridden = mysql://shared/override +""", + encoding='utf-8', + ) + myclirc = write_myclirc( + tmp_path, + f"""[main] +shared_dsns_file = {shared_file} + +[alias_dsn] +local = mysql://local/db +overridden = mysql://local/override +""", + ) + + cli = MyCli(myclirc=myclirc) + + assert DsnAliases.instance.config is cli.config + assert DsnAliases.instance.get('shared') == 'mysql://shared/db' + assert DsnAliases.instance.get('local') == 'mysql://local/db' + assert DsnAliases.instance.get('overridden') == 'mysql://local/override' + + def test_init_uses_default_myclirc_when_xdg_config_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: patch_constructor_side_effects(monkeypatch) config_file_args: list[list[str | Any]] = [] diff --git a/test/pytests/test_dsn_aliases.py b/test/pytests/test_dsn_aliases.py index e17a59ba..97c91a28 100644 --- a/test/pytests/test_dsn_aliases.py +++ b/test/pytests/test_dsn_aliases.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +import logging from pathlib import Path from types import SimpleNamespace from typing import Any @@ -50,6 +51,108 @@ def test_from_config_retains_mycli_runtime() -> None: assert aliases.mycli is mycli +def test_from_config_merges_shared_aliases_with_configured_precedence(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + """[main] +prompt = ignored + +[alias_dsn] +shared = mysql://shared/db +overridden = mysql://shared/override +-hidden = mysql://hidden/db + +[alias_dsn.init-commands] +shared = set shared=1 +""", + encoding='utf-8', + ) + config = DummyConfig({ + 'alias_dsn': { + 'local': 'mysql://local/db', + 'overridden': 'mysql://local/override', + }, + 'alias_dsn.init-commands': {'local': 'set local=1'}, + }) + + aliases = DsnAliases.from_config(config, shared_dsns_file=str(shared_file)) + + assert aliases.list() == ['shared', 'overridden', 'local'] + assert aliases.get('shared') == 'mysql://shared/db' + assert aliases.get('overridden') == 'mysql://local/override' + assert aliases.get('-hidden') is None + assert config['alias_dsn.init-commands'] == {'local': 'set local=1'} + assert 'main' not in config + + +def test_from_config_rejects_relative_shared_file(caplog: pytest.LogCaptureFixture) -> None: + config = DummyConfig({'alias_dsn': {'local': 'mysql://local/db'}}) + + with caplog.at_level(logging.WARNING, logger='mycli.packages.special.dsn_aliases'): + aliases = DsnAliases.from_config(config, shared_dsns_file='shared-myclirc') + + assert aliases.get('local') == 'mysql://local/db' + assert "Shared DSNs file path must be absolute: 'shared-myclirc'." in caplog.text + + +def test_from_config_expands_user_in_shared_file_path(monkeypatch: pytest.MonkeyPatch) -> None: + read_paths: list[str] = [] + monkeypatch.setattr(dsn_aliases_module.os.path, 'expanduser', lambda path: '/expanded/shared-myclirc') + monkeypatch.setattr(dsn_aliases_module.os.path, 'isfile', lambda path: True) + + def read_config_file(path: str) -> DummyConfig: + read_paths.append(path) + return DummyConfig({'alias_dsn': {'shared': 'mysql://shared/db'}}) + + monkeypatch.setattr(dsn_aliases_module, 'read_config_file', read_config_file) + + aliases = DsnAliases.from_config(DummyConfig(), shared_dsns_file='~/shared-myclirc') + + assert read_paths == ['/expanded/shared-myclirc'] + assert aliases.get('shared') == 'mysql://shared/db' + + +def test_from_config_warns_and_continues_for_missing_shared_file( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + config = DummyConfig({'alias_dsn': {'local': 'mysql://local/db'}}) + missing_file = tmp_path / 'missing-myclirc' + + with caplog.at_level(logging.WARNING, logger='mycli.packages.special.dsn_aliases'): + aliases = DsnAliases.from_config(config, shared_dsns_file=str(missing_file)) + + assert aliases.get('local') == 'mysql://local/db' + assert f"Unable to read shared DSNs file '{missing_file}'." in caplog.text + + +def test_from_config_continues_when_shared_file_cannot_be_read(monkeypatch: pytest.MonkeyPatch) -> None: + config = DummyConfig({'alias_dsn': {'local': 'mysql://local/db'}}) + monkeypatch.setattr(dsn_aliases_module.os.path, 'isfile', lambda path: True) + monkeypatch.setattr(dsn_aliases_module, 'read_config_file', lambda path: None) + + aliases = DsnAliases.from_config(config, shared_dsns_file='/shared-myclirc') + + assert aliases.get('local') == 'mysql://local/db' + + +def test_from_config_uses_successfully_parsed_shared_aliases( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + '[alias_dsn]\nshared = mysql://shared/db\n[invalid\n', + encoding='utf-8', + ) + + with caplog.at_level(logging.WARNING, logger='mycli.config'): + aliases = DsnAliases.from_config(DummyConfig(), shared_dsns_file=str(shared_file)) + + assert aliases.get('shared') == 'mysql://shared/db' + assert 'Unable to parse line 3 of config file' in caplog.text + + def test_query_param_defaults_without_runtime_returns_empty_dict() -> None: aliases = DsnAliases(DummyConfig()) @@ -297,6 +400,47 @@ def test_delete_effective_system_alias_does_not_rewrite_user_config(tmp_path: Pa assert merged_config['alias_dsn'] == {} +def test_save_shared_alias_override_writes_only_user_config(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_contents = '[alias_dsn]\nprod = mysql://shared/db\n' + shared_file.write_text(shared_contents, encoding='utf-8') + config_file = tmp_path / 'myclirc' + config_file.write_text('# User config.\n', encoding='utf-8') + aliases = DsnAliases.from_config( + DummyConfig(), + config_file=str(config_file), + shared_dsns_file=str(shared_file), + ) + + result = aliases.save('prod', 'mysql://local/db') + + assert result == 'Saved: prod' + assert shared_file.read_text(encoding='utf-8') == shared_contents + assert config_file.read_text(encoding='utf-8') == '# User config.\n[alias_dsn]\nprod = mysql://local/db\n' + assert aliases.get('prod') == 'mysql://local/db' + + +def test_delete_shared_alias_does_not_write_either_config_file(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_contents = '[alias_dsn]\nprod = mysql://shared/db\n' + shared_file.write_text(shared_contents, encoding='utf-8') + config_file = tmp_path / 'myclirc' + user_contents = '# User config.\n' + config_file.write_text(user_contents, encoding='utf-8') + aliases = DsnAliases.from_config( + DummyConfig(), + config_file=str(config_file), + shared_dsns_file=str(shared_file), + ) + + result = aliases.delete('prod') + + assert result == 'Deleted: prod' + assert aliases.get('prod') is None + assert shared_file.read_text(encoding='utf-8') == shared_contents + assert config_file.read_text(encoding='utf-8') == user_contents + + def test_invalid_alias_does_not_read_user_config(monkeypatch: pytest.MonkeyPatch) -> None: aliases = DsnAliases(DummyConfig(), config_file='~/.myclirc') monkeypatch.setattr( diff --git a/test/pytests/test_main.py b/test/pytests/test_main.py index aa9caccc..f3ad65c1 100644 --- a/test/pytests/test_main.py +++ b/test/pytests/test_main.py @@ -895,6 +895,34 @@ def test_list_dsn(monkeypatch): print(f"An error occurred while attempting to delete the file: {e}") +def test_list_dsn_includes_shared_aliases(monkeypatch, tmp_path): + monkeypatch.setattr(MyCli, 'system_config_files', []) + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + '[alias_dsn]\nshared = mysql://shared/db\noverridden = mysql://shared/override\n', + encoding='utf-8', + ) + myclirc = tmp_path / 'myclirc' + myclirc.write_text( + f"""[main] +shared_dsns_file = {shared_file} + +[alias_dsn] +local = mysql://local/db +overridden = mysql://local/override +""", + encoding='utf-8', + ) + + result = CliRunner().invoke( + click_entrypoint, + args=['--list-dsn', '--verbose', '--myclirc', str(myclirc)], + ) + + assert result.exit_code == 0 + assert result.output == ('shared : mysql://shared/db\noverridden : mysql://local/override\nlocal : mysql://local/db\n') + + @pytest.mark.parametrize( ('shell', 'relative_path'), (