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

Expand Down
44 changes: 41 additions & 3 deletions mycli/packages/special/favoritequeries.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from __future__ import annotations

import logging
import os
import re
from typing import Any

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()

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

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 @@ -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, '')
Expand Down
141 changes: 141 additions & 0 deletions test/pytests/test_favoritequeries.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

import pytest
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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:
Expand Down
Loading