diff --git a/changelog.md b/changelog.md index 9bfbd7dd..2a2242a8 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Upcoming (TBD) Features -------- * Populate history file in Docker with examples. +* Faster dynamic completions via faster `--list-dsn`. Bug Fixes diff --git a/mycli/cli_runner.py b/mycli/cli_runner.py index aa15ac1d..f10139f4 100644 --- a/mycli/cli_runner.py +++ b/mycli/cli_runner.py @@ -14,7 +14,7 @@ 'mycli.main_modes.list_dsn', 'mycli.packages.credentials.password_sources', 'mycli.packages.integrations.vault.vault_credentials', - 'mycli.packages.special_commands.dsn_aliases', + 'mycli.packages.utils.dsn_utils', 'mycli.packages.utils.cli_utils', ] @@ -32,7 +32,7 @@ from mycli.main_modes.checkup import main_checkup from mycli.main_modes.completions import main_completions from mycli.main_modes.execute import main_execute_from_cli -from mycli.main_modes.list_dsn import main_list_dsn +from mycli.main_modes.list_dsn import load_list_dsn_config, main_list_dsn from mycli.packages.credentials.password_sources import PasswordCandidates from mycli.packages.integrations.vault.vault_credentials import ( DEFAULT_VAULT_EXECUTABLE, @@ -41,8 +41,8 @@ VaultError, get_field_from_vault, ) -from mycli.packages.special_commands.dsn_aliases import INVALID_DSN_ALIAS_ERROR, is_valid_dsn_alias from mycli.packages.utils.cli_utils import is_valid_connection_scheme +from mycli.packages.utils.dsn_utils import INVALID_DSN_ALIAS_ERROR, is_valid_dsn_alias if TYPE_CHECKING: from mycli.main import CliArgs @@ -122,7 +122,7 @@ def expand_dsn_alias_env_vars( ) -def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory) -> None: +def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory | None = None) -> None: from mycli import main as main_module if cli_args.completions: @@ -131,6 +131,29 @@ def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory) -> Non cli_verbosity = main_module.preprocess_cli_args(cli_args, is_valid_connection_scheme) + if not cli_args.checkup: + if cli_args.csv and cli_args.format not in [None, 'csv']: + click.secho("Conflicting --csv and --format arguments.", err=True, fg="red") + sys.exit(1) + + if cli_args.table and cli_args.format not in [None, 'table']: + click.secho("Conflicting --table and --format arguments.", err=True, fg="red") + sys.exit(1) + + if not cli_args.format: + cli_args.format = 'default' + if cli_args.csv: + cli_args.format = 'csv' + if cli_args.table: + cli_args.format = 'table' + + if cli_args.list_dsn: + config = load_list_dsn_config(cli_args.myclirc) + verbosity = cli_verbosity or (-1 if config['main'].as_bool('less_chatty') else 0) + sys.exit(main_list_dsn(config, verbosity)) + + if client_factory is None: + client_factory = main_module.MyCli mycli = client_factory( prompt=cli_args.prompt, toolbar_format=cli_args.toolbar, @@ -147,26 +170,6 @@ def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory) -> Non main_checkup(mycli) sys.exit(0) - if cli_args.csv and cli_args.format not in [None, 'csv']: - click.secho("Conflicting --csv and --format arguments.", err=True, fg="red") - sys.exit(1) - - if cli_args.table and cli_args.format not in [None, 'table']: - click.secho("Conflicting --table and --format arguments.", err=True, fg="red") - sys.exit(1) - - if not cli_args.format: - cli_args.format = 'default' - - if cli_args.csv: - cli_args.format = 'csv' - - if cli_args.table: - cli_args.format = 'table' - - if cli_args.list_dsn: - sys.exit(main_list_dsn(mycli)) - dsn_uri = None dsn_password: str | None = None database: str | None = '' diff --git a/mycli/client.py b/mycli/client.py index 66c41f26..5d5e4b8a 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -24,10 +24,13 @@ from mycli.client_connection import ClientConnectionMixin from mycli.client_query import ClientQueryMixin from mycli.config import ( + SYSTEM_CONFIG_FILES, get_mylogin_cnf_path, + get_xdg_config_file, open_mylogin_cnf, read_config_file, read_config_files, + resolve_user_config_path, write_default_config, ) from mycli.constants import DEFAULT_PROMPT @@ -61,10 +64,8 @@ class MyCli(AppStateMixin, OutputMixin, ClientCommandsMixin, ClientConnectionMix # check XDG_CONFIG_HOME exists and not an empty string xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "~/.config") - xdg_config_file = os.path.join(os.path.expanduser(xdg_config_home), "mycli", "myclirc") - system_config_files: list[str | IO[str]] = [ - "/etc/myclirc", - ] + xdg_config_file = get_xdg_config_file() + system_config_files: list[str | IO[str]] = SYSTEM_CONFIG_FILES def __init__( self, @@ -92,11 +93,7 @@ def __init__( self.sandbox_mode: bool = False self.checkpoint: IO | None = None - if myclirc is None: - if os.path.exists(self.xdg_config_file): - myclirc = self.xdg_config_file - else: - myclirc = '~/.myclirc' + myclirc = resolve_user_config_path(myclirc, xdg_config_file=self.xdg_config_file) self.myclirc_path = os.path.abspath(os.path.expanduser(myclirc)) # Load config. diff --git a/mycli/config.py b/mycli/config.py index f3430155..a41c6d5c 100644 --- a/mycli/config.py +++ b/mycli/config.py @@ -1,3 +1,5 @@ +__lazy_modules__ = ['Cryptodome.Cipher'] + from copy import copy from importlib import resources from io import BytesIO, TextIOWrapper @@ -12,6 +14,22 @@ from Cryptodome.Cipher import AES logger = logging.getLogger(__name__) +SYSTEM_CONFIG_FILES: list[str | IO[str]] = ['/etc/myclirc'] + + +def get_xdg_config_file() -> str: + return os.path.join(os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config')), 'mycli', 'myclirc') + + +def resolve_user_config_path(myclirc: str | None, *, xdg_config_file: str | None = None) -> str: + if myclirc is not None: + return myclirc + xdg_config_file = get_xdg_config_file() if xdg_config_file is None else xdg_config_file + return xdg_config_file if os.path.exists(xdg_config_file) else '~/.myclirc' + + +def read_user_config(myclirc: str | None) -> ConfigObj: + return read_config_files(SYSTEM_CONFIG_FILES + [resolve_user_config_path(myclirc)]) class SQLValuePreservingConfigObj(ConfigObj): diff --git a/mycli/main.py b/mycli/main.py index 011c5f85..1bbd54e0 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -23,7 +23,7 @@ import mycli as mycli_package from mycli.cli_runner import run_from_cli_args -from mycli.client import MyCli +from mycli.client import MyCli as MyCli from mycli.constants import ( DEFAULT_PROMPT, EMPTY_PASSWORD_FLAG_SENTINEL, @@ -482,7 +482,7 @@ def click_entrypoint( """ - run_from_cli_args(cli_args, client_factory=MyCli) + run_from_cli_args(cli_args, client_factory=None) def main() -> int | None: diff --git a/mycli/main_modes/list_dsn.py b/mycli/main_modes/list_dsn.py index 54a6c13b..c10e91fa 100644 --- a/mycli/main_modes/list_dsn.py +++ b/mycli/main_modes/list_dsn.py @@ -1,18 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import click +from configobj import ConfigObj + +from mycli.config import read_user_config +from mycli.packages.utils.dsn_utils import is_valid_dsn_alias, merge_shared_dsns -from mycli.packages.special_commands.dsn_aliases import is_valid_dsn_alias -if TYPE_CHECKING: - from mycli.client import MyCli +def load_list_dsn_config(myclirc: str | None) -> ConfigObj: + config = read_user_config(myclirc) + merge_shared_dsns(config, config['main'].get('shared_dsns_file')) + return config -def main_list_dsn(mycli: 'MyCli') -> int: +def main_list_dsn(config: ConfigObj, verbosity: int) -> int: try: - alias_dsn = mycli.config['alias_dsn'] + alias_dsn = config['alias_dsn'] except KeyError: click.secho('Invalid DSNs found in the config file. Please check the "[alias_dsn]" section in myclirc.', err=True, fg='red') return 1 @@ -22,7 +25,7 @@ def main_list_dsn(mycli: 'MyCli') -> int: for alias, value in alias_dsn.items(): if not is_valid_dsn_alias(alias): continue - if mycli.verbosity >= 1: + if verbosity >= 1: click.secho(f'{alias} : {value}') else: click.secho(alias) diff --git a/mycli/packages/special_commands/dsn_aliases.py b/mycli/packages/special_commands/dsn_aliases.py index b6b49c79..15c2c76d 100644 --- a/mycli/packages/special_commands/dsn_aliases.py +++ b/mycli/packages/special_commands/dsn_aliases.py @@ -1,20 +1,17 @@ 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 log, read_config_file, str_to_bool +from mycli.config import read_config_file, str_to_bool from mycli.constants import DEFAULT_CHARSET, DEFAULT_PROMPT, KNOWN_DSN_QUERY_PARAMS - -logger = logging.getLogger(__name__) +from mycli.packages.utils.dsn_utils import INVALID_DSN_ALIAS_ERROR, is_valid_dsn_alias, merge_shared_dsns if TYPE_CHECKING: from mycli.client import MyCli DSN_SUBCOMMANDS = {'help', 'list', 'show', 'save', 'edit', 'delete'} -INVALID_DSN_ALIAS_ERROR = 'Error: DSN aliases cannot start with a dash.' MISSING = object() SSL_QUERY_PARAMS = { @@ -29,10 +26,6 @@ } -def is_valid_dsn_alias(alias: str) -> bool: - return not alias.startswith('-') - - def _config_bool(value: Any) -> bool: try: return str_to_bool(value) @@ -108,35 +101,7 @@ def from_config( 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) + merge_shared_dsns(config, shared_dsns_file) return aliases def _config_for_write(self) -> Any: diff --git a/mycli/packages/utils/dsn_utils.py b/mycli/packages/utils/dsn_utils.py new file mode 100644 index 00000000..bb33fb2d --- /dev/null +++ b/mycli/packages/utils/dsn_utils.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +from mycli.config import log, read_config_file + +logger = logging.getLogger(__name__) +INVALID_DSN_ALIAS_ERROR = 'Error: DSN aliases cannot start with a dash.' + + +def is_valid_dsn_alias(alias: str) -> bool: + return not alias.startswith('-') + + +def merge_shared_dsns(config: Any, shared_dsns_file: str | None) -> None: + if not shared_dsns_file: + return + + 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 + if not os.path.isfile(shared_dsns_file): + log(logger, logging.WARNING, f"Unable to read shared DSNs file '{shared_dsns_file}'.") + return + + shared_config = read_config_file(shared_dsns_file) + if shared_config is None: + return + + configured_aliases = config.get('alias_dsn', {}) + shared_aliases = shared_config.get('alias_dsn', {}) + config['alias_dsn'] = {} + config['alias_dsn'].update(shared_aliases) + config['alias_dsn'].update(configured_aliases) diff --git a/mycli_test/pytests/test_cli_runner.py b/mycli_test/pytests/test_cli_runner.py index c18f4549..5b63f63d 100644 --- a/mycli_test/pytests/test_cli_runner.py +++ b/mycli_test/pytests/test_cli_runner.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from typing import Any +from configobj import ConfigObj import pytest from mycli import cli_runner, main @@ -1418,21 +1419,22 @@ def test_run_from_cli_args_sets_legacy_format_flags( def test_run_from_cli_args_list_dsn_exits_with_mode_result(monkeypatch: pytest.MonkeyPatch) -> None: cli_args = make_cli_args() cli_args.list_dsn = True - client = DummyMyCli() - list_dsn_calls: list[DummyMyCli] = [] + config = ConfigObj({'main': {'less_chatty': False}, 'alias_dsn': {}}) + list_dsn_calls: list[tuple[ConfigObj, int]] = [] monkeypatch.setattr(main, 'preprocess_cli_args', lambda args, scheme_validator: 0) + monkeypatch.setattr(cli_runner, 'load_list_dsn_config', lambda path: config) - def fake_main_list_dsn(value: DummyMyCli) -> int: - list_dsn_calls.append(value) + def fake_main_list_dsn(value: ConfigObj, verbosity: int) -> int: + list_dsn_calls.append((value, verbosity)) return 7 monkeypatch.setattr(cli_runner, 'main_list_dsn', fake_main_list_dsn) with pytest.raises(SystemExit) as excinfo: - cli_runner.run_from_cli_args(cli_args, lambda **_kwargs: client) + cli_runner.run_from_cli_args(cli_args, lambda **_kwargs: pytest.fail('client factory called')) assert excinfo.value.code == 7 - assert list_dsn_calls == [client] + assert list_dsn_calls == [(config, 0)] def test_run_from_cli_args_maps_dsn_socket_parameter(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/mycli_test/pytests/test_dsn_aliases.py b/mycli_test/pytests/test_dsn_aliases.py index 90df5991..d65b8121 100644 --- a/mycli_test/pytests/test_dsn_aliases.py +++ b/mycli_test/pytests/test_dsn_aliases.py @@ -10,6 +10,7 @@ from mycli.constants import KNOWN_DSN_QUERY_PARAMS from mycli.packages.special_commands import dsn_aliases as dsn_aliases_module from mycli.packages.special_commands.dsn_aliases import INVALID_DSN_ALIAS_ERROR, DsnAliases, is_valid_dsn_alias +from mycli.packages.utils import dsn_utils class DummyConfig(dict): @@ -88,7 +89,7 @@ def test_from_config_merges_shared_aliases_with_configured_precedence(tmp_path: 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_commands.dsn_aliases'): + with caplog.at_level(logging.WARNING, logger='mycli.packages.utils.dsn_utils'): aliases = DsnAliases.from_config(config, shared_dsns_file='shared-myclirc') assert aliases.get('local') == 'mysql://local/db' @@ -97,14 +98,14 @@ def test_from_config_rejects_relative_shared_file(caplog: pytest.LogCaptureFixtu 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) + monkeypatch.setattr(dsn_utils.os.path, 'expanduser', lambda path: '/expanded/shared-myclirc') + monkeypatch.setattr(dsn_utils.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) + monkeypatch.setattr(dsn_utils, 'read_config_file', read_config_file) aliases = DsnAliases.from_config(DummyConfig(), shared_dsns_file='~/shared-myclirc') @@ -119,7 +120,7 @@ def test_from_config_warns_and_continues_for_missing_shared_file( config = DummyConfig({'alias_dsn': {'local': 'mysql://local/db'}}) missing_file = tmp_path / 'missing-myclirc' - with caplog.at_level(logging.WARNING, logger='mycli.packages.special_commands.dsn_aliases'): + with caplog.at_level(logging.WARNING, logger='mycli.packages.utils.dsn_utils'): aliases = DsnAliases.from_config(config, shared_dsns_file=str(missing_file)) assert aliases.get('local') == 'mysql://local/db' @@ -128,8 +129,8 @@ def test_from_config_warns_and_continues_for_missing_shared_file( 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) + monkeypatch.setattr(dsn_utils.os.path, 'isfile', lambda path: True) + monkeypatch.setattr(dsn_utils, 'read_config_file', lambda path: None) aliases = DsnAliases.from_config(config, shared_dsns_file='/shared-myclirc') diff --git a/mycli_test/pytests/test_main.py b/mycli_test/pytests/test_main.py index 15b0eff0..1b0c9d53 100644 --- a/mycli_test/pytests/test_main.py +++ b/mycli_test/pytests/test_main.py @@ -28,6 +28,7 @@ from pymysql.err import OperationalError import pytest +from mycli import config as config_module from mycli import main import mycli.cli_runner import mycli.client_connection @@ -803,7 +804,7 @@ def test_click_entrypoint_parses_kubectl_tunnel_options(monkeypatch: pytest.Monk assert calls[0][0].kubectl_resource == 'service/mysql' assert calls[0][0].kubectl_options == '--context prod' assert calls[0][0].port == 3307 - assert calls[0][1] is MyCli + assert calls[0][1] is None def test_command_descriptions_end_with_periods(): @@ -890,7 +891,7 @@ def stub_terminal_size(): def test_list_dsn(monkeypatch): - monkeypatch.setattr(MyCli, "system_config_files", []) + monkeypatch.setattr(config_module, 'SYSTEM_CONFIG_FILES', []) runner = CliRunner() # keep Windows from locking the file with delete=False with NamedTemporaryFile(prefix=TEMPFILE_PREFIX, mode="w", delete=False) as myclirc: @@ -916,7 +917,7 @@ def test_list_dsn(monkeypatch): def test_list_dsn_includes_shared_aliases(monkeypatch, tmp_path): - monkeypatch.setattr(MyCli, 'system_config_files', []) + monkeypatch.setattr(config_module, 'SYSTEM_CONFIG_FILES', []) shared_file = tmp_path / 'shared-myclirc' shared_file.write_text( '[alias_dsn]\nshared = mysql://shared/db\noverridden = mysql://shared/override\n', diff --git a/mycli_test/pytests/test_main_modes_list_dsn.py b/mycli_test/pytests/test_main_modes_list_dsn.py index 6083af46..1cb8e9db 100644 --- a/mycli_test/pytests/test_main_modes_list_dsn.py +++ b/mycli_test/pytests/test_main_modes_list_dsn.py @@ -1,14 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass +import logging +import os +from pathlib import Path +import subprocess +import sys from typing import Any, cast -import mycli.main_modes.list_dsn as list_dsn_mode +from click.testing import CliRunner +import pytest - -@dataclass -class DummyCliArgs: - verbose: int = 0 +from mycli import cli_runner, main +from mycli import config as config_module +from mycli.main_modes import list_dsn as list_dsn_mode +from mycli.packages.utils import dsn_utils class DummyConfig: @@ -22,25 +27,17 @@ def __getitem__(self, key: str) -> dict[str, str]: return self.value -class DummyMyCli: - def __init__(self, config: Any) -> None: - self.config = config - self.verbosity = 0 - - -def main_list_dsn(mycli: DummyMyCli) -> int: - return list_dsn_mode.main_list_dsn(cast(Any, mycli)) +def main_list_dsn(config: DummyConfig, verbosity: int = 0) -> int: + return list_dsn_mode.main_list_dsn(cast(Any, config), verbosity) def test_main_list_dsn_lists_aliases_without_values(monkeypatch) -> None: secho_calls: list[tuple[str, bool | None, str | None]] = [] - mycli = DummyMyCli( - DummyConfig({ - 'prod': 'mysql://u:p@h/db', - '-hidden': 'mysql://hidden/db', - 'staging': 'mysql://u2:p2@h2/db2', - }) - ) + config = DummyConfig({ + 'prod': 'mysql://u:p@h/db', + '-hidden': 'mysql://hidden/db', + 'staging': 'mysql://u2:p2@h2/db2', + }) monkeypatch.setattr( list_dsn_mode.click, @@ -48,7 +45,7 @@ def test_main_list_dsn_lists_aliases_without_values(monkeypatch) -> None: lambda message, err=None, fg=None: secho_calls.append((message, err, fg)), ) - result = main_list_dsn(mycli) + result = main_list_dsn(config) assert result == 0 assert secho_calls == [ @@ -59,8 +56,7 @@ def test_main_list_dsn_lists_aliases_without_values(monkeypatch) -> None: def test_main_list_dsn_lists_aliases_with_values_in_verbose_mode(monkeypatch) -> None: secho_calls: list[tuple[str, bool | None, str | None]] = [] - mycli = DummyMyCli(DummyConfig({'prod': 'mysql://u:p@h/db', '-hidden': 'mysql://hidden/db'})) - mycli.verbosity = 1 + config = DummyConfig({'prod': 'mysql://u:p@h/db', '-hidden': 'mysql://hidden/db'}) monkeypatch.setattr( list_dsn_mode.click, @@ -68,7 +64,7 @@ def test_main_list_dsn_lists_aliases_with_values_in_verbose_mode(monkeypatch) -> lambda message, err=None, fg=None: secho_calls.append((message, err, fg)), ) - result = main_list_dsn(mycli) + result = main_list_dsn(config, 1) assert result == 0 assert secho_calls == [('prod : mysql://u:p@h/db', None, None)] @@ -76,7 +72,7 @@ def test_main_list_dsn_lists_aliases_with_values_in_verbose_mode(monkeypatch) -> def test_main_list_dsn_reports_invalid_alias_section(monkeypatch) -> None: secho_calls: list[tuple[str, bool | None, str | None]] = [] - mycli = DummyMyCli(DummyConfig(KeyError('alias_dsn'))) + config = DummyConfig(KeyError('alias_dsn')) monkeypatch.setattr( list_dsn_mode.click, @@ -84,7 +80,7 @@ def test_main_list_dsn_reports_invalid_alias_section(monkeypatch) -> None: lambda message, err=None, fg=None: secho_calls.append((message, err, fg)), ) - result = main_list_dsn(mycli) + result = main_list_dsn(config) assert result == 1 assert secho_calls == [ @@ -98,7 +94,7 @@ def test_main_list_dsn_reports_invalid_alias_section(monkeypatch) -> None: def test_main_list_dsn_reports_other_config_errors(monkeypatch) -> None: secho_calls: list[tuple[str, bool | None, str | None]] = [] - mycli = DummyMyCli(DummyConfig(RuntimeError('boom'))) + config = DummyConfig(RuntimeError('boom')) monkeypatch.setattr( list_dsn_mode.click, @@ -106,7 +102,189 @@ def test_main_list_dsn_reports_other_config_errors(monkeypatch) -> None: lambda message, err=None, fg=None: secho_calls.append((message, err, fg)), ) - result = main_list_dsn(mycli) + result = main_list_dsn(config) assert result == 1 assert secho_calls == [('boom', True, 'red')] + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(config_module, 'SYSTEM_CONFIG_FILES', []) + # Reproduce startup logging before another test configures the client. + monkeypatch.setattr(config_module.logger, 'parent', logging.getLogger()) + monkeypatch.setattr(dsn_utils.logger, 'parent', logging.getLogger()) + monkeypatch.setenv('HOME', str(tmp_path)) + monkeypatch.setenv('USERPROFILE', str(tmp_path)) + monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'xdg')) + + +@pytest.mark.parametrize('location', ['explicit', 'xdg', 'home']) +def test_list_dsn_selects_user_config(location: str, tmp_path: Path) -> None: + home = tmp_path / '.myclirc' + home.write_text('[alias_dsn]\nhome = mysql://home/db\n') + args = ['--list-dsn'] + if location in ('explicit', 'xdg'): + xdg = tmp_path / 'xdg' / 'mycli' / 'myclirc' + xdg.parent.mkdir(parents=True) + xdg.write_text('[alias_dsn]\nxdg = mysql://xdg/db\n') + if location == 'explicit': + explicit = tmp_path / 'explicit' + explicit.write_text('[alias_dsn]\nexplicit = mysql://explicit/db\n') + args += ['--myclirc', str(explicit)] + + result = CliRunner().invoke(main.click_entrypoint, args) + + assert result.exit_code == 0 + assert result.output == f'{location}\n' + + +def test_list_dsn_merges_system_user_and_shared_aliases(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + shared = tmp_path / 'shared' + shared.write_text('[alias_dsn]\nshared = mysql://shared/db\noverridden = mysql://shared/db\n') + system = tmp_path / 'system' + system.write_text(f'[main]\nshared_dsns_file = {shared}\n[alias_dsn]\nsystem = mysql://system/db\noverridden = mysql://system/db\n') + user = tmp_path / '.myclirc' + user.write_text('[alias_dsn]\nuser = mysql://user/db\noverridden = mysql://user/db\n') + monkeypatch.setattr(config_module, 'SYSTEM_CONFIG_FILES', [str(system)]) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn', '--verbose']) + + assert result.exit_code == 0 + assert result.output == 'shared : mysql://shared/db\noverridden : mysql://user/db\nsystem : mysql://system/db\nuser : mysql://user/db\n' + + +@pytest.mark.parametrize('explicit', [False, True]) +def test_list_dsn_does_not_create_missing_config(tmp_path: Path, explicit: bool) -> None: + path = tmp_path / '.myclirc' + args = ['--list-dsn'] + (['--myclirc', str(path)] if explicit else []) + + result = CliRunner().invoke(main.click_entrypoint, args) + + assert result.exit_code == 0 + assert result.output == '' + assert not path.exists() + + +@pytest.mark.parametrize('existing', [False, True]) +def test_list_dsn_does_not_touch_configured_logs(tmp_path: Path, existing: bool) -> None: + log = tmp_path / 'debug.log' + audit = tmp_path / 'audit.log' + if existing: + log.write_text('unchanged') + audit.write_text('unchanged') + config = tmp_path / '.myclirc' + content = f'[main]\nlog_file = {log}\naudit_log = {audit}\n[alias_dsn]\nprod = mysql://host/db\n' + config.write_text(content) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn']) + + assert result.exit_code == 0 + assert config.read_text() == content + for path in (log, audit): + assert path.exists() == existing + if existing: + assert path.read_text() == 'unchanged' + + +def test_list_dsn_skips_unrelated_client_settings(tmp_path: Path) -> None: + (tmp_path / '.myclirc').write_text( + '[main]\ntable_format = not-a-format\nbeep_after_seconds = invalid\n[alias_dsn]\nprod = mysql://host/db\n' + ) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn']) + + assert result.exit_code == 0 + assert result.output == 'prod\n' + + +@pytest.mark.parametrize( + ('flags', 'expected'), [([], 'prod\n'), (['--quiet'], 'prod\n'), (['--verbose'], 'prod : mysql://${USER}@host/db\n')] +) +def test_list_dsn_preserves_verbosity_and_raw_values(tmp_path: Path, flags: list[str], expected: str) -> None: + (tmp_path / '.myclirc').write_text( + '[main]\nless_chatty = true\n[alias_dsn]\nprod = mysql://${USER}@host/db\n-hidden = mysql://hidden/db\n' + ) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn', *flags]) + + assert result.exit_code == 0 + assert result.output == expected + + +def test_list_dsn_reports_relative_shared_path(tmp_path: Path) -> None: + (tmp_path / '.myclirc').write_text('[main]\nshared_dsns_file = relative\n[alias_dsn]\nprod = mysql://host/db\n') + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn']) + + assert result.exit_code == 0 + assert "Shared DSNs file path must be absolute: 'relative'." in result.stderr + assert result.stdout == 'prod\n' + + +def test_list_dsn_keeps_valid_entries_in_malformed_config(tmp_path: Path) -> None: + (tmp_path / '.myclirc').write_text('[alias_dsn]\nprod = mysql://host/db\n[invalid\n') + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn']) + + assert result.exit_code == 0 + assert 'Unable to parse line 3' in result.stderr + assert result.stdout == 'prod\n' + + +@pytest.mark.parametrize('flags', [['--verbose', '--quiet'], ['--csv', '--format', 'table'], ['--table', '--format', 'csv'], ['--resume']]) +def test_list_dsn_preserves_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli_runner, 'load_list_dsn_config', lambda path: pytest.fail('config loaded')) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn', *flags]) + + assert result.exit_code == 1 + assert 'Error:' in result.output or 'Conflicting' in result.output + + +def test_completions_take_precedence_over_list_dsn(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli_runner, 'load_list_dsn_config', lambda path: pytest.fail('config loaded')) + monkeypatch.setattr(cli_runner, 'main_completions', lambda shell: None) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn', '--completions', 'bash']) + + assert result.exit_code == 0 + + +def test_checkup_takes_precedence_over_list_dsn(monkeypatch: pytest.MonkeyPatch) -> None: + client = object() + monkeypatch.setattr(main, 'MyCli', lambda **kwargs: client) + monkeypatch.setattr(cli_runner, 'load_list_dsn_config', lambda path: pytest.fail('config loaded')) + calls: list[object] = [] + monkeypatch.setattr(cli_runner, 'main_checkup', calls.append) + + result = CliRunner().invoke(main.click_entrypoint, ['--list-dsn', '--checkup', '--csv', '--format', 'table']) + + assert result.exit_code == 0 + assert calls == [client] + + +@pytest.mark.skipif(sys.version_info < (3, 15), reason='Native lazy imports require Python 3.15.') +def test_list_dsn_does_not_import_runtime_dependencies(tmp_path: Path) -> None: + config = tmp_path / 'myclirc' + config.write_text('[alias_dsn]\nprod = mysql://host/db\n') + script = ''' +import sys +from mycli import config +config.SYSTEM_CONFIG_FILES = [] +from mycli.main import click_entrypoint +try: + click_entrypoint.main(['--list-dsn', '--myclirc', sys.argv[1]], standalone_mode=False) +except SystemExit as exc: + assert exc.code == 0 +prefixes = ('mycli.client', 'mycli.main_modes.repl', 'mycli.packages.special_commands', + 'sqlglot', 'prompt_toolkit', 'llm', 'pymysql', 'keyring', 'Cryptodome.Cipher.AES') +loaded = sorted(name for name in sys.modules if any(name == prefix or name.startswith(prefix + '.') for prefix in prefixes)) +assert not loaded, loaded +''' + env = dict(os.environ) + env.pop('PYTHONPATH', None) + result = subprocess.run([sys.executable, '-c', script, str(config)], env=env, text=True, capture_output=True, timeout=30) + + assert result.returncode == 0, result.stderr + assert result.stdout == 'prod\n'