From 3d061e2a70ad558bc5ebf1a198aaba3ce4448f14 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Wed, 7 Oct 2026 12:48:12 -0400 Subject: [PATCH] allow comma-containing init-commands in ~/.myclirc Generalize FavoriteQueryPreservingConfigObj's _parse() to cover all config file stanzas with SQL values: * favorite_queries * init-commands * alias_dsn.init-commands And use a new class name SQLValuePreservingConfigObj. Incidentally update the known_bugs.md with an explanation of the limitation on saving favorite_queries with internal triplequotes. Though the same issue exists on read of unquoted init-commands, it is harder to explain, and left out of the known_bugs.md entry. --- changelog.md | 1 + doc/known_bugs.md | 5 ++ mycli/config.py | 12 +++-- mycli_test/pytests/test_config.py | 76 ++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/changelog.md b/changelog.md index 723508f5..14c92c38 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Bug Fixes -------- * Let the `beep_after_seconds` slow query alert happen before paged output. * Allow comma-containing favorite queries in `~/.myclirc`. +* Allow comma-containing init-commands in `~/.myclirc`. Internal diff --git a/doc/known_bugs.md b/doc/known_bugs.md index 2c09a3a4..eeeaf4f3 100644 --- a/doc/known_bugs.md +++ b/doc/known_bugs.md @@ -59,3 +59,8 @@ The prompt and toolbar are updated more often than needed, even with caching. When syntax highlighting with `pygmentize` is enabled for history search previews, the colors do not match the user's preferences in `~/.myclirc`. + +## favorite queries + +Favorite queries are triple-quoted on save in `~/.myclirc`. If the text of the SQL +in the favorite query contains both possible triple-quote delimiters, this fails. diff --git a/mycli/config.py b/mycli/config.py index 21600aaa..713b3262 100644 --- a/mycli/config.py +++ b/mycli/config.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class FavoriteQueryPreservingConfigObj(ConfigObj): +class SQLValuePreservingConfigObj(ConfigObj): """When reading, quote SQL text on the fly which ConfigObj would otherwise interpret as a list.""" # Buglet: the use of _get_triple_quote() does not allow values which @@ -25,7 +25,7 @@ def _parse(self, infile: list[str]) -> None: return lines = infile.copy() - in_favorites = False + in_sql_stanza = False index = 0 while index < len(lines): line = lines[index] @@ -35,7 +35,9 @@ def _parse(self, infile: list[str]) -> None: section = self._sectionmarker.match(line) if section is not None: _, opening, name, closing, _ = section.groups() - in_favorites = opening.count('[') == closing.count(']') == 1 and self._unquote(name) == 'favorite_queries' + in_sql_stanza = opening.count('[') == closing.count(']') == 1 and ( + self._unquote(name) == 'favorite_queries' or 'init-commands' in name + ) else: entry = self._keyword.match(line) if entry is not None: @@ -46,7 +48,7 @@ def _parse(self, infile: list[str]) -> None: _, _, index = self._multiline(value, lines, index, len(lines) - 1) except SyntaxError: break - elif in_favorites and not value.startswith(('"', "'")): + elif in_sql_stanza and not value.startswith(('"', "'")): match = self._nolistvalue.match(value) if match is not None: sql, comment = match.groups() @@ -130,7 +132,7 @@ def read_config_file( if preserve_quotes: config = LimiitedQuotePreservingConfigObj(f, interpolation=False, encoding="utf8", list_values=False) else: - config = FavoriteQueryPreservingConfigObj(f, interpolation=False, encoding="utf8", list_values=list_values) + config = SQLValuePreservingConfigObj(f, interpolation=False, encoding="utf8", list_values=list_values) except ConfigObjError as e: if raise_errors: raise diff --git a/mycli_test/pytests/test_config.py b/mycli_test/pytests/test_config.py index 142d87f3..19d47a2a 100644 --- a/mycli_test/pytests/test_config.py +++ b/mycli_test/pytests/test_config.py @@ -173,6 +173,78 @@ def test_read_config_file_list_values_off(): assert config["main"]["weather"] == "'cloudy with a chance of meatballs'" +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +@pytest.mark.parametrize( + 'query', + [ + 'SET @first = 1, @second = 2', + "SET @message = CONCAT('hello', ', world')", + '''SET @first = 'a,b', @second = "c,d"''', + ], +) +def test_init_commands_preserve_unquoted_sql(section: str, query: str) -> None: + config = read_config_file(StringIO(f'[{section}]\nstartup = {query}\n'), raise_errors=True) + + assert config is not None + assert config[section]['startup'] == query + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +@pytest.mark.parametrize('quote', ['"', "'", '"""', "'''"]) +def test_init_commands_preserve_config_quote_semantics(section: str, quote: str) -> None: + query = 'SET @first = 1, @second = 2' + config = read_config_file(StringIO(f'[{section}]\nstartup = {quote}{query}{quote}\n'), raise_errors=True) + + assert config is not None + assert config[section]['startup'] == query + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +def test_init_commands_preserve_multiline_sql(section: str) -> None: + query = "SET @first = 1,\n @second = 'a,b'" + config = read_config_file(StringIO(f'[{section}]\nstartup = """{query}"""\n'), raise_errors=True) + + assert config is not None + assert config[section]['startup'] == query + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +def test_init_commands_strip_trailing_config_comment(section: str) -> None: + query = 'SET @first = 1, @second = 2' + config = read_config_file(StringIO(f'[{section}]\nstartup = {query} # Startup settings.\n'), raise_errors=True) + + assert config is not None + assert config[section]['startup'] == query + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +def test_init_commands_accept_spaced_quoted_section_name(section: str) -> None: + query = 'SET @first = 1, @second = 2' + config = read_config_file(StringIO(f'[ "{section}" ]\nstartup = {query}\n'), raise_errors=True) + + assert config is not None + assert config[section]['startup'] == query + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +def test_init_commands_do_not_change_following_section_lists(section: str) -> None: + config = read_config_file( + StringIO(f'[{section}]\nstartup = SET @first = 1, @second = 2\n[main]\nitems = a, b\n'), + raise_errors=True, + ) + + assert config is not None + assert config['main']['items'] == ['a', 'b'] + + +@pytest.mark.parametrize('section', ['init-commands', 'alias_dsn.init-commands']) +def test_nested_init_command_section_keeps_list_parsing(section: str) -> None: + config = read_config_file(StringIO(f'[main]\n[[{section}]]\nitems = a, b\n'), raise_errors=True) + + assert config is not None + assert config['main'][section]['items'] == ['a', 'b'] + + def test_quote_preserving_config_retains_quotes_and_quotes_multiline_values() -> None: config = read_config_file(StringIO('[main]\nquoted = "value"\n'), preserve_quotes=True) @@ -294,7 +366,7 @@ def test_read_config_file_permission_error(monkeypatch, caplog) -> None: def raise_oserror(*_args, **_kwargs): raise OSError(13, 'denied', '/tmp/test.cnf') - monkeypatch.setattr(config_module, 'FavoriteQueryPreservingConfigObj', raise_oserror) + monkeypatch.setattr(config_module, 'SQLValuePreservingConfigObj', raise_oserror) with caplog.at_level(logging.WARNING, logger='mycli.config'): assert read_config_file('/tmp/test.cnf') is None @@ -338,7 +410,7 @@ def test_read_config_file_can_raise_io_errors(monkeypatch) -> None: def raise_oserror(*_args, **_kwargs): raise error - monkeypatch.setattr(config_module, 'FavoriteQueryPreservingConfigObj', raise_oserror) + monkeypatch.setattr(config_module, 'SQLValuePreservingConfigObj', raise_oserror) with pytest.raises(OSError) as exc_info: read_config_file('/tmp/test.cnf', raise_errors=True)