Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Upcoming (TBD)
==============

Features
---------
* Allow reading DSN aliases from a shared configuration file.


2.11.0 (2026/08/07)
==============

Expand Down
7 changes: 6 additions & 1 deletion mycli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
6 changes: 6 additions & 0 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 42 additions & 3 deletions mycli/packages/special/dsn_aliases.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions test/pytests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down
144 changes: 144 additions & 0 deletions test/pytests/test_dsn_aliases.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Mapping
import logging
from pathlib import Path
from types import SimpleNamespace
from typing import Any
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions test/pytests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
(
Expand Down
Loading