fix(cfg): give the lines of each duplicate config key instead of crashing

This commit is contained in:
imSp4rky
2026-10-22 19:45:46 -06:00
parent 1ec236f61b
commit f83a594c91
3 changed files with 94 additions and 2 deletions
+1 -1
View File
@@ -339,7 +339,7 @@ unshackle cfg [KEY] [VALUE] [OPTIONS]
| `--list` | List all set configuration values. |
!!! warning "Comments are stripped"
Writing a value through `cfg` rewrites the configuration file and **removes all comments** from it. If your config relies on comments, edit it by hand instead. Setting a value and using `--unset` together is an error.
Writing a value through `cfg` rewrites the configuration file and **removes all comments** from it. If your config relies on comments, edit it by hand instead. Setting a value and using `--unset` together is an error. If a config key is set twice in the same block, `cfg` stops and gives the line numbers of the two copies. Other commands use the later copy.
!!! example
```shell
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from pathlib import Path
import pytest
from click.testing import CliRunner
from unshackle.commands import cfg as cfg_module
@pytest.fixture
def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "unshackle.yaml"
monkeypatch.setattr(cfg_module, "get_config_path", lambda: path)
return path
def test_a_duplicate_key_names_its_lines_but_no_key_or_value(config_file: Path) -> None:
config_file.write_text(
"theme: rainbow\ncredentials:\n EXAMPLE: first-secret\n OTHER: other\n EXAMPLE: second-secret\n"
"serve:\n users:\n key-secret: {tier: a}\n key-secret: {tier: b}\n",
encoding="utf-8",
)
result = CliRunner().invoke(cfg_module.cfg, ["theme"])
assert isinstance(result.exception, SystemExit), result.exception # a ClickException, not a crash
assert result.exit_code == 1
text = " ".join(result.output.replace("│", " ").split()) # the error panel wraps its lines
assert "unshackle.yaml: duplicate key on lines 3 and 5, lines 8 and 9. Unshackle uses the later one." in text
assert "secret" not in result.output
assert "EXAMPLE" not in result.output
def test_a_duplicate_key_blocks_a_write(config_file: Path) -> None:
original = "theme: rainbow\ncredentials:\n EXAMPLE: a\n EXAMPLE: b\n"
config_file.write_text(original, encoding="utf-8")
result = CliRunner().invoke(cfg_module.cfg, ["theme", "plain"])
assert result.exit_code == 1
assert config_file.read_text(encoding="utf-8") == original
def test_a_valid_config_still_reads_a_key(config_file: Path) -> None:
config_file.write_text("theme: rainbow\n", encoding="utf-8")
result = CliRunner().invoke(cfg_module.cfg, ["theme"])
assert result.exit_code == 0, result.output
assert "rainbow" in result.output
def test_duplicate_key_lines_follows_aliases_without_looping() -> None:
from ruamel.yaml import YAML
root = YAML().compose("base: &b\n x: 1\n x: 2\n self: *b\nother: *b\nlist:\n - y: 1\n y: 2\n")
assert cfg_module.duplicate_key_lines(root) == [(2, 3), (7, 8)]
def test_a_duplicate_only_ruamel_sees_still_gives_its_line(config_file: Path) -> None:
config_file.write_text("a: 1\n1: x\n0x1: y\n", encoding="utf-8")
result = CliRunner().invoke(cfg_module.cfg, ["a"])
assert "unshackle.yaml: duplicate key on line 3." in " ".join(result.output.replace("│", " ").split())
+35 -1
View File
@@ -4,11 +4,37 @@ import sys
import click
from ruamel.yaml import YAML
from ruamel.yaml.constructor import DuplicateKeyError
from ruamel.yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
from unshackle.core.config import config, get_config_path
from unshackle.core.constants import context_settings
def duplicate_key_lines(root: Node) -> list[tuple[int, int]]:
"""Return (first line, repeat line), counted from 1, for each repeated scalar key in the node tree."""
pairs, stack, visited = [], [root], set()
while stack:
node = stack.pop()
if node in visited: # an alias reuses its node, and can point back at itself
continue
visited.add(node)
if isinstance(node, MappingNode):
first: dict[tuple[str, str], int] = {}
for key_node, value_node in node.value:
if isinstance(key_node, ScalarNode):
key = (key_node.tag, key_node.value)
line = key_node.start_mark.line + 1
if key in first:
pairs.append((first[key], line))
else:
first[key] = line
stack += [key_node, value_node]
elif isinstance(node, SequenceNode):
stack += node.value
return sorted(pairs)
@click.command(
short_help="Manage configuration values for the program and its services.", context_settings=context_settings
)
@@ -41,7 +67,15 @@ def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> N
config_path = get_config_path() or config.directories.user_configs / config.filenames.root_config
if config_path.exists():
data = yaml.load(config_path)
try:
data = yaml.load(config_path)
except DuplicateKeyError as e:
pairs = duplicate_key_lines(yaml.compose(config_path))
where = ", ".join(f"lines {first} and {repeat}" for first, repeat in pairs)
where = where or f"line {e.problem_mark.line + 1}"
raise click.ClickException(
f"{config_path.name}: duplicate key on {where}. Unshackle uses the later one."
) from None
if not data:
log.warning("No config file was found or it has no data, yet")