From 8e6879789b576bd3bc8613779f53e78bb224efa4 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 20:41:51 +0000 Subject: [PATCH 1/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- changelog.rst | 7 ++ pgcli/config.py | 172 ++++++++++++++++++++++++++++++++++++++++--- tests/test_config.py | 53 ++++++++++++- 3 files changed, 222 insertions(+), 10 deletions(-) diff --git a/changelog.rst b/changelog.rst index ee5d1fcf5..e3090b364 100644 --- a/changelog.rst +++ b/changelog.rst @@ -3,6 +3,13 @@ Upcoming Features: --------- +* Replace ConfigObj with the standard-library ``configparser`` for the main + pgclirc parser while retaining case-sensitive names, typed settings, quoted + values, lists, literal percent/hash characters, user comments, and + default/user precedence. ConfigObj's nested ``[[section]]`` syntax is not + supported; pgclirc continues to use single-bracket section names (including + dotted names such as ``[alias_dsn.init-commands]``). ConfigObj remains a + dependency for PostgreSQL service-file parsing. * Add support for `single-command` to run a SQL command and exit. * Command line option `-c` or `--command`. * You can specify multiple times. diff --git a/pgcli/config.py b/pgcli/config.py index 2b44a7bb7..fe0cdec97 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -1,10 +1,166 @@ import shutil import os import platform +import configparser +import shlex from os.path import expanduser, exists, dirname import re from typing import TextIO -from configobj import ConfigObj + + +class ConfigSection(dict): + """A case-sensitive config section with ConfigObj's typed accessors.""" + + def as_bool(self, key): + value = self[key].lower() + if value in ("1", "yes", "true", "on"): + return True + if value in ("0", "no", "false", "off"): + return False + raise ValueError(f"Not a boolean: {self[key]}") + + def as_int(self, key): + return int(self[key]) + + def as_list(self, key): + value = self[key] + if not value: + return [] + lexer = shlex.shlex(value, posix=True) + lexer.whitespace = "," + lexer.whitespace_split = True + lexer.commenters = "" + return [item.strip() for item in lexer] + + +class PgcliConfig(dict): + """The subset of ConfigObj's interface used by pgcli and pgspecial.""" + + def __init__(self, filename, sections=None): + super().__init__(sections or {}) + self.filename = filename + self._original = {name: dict(section) for name, section in self.items()} + + def write(self): + """Update values in place while retaining user comments and layout.""" + try: + with open(self.filename, encoding="utf-8") as source: + lines = source.readlines() + except FileNotFoundError: + lines = [] + + output = [] + seen_sections = set() + section = None + seen_options = set() + skip_continuations = False + + def append_missing_options(): + if section not in self: + return + for key, value in self[section].items(): + if key not in seen_options: + output.extend(_format_option(key, value)) + + for line in lines: + section_match = re.match(r"\s*\[([^]]+)\]\s*(?:[#;].*)?$", line) + if section_match: + append_missing_options() + section = section_match.group(1) + seen_sections.add(section) + seen_options = set() + skip_continuations = False + output.append(line) + continue + + if skip_continuations and line.startswith((" ", "\t")) and line.strip() and not line.lstrip().startswith(("#", ";")): + continue + skip_continuations = False + + option_match = re.match(r"(\s*)([^#;\s][^:=]*?)(\s*[=:]\s*)(.*?)(\r?\n)?$", line) + if section in self and option_match: + key = option_match.group(2).rstrip() + if key in self[section]: + seen_options.add(key) + if self[section][key] == self._original.get(section, {}).get(key): + output.append(line) + else: + _, inline_comment = _split_value_comment(option_match.group(4)) + output.extend( + _format_option( + key, + self[section][key], + option_match.group(1), + option_match.group(3), + inline_comment, + ) + ) + skip_continuations = True + else: + skip_continuations = True + # A missing key was deliberately deleted. + continue + output.append(line) + + append_missing_options() + for name, values in self.items(): + if name in seen_sections: + continue + if output and output[-1].strip(): + output.append("\n") + output.append(f"[{name}]\n") + for key, value in values.items(): + output.extend(_format_option(key, value)) + + with open(self.filename, "w", encoding="utf-8", newline="") as destination: + destination.writelines(output) + self._original = {name: dict(values) for name, values in self.items()} + + +def _format_option(key, value, indent="", separator=" = ", inline_comment=""): + value = str(value) + parts = value.splitlines() or [""] + lines = [f"{indent}{key}{separator}{parts[0]}{inline_comment}\n"] + lines.extend(f"{indent}\t{part}\n" for part in parts[1:]) + return lines + + +def _read_config(filename): + parser = configparser.RawConfigParser( + delimiters=("=",), + comment_prefixes=("#", ";"), + inline_comment_prefixes=None, + interpolation=None, + strict=True, + empty_lines_in_values=False, + ) + parser.optionxform = str + parser.read(expanduser(filename), encoding="utf-8") + return { + name: ConfigSection({key: _unquote(_split_value_comment(value)[0]) for key, value in parser.items(name, raw=True)}) + for name in parser.sections() + } + + +def _split_value_comment(value): + quote = None + for index, character in enumerate(value): + if character in "\"'": + quote = None if quote == character else character if quote is None else quote + elif character == "#" and quote is None: + uncommented = value[:index].rstrip() + newline = value.find("\n", index) + if newline == -1: + return uncommented, value[len(uncommented) :] + return uncommented + value[newline:], value[len(uncommented) : newline] + return value, "" + + +def _unquote(value): + stripped = value.strip() + if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in "\"'": + return stripped[1:-1] + return stripped def config_location(): @@ -17,16 +173,14 @@ def config_location(): def load_config(usr_cfg, def_cfg=None): - # avoid config merges when possible. For writing, we need an umerged config instance. - # see https://github.com/dbcli/pgcli/issues/1240 and https://github.com/DiffSK/configobj/issues/171 + usr_cfg = expanduser(usr_cfg) if def_cfg: - cfg = ConfigObj() - cfg.merge(ConfigObj(def_cfg, interpolation=False)) - cfg.merge(ConfigObj(expanduser(usr_cfg), interpolation=False, encoding="utf-8")) + sections = _read_config(def_cfg) + for name, values in _read_config(usr_cfg).items(): + sections.setdefault(name, ConfigSection()).update(values) else: - cfg = ConfigObj(expanduser(usr_cfg), interpolation=False, encoding="utf-8") - cfg.filename = expanduser(usr_cfg) - return cfg + sections = _read_config(usr_cfg) + return PgcliConfig(usr_cfg, sections) def ensure_dir_exists(path): diff --git a/tests/test_config.py b/tests/test_config.py index 08fe74e65..9f52600f1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,7 @@ import pytest -from pgcli.config import ensure_dir_exists, skip_initial_comment +from pgcli.config import ensure_dir_exists, load_config, skip_initial_comment def test_ensure_file_parent(tmpdir): @@ -41,3 +41,54 @@ def test_ensure_other_create_error(tmpdir): ) def test_skip_initial_comment(text, skipped_lines): assert skip_initial_comment(io.StringIO(text)) == skipped_lines + + +def test_load_config_preserves_pgclirc_compatibility(tmp_path): + defaults = tmp_path / "defaults" + defaults.write_text( + "[main]\nEnabled = True\ncount = 2\nitems = one, 'two, too'\nprompt = 'default'\n[CaseSensitive]\nMixedCase = default\n", + encoding="utf-8", + ) + user = tmp_path / "config" + user.write_text( + "[main]\nprompt = '100% # ready'\nunquoted = value# comment\n[CaseSensitive]\nMixedCase = override\nmixedcase = separate\n", + encoding="utf-8", + ) + + config = load_config(str(user), str(defaults)) + + assert config["main"].as_bool("Enabled") is True + assert config["main"].as_int("count") == 2 + assert config["main"].as_list("items") == ["one", "two, too"] + assert config["main"]["prompt"] == "100% # ready" + assert config["main"]["unquoted"] == "value" + assert config["CaseSensitive"] == {"MixedCase": "override", "mixedcase": "separate"} + + +def test_config_write_keeps_comments_and_round_trips_queries(tmp_path): + from pgspecial.namedqueries import NamedQueries + + filename = tmp_path / "config" + filename.write_text( + "# user's heading\n[named queries]\n# keep this explanation\nold = select 1 # keep inline too\n" + "remove = select 2\n\n[main]\nprompt = '# > 100%'\n", + encoding="utf-8", + ) + config = load_config(str(filename)) + + queries = NamedQueries.from_config(config) + queries.save("old", "select 3\nfrom numbers") + queries.save("NewQuery", "select '#', '100%'") + assert queries.delete("remove") == "remove: Deleted" + + contents = filename.read_text(encoding="utf-8") + assert "# user's heading" in contents + assert "# keep this explanation" in contents + assert "# keep inline too" in contents + assert "remove =" not in contents + reloaded = load_config(str(filename)) + assert reloaded["named queries"] == { + "old": "select 3\nfrom numbers", + "NewQuery": "select '#', '100%'", + } + assert reloaded["main"]["prompt"] == "# > 100%" From 6d7f389747838f747f4ac39c4f4ea51178c6c2f8 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 20:55:51 +0000 Subject: [PATCH 2/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- pgcli/config.py | 45 ++++++++++++++++++++++++++++++++++++++++---- tests/test_config.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/pgcli/config.py b/pgcli/config.py index fe0cdec97..efbfb000f 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -26,6 +26,8 @@ def as_list(self, key): value = self[key] if not value: return [] + if isinstance(value, ConfigValue) and value.quoted: + return [str(value)] lexer = shlex.shlex(value, posix=True) lexer.whitespace = "," lexer.whitespace_split = True @@ -33,6 +35,15 @@ def as_list(self, key): return [item.strip() for item in lexer] +class ConfigValue(str): + """A string that remembers when its entire source value was quoted.""" + + def __new__(cls, value, quoted=False): + instance = super().__new__(cls, value) + instance.quoted = quoted + return instance + + class PgcliConfig(dict): """The subset of ConfigObj's interface used by pgcli and pgspecial.""" @@ -120,6 +131,10 @@ def append_missing_options(): def _format_option(key, value, indent="", separator=" = ", inline_comment=""): value = str(value) parts = value.splitlines() or [""] + if len(parts) == 1 and len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + outer_quote = '"' if value[0] == "'" else "'" + value = f"{outer_quote}{value}{outer_quote}" + parts = [value] lines = [f"{indent}{key}{separator}{parts[0]}{inline_comment}\n"] lines.extend(f"{indent}\t{part}\n" for part in parts[1:]) return lines @@ -135,13 +150,32 @@ def _read_config(filename): empty_lines_in_values=False, ) parser.optionxform = str - parser.read(expanduser(filename), encoding="utf-8") + try: + with open(expanduser(filename), encoding="utf-8") as source: + contents = source.read() + except FileNotFoundError: + return {} + parser.read_string(_normalize_triple_quoted_values(contents), source=filename) return { name: ConfigSection({key: _unquote(_split_value_comment(value)[0]) for key, value in parser.items(name, raw=True)}) for name in parser.sections() } +def _normalize_triple_quoted_values(contents): + """Translate ConfigObj triple-quoted values to configparser continuations.""" + pattern = re.compile( + r"^([ \t]*[^#;\s][^=\r\n]*?[ \t]*=[ \t]*)(\"\"\"|''')(.*?)\2[ \t]*(?:#[^\r\n]*)?$", + re.MULTILINE | re.DOTALL, + ) + + def replace(match): + parts = match.group(3).split("\n") + return match.group(1) + parts[0] + "".join(f"\n\t{part}" for part in parts[1:]) + + return pattern.sub(replace, contents) + + def _split_value_comment(value): quote = None for index, character in enumerate(value): @@ -158,9 +192,12 @@ def _split_value_comment(value): def _unquote(value): stripped = value.strip() - if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in "\"'": - return stripped[1:-1] - return stripped + if len(stripped) >= 2 and stripped[0] in "\"'": + quote = stripped[0] + closing = stripped.find(quote, 1) + if closing == len(stripped) - 1: + return ConfigValue(stripped[1:-1], quoted=True) + return ConfigValue(stripped) def config_location(): diff --git a/tests/test_config.py b/tests/test_config.py index 9f52600f1..9151ad477 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -92,3 +92,34 @@ def test_config_write_keeps_comments_and_round_trips_queries(tmp_path): "NewQuery": "select '#', '100%'", } assert reloaded["main"]["prompt"] == "# > 100%" + + +def test_configobj_quoted_lists_are_preserved(tmp_path): + filename = tmp_path / "config" + filename.write_text( + '[main]\nitems = "delete", "update"\nquoted_item = "delete, update"\n', + encoding="utf-8", + ) + + config = load_config(str(filename)) + + assert config["main"].as_list("items") == ["delete", "update"] + assert config["main"].as_list("quoted_item") == ["delete, update"] + + +def test_configobj_multiline_and_literal_quotes_survive_writes(tmp_path): + filename = tmp_path / "config" + filename.write_text( + "[named queries]\nold = '''select 1\nfrom numbers''' # keep\n[main]\nprompt = original\n", + encoding="utf-8", + ) + config = load_config(str(filename)) + + config["named queries"]["new"] = "select 2" + config["main"]["prompt"] = "'quoted prompt'" + config.write() + + reloaded = load_config(str(filename)) + assert reloaded["named queries"]["old"] == "select 1\nfrom numbers" + assert reloaded["main"]["prompt"] == "'quoted prompt'" + assert "''' # keep" in filename.read_text(encoding="utf-8") From 49501de452329c95c3b4289ec163281cb3719e42 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 20:59:16 +0000 Subject: [PATCH 3/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- pgcli/config.py | 35 +++++++++++++++++++++++++++++++++-- tests/test_config.py | 20 +++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/pgcli/config.py b/pgcli/config.py index efbfb000f..eaf303662 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -73,7 +73,9 @@ def append_missing_options(): if key not in seen_options: output.extend(_format_option(key, value)) - for line in lines: + line_number = 0 + while line_number < len(lines): + line = lines[line_number] section_match = re.match(r"\s*\[([^]]+)\]\s*(?:[#;].*)?$", line) if section_match: append_missing_options() @@ -82,21 +84,27 @@ def append_missing_options(): seen_options = set() skip_continuations = False output.append(line) + line_number += 1 continue if skip_continuations and line.startswith((" ", "\t")) and line.strip() and not line.lstrip().startswith(("#", ";")): + line_number += 1 continue skip_continuations = False option_match = re.match(r"(\s*)([^#;\s][^:=]*?)(\s*[=:]\s*)(.*?)(\r?\n)?$", line) if section in self and option_match: key = option_match.group(2).rstrip() + value_end, triple_quoted_comment = _triple_quoted_value_end( + lines, line_number, option_match.group(4) + ) if key in self[section]: seen_options.add(key) if self[section][key] == self._original.get(section, {}).get(key): - output.append(line) + output.extend(lines[line_number : value_end + 1]) else: _, inline_comment = _split_value_comment(option_match.group(4)) + inline_comment = triple_quoted_comment or inline_comment output.extend( _format_option( key, @@ -108,10 +116,14 @@ def append_missing_options(): ) skip_continuations = True else: + if triple_quoted_comment: + output.append(f"{option_match.group(1)}{triple_quoted_comment.lstrip()}\n") skip_continuations = True # A missing key was deliberately deleted. + line_number = value_end + 1 continue output.append(line) + line_number += 1 append_missing_options() for name, values in self.items(): @@ -128,6 +140,25 @@ def append_missing_options(): self._original = {name: dict(values) for name, values in self.items()} +def _triple_quoted_value_end(lines, start, first_value): + """Return the last source line and trailing comment for a triple-quoted value.""" + stripped = first_value.lstrip() + quote = stripped[:3] + if quote not in ('"""', "'''"): + return start, "" + + for line_number in range(start, len(lines)): + value = stripped[3:] if line_number == start else lines[line_number] + closing = value.find(quote) + if closing == -1: + continue + suffix = value[closing + 3 :].rstrip("\r\n") + match = re.fullmatch(r"[ \t]*(#[^\r\n]*)?", suffix) + if match: + return line_number, suffix if match.group(1) else "" + return start, "" + + def _format_option(key, value, indent="", separator=" = ", inline_comment=""): value = str(value) parts = value.splitlines() or [""] diff --git a/tests/test_config.py b/tests/test_config.py index 9151ad477..2d34aa190 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -107,19 +107,29 @@ def test_configobj_quoted_lists_are_preserved(tmp_path): assert config["main"].as_list("quoted_item") == ["delete, update"] -def test_configobj_multiline_and_literal_quotes_survive_writes(tmp_path): +def test_configobj_multiline_queries_update_and_delete(tmp_path): + from pgspecial.namedqueries import NamedQueries + filename = tmp_path / "config" filename.write_text( - "[named queries]\nold = '''select 1\nfrom numbers''' # keep\n[main]\nprompt = original\n", + "[named queries]\nold = '''select 1\nfrom numbers''' # keep old comment\n" + "remove = \"\"\"select 2\nfrom numbers\nwhere false\"\"\" # keep remove comment\n" + "[main]\nprompt = original\n", encoding="utf-8", ) config = load_config(str(filename)) - config["named queries"]["new"] = "select 2" + queries = NamedQueries.from_config(config) + queries.save("old", "select 3\nfrom updated") + assert queries.delete("remove") == "remove: Deleted" config["main"]["prompt"] = "'quoted prompt'" config.write() + contents = filename.read_text(encoding="utf-8") + assert "from numbers'''" not in contents + assert 'where false\"\"\"' not in contents + assert "old = select 3 # keep old comment" in contents + assert "# keep remove comment" in contents reloaded = load_config(str(filename)) - assert reloaded["named queries"]["old"] == "select 1\nfrom numbers" + assert reloaded["named queries"] == {"old": "select 3\nfrom updated"} assert reloaded["main"]["prompt"] == "'quoted prompt'" - assert "''' # keep" in filename.read_text(encoding="utf-8") From 3dd0f6d404330f4c785f6c34e0a660f0ea66aaca Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 21:02:31 +0000 Subject: [PATCH 4/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- pgcli/config.py | 4 +--- tests/test_config.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pgcli/config.py b/pgcli/config.py index eaf303662..719cf949f 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -95,9 +95,7 @@ def append_missing_options(): option_match = re.match(r"(\s*)([^#;\s][^:=]*?)(\s*[=:]\s*)(.*?)(\r?\n)?$", line) if section in self and option_match: key = option_match.group(2).rstrip() - value_end, triple_quoted_comment = _triple_quoted_value_end( - lines, line_number, option_match.group(4) - ) + value_end, triple_quoted_comment = _triple_quoted_value_end(lines, line_number, option_match.group(4)) if key in self[section]: seen_options.add(key) if self[section][key] == self._original.get(section, {}).get(key): diff --git a/tests/test_config.py b/tests/test_config.py index 2d34aa190..775d77cfe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -127,7 +127,7 @@ def test_configobj_multiline_queries_update_and_delete(tmp_path): contents = filename.read_text(encoding="utf-8") assert "from numbers'''" not in contents - assert 'where false\"\"\"' not in contents + assert 'where false"""' not in contents assert "old = select 3 # keep old comment" in contents assert "# keep remove comment" in contents reloaded = load_config(str(filename)) From 837769f09f6ed969f9395b4215a31f2d2424ac31 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 21:09:32 +0000 Subject: [PATCH 5/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- pgcli/config.py | 63 +++++++++++++++++++++++++++++++++++--------- tests/test_config.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/pgcli/config.py b/pgcli/config.py index 719cf949f..47814f697 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -2,7 +2,6 @@ import os import platform import configparser -import shlex from os.path import expanduser, exists, dirname import re from typing import TextIO @@ -28,11 +27,17 @@ def as_list(self, key): return [] if isinstance(value, ConfigValue) and value.quoted: return [str(value)] - lexer = shlex.shlex(value, posix=True) - lexer.whitespace = "," - lexer.whitespace_split = True - lexer.commenters = "" - return [item.strip() for item in lexer] + items = [] + start = 0 + quote = None + for index, character in enumerate(value): + if character in "\"'" and not _is_escaped(value, index): + quote = None if quote == character else character if quote is None else quote + elif character == "," and quote is None: + items.append(str(_unquote(value[start:index]))) + start = index + 1 + items.append(str(_unquote(value[start:]))) + return items class ConfigValue(str): @@ -159,11 +164,17 @@ def _triple_quoted_value_end(lines, start, first_value): def _format_option(key, value, indent="", separator=" = ", inline_comment=""): value = str(value) - parts = value.splitlines() or [""] - if len(parts) == 1 and len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + if "#" in value or value != value.strip(): + if "'" not in value: + value = f"'{value}'" + elif '"' not in value: + value = f'"{value}"' + else: + value = f'"""{value}"""' + elif "\n" not in value and "\r" not in value and len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": outer_quote = '"' if value[0] == "'" else "'" value = f"{outer_quote}{value}{outer_quote}" - parts = [value] + parts = value.splitlines() or [""] lines = [f"{indent}{key}{separator}{parts[0]}{inline_comment}\n"] lines.extend(f"{indent}\t{part}\n" for part in parts[1:]) return lines @@ -177,6 +188,7 @@ def _read_config(filename): interpolation=None, strict=True, empty_lines_in_values=False, + default_section=None, ) parser.optionxform = str try: @@ -200,15 +212,27 @@ def _normalize_triple_quoted_values(contents): def replace(match): parts = match.group(3).split("\n") - return match.group(1) + parts[0] + "".join(f"\n\t{part}" for part in parts[1:]) + quote = match.group(2) + return match.group(1) + quote + parts[0] + "".join(f"\n\t{part}" for part in parts[1:]) + quote return pattern.sub(replace, contents) def _split_value_comment(value): + stripped = value.lstrip() + triple_quote = stripped[:3] + if triple_quote in ('"""', "'''"): + closing = value.rfind(triple_quote) + if closing > len(value) - len(stripped): + suffix = value[closing + 3 :] + comment = re.fullmatch(r"([ \t]*#[^\r\n]*)(\r?\n.*)?", suffix, re.DOTALL) + if comment: + return value[: closing + 3] + (comment.group(2) or ""), comment.group(1) + return value, "" + quote = None for index, character in enumerate(value): - if character in "\"'": + if character in "\"'" and not _is_escaped(value, index): quote = None if quote == character else character if quote is None else quote elif character == "#" and quote is None: uncommented = value[:index].rstrip() @@ -219,11 +243,26 @@ def _split_value_comment(value): return value, "" +def _is_escaped(value, index): + """Return whether the character at index follows an odd run of slashes.""" + slashes = 0 + index -= 1 + while index >= 0 and value[index] == "\\": + slashes += 1 + index -= 1 + return slashes % 2 == 1 + + def _unquote(value): stripped = value.strip() + if len(stripped) >= 6 and stripped[:3] in ('"""', "'''") and stripped.endswith(stripped[:3]): + return ConfigValue(stripped[3:-3], quoted=True) if len(stripped) >= 2 and stripped[0] in "\"'": quote = stripped[0] - closing = stripped.find(quote, 1) + closing = next( + (index for index in range(1, len(stripped)) if stripped[index] == quote and not _is_escaped(stripped, index)), + None, + ) if closing == len(stripped) - 1: return ConfigValue(stripped[1:-1], quoted=True) return ConfigValue(stripped) diff --git a/tests/test_config.py b/tests/test_config.py index 775d77cfe..c300e1078 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -107,6 +107,50 @@ def test_configobj_quoted_lists_are_preserved(tmp_path): assert config["main"].as_list("quoted_item") == ["delete, update"] +def test_configobj_lists_preserve_backslashes(tmp_path): + filename = tmp_path / "config" + filename.write_text("[main]\npaths = C:\\tmp\\file, D:\\data\n", encoding="utf-8") + + config = load_config(str(filename)) + + assert config["main"].as_list("paths") == [r"C:\tmp\file", r"D:\data"] + + +def test_escaped_quote_does_not_expose_hash_comment(tmp_path): + filename = tmp_path / "config" + filename.write_text("[main]\nprompt = 'Bob\\'s # tag'\n", encoding="utf-8") + + config = load_config(str(filename)) + + assert config["main"]["prompt"] == r"Bob\'s # tag" + + +def test_default_is_an_ordinary_case_sensitive_section(tmp_path): + filename = tmp_path / "config" + filename.write_text("[DEFAULT]\nOnlyHere = value\n[main]\nprompt = ready\n", encoding="utf-8") + + config = load_config(str(filename)) + + assert config["DEFAULT"] == {"OnlyHere": "value"} + assert config["main"] == {"prompt": "ready"} + + +@pytest.mark.parametrize( + "query", + ["select payload #> path", " select trailing ", '''select 'one', "two" #> path'''], +) +def test_named_query_write_quotes_hashes_and_surrounding_whitespace(tmp_path, query): + from pgspecial.namedqueries import NamedQueries + + filename = tmp_path / "config" + filename.write_text("[named queries]\n", encoding="utf-8") + config = load_config(str(filename)) + + NamedQueries.from_config(config).save("q", query) + + assert load_config(str(filename))["named queries"]["q"] == query + + def test_configobj_multiline_queries_update_and_delete(tmp_path): from pgspecial.namedqueries import NamedQueries From 146e7028611a382509a3f1669b09cc0222d60c8f Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 21:17:13 +0000 Subject: [PATCH 6/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- docs/config-compatibility.rst | 22 ++++++++++++++ pgcli/config.py | 57 +++++++++++++++++++++++++++++------ tests/test_config.py | 55 ++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 docs/config-compatibility.rst diff --git a/docs/config-compatibility.rst b/docs/config-compatibility.rst new file mode 100644 index 000000000..d9c8e36da --- /dev/null +++ b/docs/config-compatibility.rst @@ -0,0 +1,22 @@ +Pgclirc parser compatibility +============================ + +Pgcli uses :mod:`configparser` for the section and option structure of its main +``pgclirc`` file, with a small compatibility layer for the ConfigObj syntax +used by existing files. Unlike an unconfigured ``configparser`` instance, the +adapter keeps option names case-sensitive, disables ``%`` interpolation, treats +``[DEFAULT]`` as an ordinary section, removes matching outer quotes, and exposes +ConfigObj-compatible boolean, integer, and comma-separated list accessors. + +Quoted values may contain literal ``#`` and ``%`` characters. Inline ``#`` +comments, user comments, and layout are retained when settings or named queries +are written. Both ConfigObj triple-quote styles are accepted for multiline +values; their blank lines, comment-prefixed lines, indentation, and surrounding +whitespace are preserved. New multiline values are written with triple quotes. +Default-file values are loaded first and user-file values override them. + +Pgclirc supports single-bracket sections, including dotted names such as +``[alias_dsn.init-commands]``. ConfigObj root-level options and nested +``[[sections]]`` are intentionally unsupported and produce a clear error rather +than being silently reinterpreted. ConfigObj remains a runtime dependency for +PostgreSQL service-file parsing until that separate consumer is migrated. diff --git a/pgcli/config.py b/pgcli/config.py index 47814f697..0b068c7b5 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -24,6 +24,8 @@ def as_int(self, key): def as_list(self, key): value = self[key] if not value: + return [""] + if value == ",": return [] if isinstance(value, ConfigValue) and value.quoted: return [str(value)] @@ -37,6 +39,8 @@ def as_list(self, key): items.append(str(_unquote(value[start:index]))) start = index + 1 items.append(str(_unquote(value[start:]))) + if value.endswith(","): + items.pop() return items @@ -164,6 +168,11 @@ def _triple_quoted_value_end(lines, start, first_value): def _format_option(key, value, indent="", separator=" = ", inline_comment=""): value = str(value) + if "\n" in value or "\r" in value: + quote = next((candidate for candidate in ('"""', "'''") if candidate not in value), None) + if quote is None: + raise ValueError(f"Cannot serialize {key!r}: value contains both triple-quote delimiters") + return [f"{indent}{key}{separator}{quote}{value}{quote}{inline_comment}\n"] if "#" in value or value != value.strip(): if "'" not in value: value = f"'{value}'" @@ -196,26 +205,54 @@ def _read_config(filename): contents = source.read() except FileNotFoundError: return {} - parser.read_string(_normalize_triple_quoted_values(contents), source=filename) + normalized, multiline_values = _extract_triple_quoted_values(contents, filename) + parser.read_string(normalized, source=filename) return { - name: ConfigSection({key: _unquote(_split_value_comment(value)[0]) for key, value in parser.items(name, raw=True)}) + name: ConfigSection({ + key: multiline_values.get(str(parsed := _unquote(_split_value_comment(value)[0])), parsed) + for key, value in parser.items(name, raw=True) + }) for name in parser.sections() } -def _normalize_triple_quoted_values(contents): - """Translate ConfigObj triple-quoted values to configparser continuations.""" +def _extract_triple_quoted_values(contents, source): + """Hide ConfigObj multiline values from configparser and retain them exactly.""" pattern = re.compile( - r"^([ \t]*[^#;\s][^=\r\n]*?[ \t]*=[ \t]*)(\"\"\"|''')(.*?)\2[ \t]*(?:#[^\r\n]*)?$", + r"^([ \t]*[^#;\s][^=\r\n]*?[ \t]*=[ \t]*)(\"\"\"|''')(.*?)\2([ \t]*(?:#[^\r\n]*)?)$", re.MULTILINE | re.DOTALL, ) + values = {} + counter = 0 def replace(match): - parts = match.group(3).split("\n") - quote = match.group(2) - return match.group(1) + quote + parts[0] + "".join(f"\n\t{part}" for part in parts[1:]) + quote - - return pattern.sub(replace, contents) + nonlocal counter + while True: + placeholder = f"__pgcli_multiline_{counter}__" + counter += 1 + if placeholder not in contents: + break + values[placeholder] = ConfigValue(match.group(3), quoted=True) + return f'{match.group(1)}"{placeholder}"{match.group(4)}' + + normalized = pattern.sub(replace, contents) + _validate_supported_structure(normalized, source) + return normalized, values + + +def _validate_supported_structure(contents, source): + """Reject ConfigObj structures that pgclirc has never needed.""" + section_seen = False + for line_number, line in enumerate(contents.splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", ";")): + continue + if re.match(r"^\[\[.*\]\](?:\s*[#;].*)?$", stripped): + raise ValueError(f"{source}:{line_number}: nested [[sections]] are not supported") + if re.match(r"^\[[^]]+\](?:\s*[#;].*)?$", stripped): + section_seen = True + elif not section_seen and re.match(r"^[^#;\s][^=]*=", stripped): + raise ValueError(f"{source}:{line_number}: root-level options are not supported") def _split_value_comment(value): diff --git a/tests/test_config.py b/tests/test_config.py index c300e1078..76a512333 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ import io import os +import re import stat import pytest @@ -116,6 +117,17 @@ def test_configobj_lists_preserve_backslashes(tmp_path): assert config["main"].as_list("paths") == [r"C:\tmp\file", r"D:\data"] +@pytest.mark.parametrize( + "source, expected", + [("", [""]), ('""', [""]), ("delete,", ["delete"]), (",", [])], +) +def test_configobj_list_edge_cases(tmp_path, source, expected): + filename = tmp_path / "config" + filename.write_text(f"[main]\nitems = {source}\n", encoding="utf-8") + + assert load_config(str(filename))["main"].as_list("items") == expected + + def test_escaped_quote_does_not_expose_hash_comment(tmp_path): filename = tmp_path / "config" filename.write_text("[main]\nprompt = 'Bob\\'s # tag'\n", encoding="utf-8") @@ -172,8 +184,49 @@ def test_configobj_multiline_queries_update_and_delete(tmp_path): contents = filename.read_text(encoding="utf-8") assert "from numbers'''" not in contents assert 'where false"""' not in contents - assert "old = select 3 # keep old comment" in contents + assert 'old = """select 3\nfrom updated""" # keep old comment' in contents assert "# keep remove comment" in contents reloaded = load_config(str(filename)) assert reloaded["named queries"] == {"old": "select 3\nfrom updated"} assert reloaded["main"]["prompt"] == "'quoted prompt'" + + +@pytest.mark.parametrize("quote", ['"""', "'''"]) +def test_multiline_queries_are_lossless_across_writes_and_delete(tmp_path, quote): + filename = tmp_path / "config" + original = "\nselect 1\n\n# literal hash\n; literal semicolon\n indented\n" + filename.write_text( + f"[named queries]\nq = {quote}{original}{quote}\nkeep = select 2 # keep\n", + encoding="utf-8", + ) + + config = load_config(str(filename)) + assert config["named queries"]["q"] == original + config["named queries"]["new"] = original + config.write() + assert load_config(str(filename))["named queries"]["new"] == original + + reloaded = load_config(str(filename)) + reloaded.write() + del reloaded["named queries"]["q"] + reloaded.write() + result = load_config(str(filename))["named queries"] + assert "q" not in result + assert result["new"] == original + assert result["keep"] == "select 2" + assert "# keep" in filename.read_text(encoding="utf-8") + + +@pytest.mark.parametrize( + "contents, message", + [ + ("option = value\n[main]\nprompt = ready\n", "root-level options"), + ("[main]\n[[nested]]\noption = value\n", "nested [[sections]]"), + ], +) +def test_unsupported_configobj_structures_fail_clearly(tmp_path, contents, message): + filename = tmp_path / "config" + filename.write_text(contents, encoding="utf-8") + + with pytest.raises(ValueError, match=re.escape(message)): + load_config(str(filename)) From 8609536d8a32d8963cf662514e9e6af691a80ab7 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Fri, 11 Sep 2026 21:23:54 +0000 Subject: [PATCH 7/7] Replace the pgclirc ConfigObj parser while preserving configuration Closes dbcli/pgcli#1634 --- pgcli/config.py | 4 ++-- tests/test_config.py | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pgcli/config.py b/pgcli/config.py index 0b068c7b5..bb9ae0f64 100644 --- a/pgcli/config.py +++ b/pgcli/config.py @@ -25,10 +25,10 @@ def as_list(self, key): value = self[key] if not value: return [""] - if value == ",": - return [] if isinstance(value, ConfigValue) and value.quoted: return [str(value)] + if value == ",": + return [] items = [] start = 0 quote = None diff --git a/tests/test_config.py b/tests/test_config.py index 76a512333..c919ef38d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -119,7 +119,14 @@ def test_configobj_lists_preserve_backslashes(tmp_path): @pytest.mark.parametrize( "source, expected", - [("", [""]), ('""', [""]), ("delete,", ["delete"]), (",", [])], + [ + ("", [""]), + ('""', [""]), + ("delete,", ["delete"]), + (",", []), + ('","', [","]), + ("','", [","]), + ], ) def test_configobj_list_edge_cases(tmp_path, source, expected): filename = tmp_path / "config"