diff --git a/changelog.md b/changelog.md index 0e656c1c..b268a42d 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ Features * Always clean favorite queries on save and fetch. * Give a clearer message on a Vault connection if the user is not logged in. * Promote Vault integration out of beta status. +* Allow reading favorite queries from a shared configuration file. Bugfixes diff --git a/mycli/client.py b/mycli/client.py index 287646d9..732d520d 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -115,7 +115,11 @@ def __init__( self.beep_after_seconds = float(c["main"]["beep_after_seconds"] or 0) self.default_keepalive_ticks = c['connection'].as_int('default_keepalive_ticks') - FavoriteQueries.instance = FavoriteQueries.from_config(self.config, myclirc) + FavoriteQueries.instance = FavoriteQueries.from_config( + self.config, + myclirc, + c['main'].get('shared_favorites_file'), + ) DsnAliases.instance = DsnAliases.from_config(self.config, self, config_file=myclirc) self.dsn_alias: str | None = None diff --git a/mycli/myclirc b/mycli/myclirc index 3f9fbf78..8c54913e 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -71,6 +71,12 @@ timing = True # Show the full SQL when running a favorite query. Set to False to hide. show_favorite_query = True +# Load additional favorite queries from the [favorite_queries] section of this +# file. Favorites 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-favorites.ini +shared_favorites_file = + # Beep after long-running queries are completed; 0 to disable. beep_after_seconds = 0 diff --git a/mycli/packages/special/favoritequeries.py b/mycli/packages/special/favoritequeries.py index 7e61be45..e88909cc 100644 --- a/mycli/packages/special/favoritequeries.py +++ b/mycli/packages/special/favoritequeries.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import re from typing import Any @@ -7,7 +8,9 @@ from jinja2 import meta, nodes from jinja2.sandbox import SandboxedEnvironment -from mycli.config import read_config_file +from mycli.config import log, read_config_file + +logger = logging.getLogger(__name__) MISSING = object() @@ -123,8 +126,43 @@ def __init__(self, config: Any, config_file: str | None = None) -> None: self.config_file = config_file @classmethod - def from_config(cls, config: Any, config_file: str | None = None) -> FavoriteQueries: - return FavoriteQueries(config, config_file) + def from_config( + cls, + config: Any, + config_file: str | None = None, + shared_favorites_file: str | None = None, + ) -> FavoriteQueries: + favorites = cls(config, config_file) + if not shared_favorites_file: + return favorites + + shared_favorites_file = os.path.expanduser(shared_favorites_file) + if not os.path.isabs(shared_favorites_file): + log( + logger, + logging.WARNING, + f"Shared favorites file path must be absolute: '{shared_favorites_file}'.", + ) + return favorites + + if not os.path.isfile(shared_favorites_file): + log( + logger, + logging.WARNING, + f"Unable to read shared favorites file '{shared_favorites_file}'.", + ) + return favorites + + shared_config = read_config_file(shared_favorites_file) + if shared_config is None: + return favorites + + configured_queries = config.get(cls.section_name, {}) + shared_queries = shared_config.get(cls.section_name, {}) + config[cls.section_name] = {} + config[cls.section_name].update(shared_queries) + config[cls.section_name].update(configured_queries) + return favorites def _clean_query(self, query: str | None) -> str | None: if not query: diff --git a/test/myclirc b/test/myclirc index 7be58325..d66196ca 100644 --- a/test/myclirc +++ b/test/myclirc @@ -71,6 +71,12 @@ timing = True # Show the full SQL when running a favorite query. Set to False to hide. show_favorite_query = True +# Load additional favorite queries from the [favorite_queries] section of this +# file. Favorites 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-favorites.ini +shared_favorites_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 9c3d1b3d..133e25d7 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -186,6 +186,35 @@ def test_init_configures_favorite_queries_with_user_config_path(monkeypatch: pyt assert FavoriteQueries.instance.config_file == myclirc +def test_init_loads_shared_favorite_queries(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + """[favorite_queries] +shared = select 1 +overridden = select 'shared' +""", + encoding='utf-8', + ) + myclirc = write_myclirc( + tmp_path, + f"""[main] +shared_favorites_file = {shared_file} + +[favorite_queries] +local = select 2 +overridden = select 'local' +""", + ) + + cli = MyCli(myclirc=myclirc) + + assert FavoriteQueries.instance.config is cli.config + assert FavoriteQueries.instance.get('shared') == 'select 1' + assert FavoriteQueries.instance.get('local') == 'select 2' + assert FavoriteQueries.instance.get('overridden') == "select 'local'" + + def test_init_configures_dsn_aliases_with_user_config_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: patch_constructor_side_effects(monkeypatch) myclirc = write_myclirc(tmp_path, '') diff --git a/test/pytests/test_favoritequeries.py b/test/pytests/test_favoritequeries.py index 64174bd1..985fae09 100644 --- a/test/pytests/test_favoritequeries.py +++ b/test/pytests/test_favoritequeries.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +import logging from pathlib import Path import pytest @@ -32,6 +33,106 @@ def test_from_config_returns_instance_with_same_config() -> None: assert favorites.config_file == '/tmp/myclirc' +def test_from_config_merges_shared_queries_with_configured_precedence(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + """[main] +prompt = ignored + +[favorite_queries] +shared = select 1 +overridden = select 'shared' +""", + encoding='utf-8', + ) + config = DummyConfig({ + 'favorite_queries': { + 'local': 'select 2', + 'overridden': "select 'local'", + }, + }) + + favorites = FavoriteQueries.from_config(config, shared_favorites_file=str(shared_file)) + + assert favorites.list() == ['shared', 'overridden', 'local'] + assert favorites.get('shared') == 'select 1' + assert favorites.get('overridden') == "select 'local'" + assert 'main' not in config + + +def test_from_config_rejects_relative_shared_file( + caplog: pytest.LogCaptureFixture, +) -> None: + config = DummyConfig({'favorite_queries': {'local': 'select 1'}}) + + with caplog.at_level(logging.WARNING, logger='mycli.packages.special.favoritequeries'): + favorites = FavoriteQueries.from_config(config, shared_favorites_file='shared-myclirc') + + assert favorites.get('local') == 'select 1' + assert favorites.get('shared') is None + assert "Shared favorites 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(favoritequeries_module.os.path, 'expanduser', lambda path: '/expanded/shared-myclirc') + monkeypatch.setattr(favoritequeries_module.os.path, 'isfile', lambda path: True) + + def read_config_file(path: str) -> DummyConfig: + read_paths.append(path) + return DummyConfig({'favorite_queries': {'shared': 'select 1'}}) + + monkeypatch.setattr(favoritequeries_module, 'read_config_file', read_config_file) + + favorites = FavoriteQueries.from_config(DummyConfig(), shared_favorites_file='~/shared-myclirc') + + assert read_paths == ['/expanded/shared-myclirc'] + assert favorites.get('shared') == 'select 1' + + +def test_from_config_warns_and_continues_for_missing_shared_file( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + config = DummyConfig({'favorite_queries': {'local': 'select 1'}}) + missing_file = tmp_path / 'missing-myclirc' + + with caplog.at_level(logging.WARNING, logger='mycli.packages.special.favoritequeries'): + favorites = FavoriteQueries.from_config(config, shared_favorites_file=str(missing_file)) + + assert favorites.get('local') == 'select 1' + assert f"Unable to read shared favorites file '{missing_file}'." in caplog.text + + +def test_from_config_continues_when_shared_file_cannot_be_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = DummyConfig({'favorite_queries': {'local': 'select 1'}}) + monkeypatch.setattr(favoritequeries_module.os.path, 'isfile', lambda path: True) + monkeypatch.setattr(favoritequeries_module, 'read_config_file', lambda path: None) + + favorites = FavoriteQueries.from_config(config, shared_favorites_file='/shared-myclirc') + + assert favorites.get('local') == 'select 1' + + +def test_from_config_uses_successfully_parsed_shared_queries( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_file.write_text( + '[favorite_queries]\nshared = select 1\n[invalid\n', + encoding='utf-8', + ) + + with caplog.at_level(logging.WARNING, logger='mycli.config'): + favorites = FavoriteQueries.from_config(DummyConfig(), shared_favorites_file=str(shared_file)) + + assert favorites.get('shared') == 'select 1' + assert 'Unable to parse line 3 of config file' in caplog.text + + def test_list_and_get_use_favorite_queries_section() -> None: config = DummyConfig({ 'favorite_queries': { @@ -217,6 +318,46 @@ def test_delete_effective_system_favorite_does_not_rewrite_user_config(tmp_path: assert merged_config['favorite_queries'] == {} +def test_save_shared_favorite_override_writes_only_user_config(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_contents = '[favorite_queries]\nreport = select 1\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') + favorites = FavoriteQueries.from_config( + DummyConfig(), + str(config_file), + str(shared_file), + ) + + favorites.save('report', 'select 2') + + assert shared_file.read_text(encoding='utf-8') == shared_contents + assert config_file.read_text(encoding='utf-8') == '# User config.\n[favorite_queries]\nreport = select 2\n' + assert favorites.get('report') == 'select 2' + + +def test_delete_shared_favorite_does_not_write_either_config_file(tmp_path: Path) -> None: + shared_file = tmp_path / 'shared-myclirc' + shared_contents = '[favorite_queries]\nreport = select 1\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') + favorites = FavoriteQueries.from_config( + DummyConfig(), + str(config_file), + str(shared_file), + ) + + result = favorites.delete('report') + + assert result == 'report: Deleted.' + assert favorites.get('report') is None + assert shared_file.read_text(encoding='utf-8') == shared_contents + assert config_file.read_text(encoding='utf-8') == user_contents + + def test_save_does_not_update_runtime_config_when_user_config_cannot_be_read( monkeypatch: pytest.MonkeyPatch, ) -> None: