From 73aebe742d365f134a7393b086b80c78ed6e7316 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 17 Oct 2026 09:27:24 -0400 Subject: [PATCH] speed up --list-dsn, primarily on Python 3.15 * move utilities from dsn_aliases.py to a new dsn_utils.py (reading and merging multiple files) * support a None ClientFactory in cli_runner.py, deferring populating it until after main_list_dsn() may have been run * create common config utilities for finding the config files * make AES a Python 3.15 lazy-load in config.py, since it is not needed in most cases: _eg_ reading DSNs from ~/.myclirc * read the config independently in list_dsn.py, using the new common config utilities, sidestepping full instantiations/imports * pass a verbosity int to main_list_dsn() instead of a MyCli object This brings down the --list-dsn operation from ~0.4 seconds to ~0.1 seconds (measured using Python 3.15 on a Mac Studio M1). On Python 3.14 on the same machine, the result is very modestly better, from consistently longer than 0.4 seconds, to consistently shorter than 0.4 seconds. Motivation: "mycli --list-dsn" is invoked during dynamic shell completions. This change makes shell completions considerably more responsive and pleasant to use. Homebrew users are already on Python 3.15, and will gain the full improvement immediately. For other packagers, considerably more time will be needed. Future work or discussion: function-level imports could be used to defer loading many dependencies in Python interpreters prior to 3.15, though it would make the code considerably less tidy. Maybe this could be done selectively, for example just with the AES import. Notes: --help and --version were already very fast under Python 3.15. --checkup has not been optimized, but there is little need, since it will always take some time to contact PyPi during the checkup run. --- changelog.md | 1 + mycli/cli_runner.py | 51 ++-- mycli/client.py | 15 +- mycli/config.py | 18 ++ mycli/main.py | 4 +- mycli/main_modes/list_dsn.py | 19 +- .../packages/special_commands/dsn_aliases.py | 41 +-- mycli/packages/utils/dsn_utils.py | 37 +++ mycli_test/pytests/test_cli_runner.py | 14 +- mycli_test/pytests/test_dsn_aliases.py | 15 +- mycli_test/pytests/test_main.py | 7 +- .../pytests/test_main_modes_list_dsn.py | 236 +++++++++++++++--- 12 files changed, 332 insertions(+), 126 deletions(-) create mode 100644 mycli/packages/utils/dsn_utils.py diff --git a/changelog.md b/changelog.md index 9bfbd7ddd..2a2242a8e 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 aa15ac1dc..f10139f4d 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 66c41f26d..5d5e4b8aa 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 f34301550..a41c6d5c5 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 011c5f85f..1bbd54e04 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 54a6c13b7..c10e91fae 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 b6b49c79d..15c2c76d6 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 000000000..bb33fb2da --- /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 c18f4549e..5b63f63d0 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 90df5991f..d65b8121f 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 15b0eff0a..1b0c9d535 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 6083af467..1cb8e9db7 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'