From 75404edb6379a760a86f8b9e83ae1ad39311ffa0 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Fri, 7 Aug 2026 08:20:10 -0400 Subject: [PATCH] read favorite queries from an optional shared file Add a main.shared_favorites_file configuration option. If set, favorite queries are loaded from that file, but never written to that file, so it currently must be composed in an editor. Favorite queries in the user's ~/.myclirc take precedence over those in the shared file. --- changelog.md | 1 + mycli/client.py | 6 +- mycli/myclirc | 6 + mycli/packages/special/favoritequeries.py | 44 ++++++- test/myclirc | 6 + test/pytests/test_client.py | 29 +++++ test/pytests/test_favoritequeries.py | 141 ++++++++++++++++++++++ 7 files changed, 229 insertions(+), 4 deletions(-) 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: