From 3e9c26bd46dccf98ff98a45dad45837866edb628 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 22 Oct 2026 20:16:35 -0600 Subject: [PATCH] refactor(config): log the duplicate-key warning without the python warning prefix --- tests/core/test_config_duplicate_keys.py | 27 ++++++++++++++---------- unshackle/core/config.py | 9 ++++++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/core/test_config_duplicate_keys.py b/tests/core/test_config_duplicate_keys.py index cf0889f2..d05f4e0a 100644 --- a/tests/core/test_config_duplicate_keys.py +++ b/tests/core/test_config_duplicate_keys.py @@ -1,6 +1,6 @@ from __future__ import annotations -import warnings +import logging from pathlib import Path import pytest @@ -17,22 +17,25 @@ def test_duplicate_key_lines_follows_aliases_without_looping(compose) -> None: assert duplicate_key_lines(compose(ALIASES)) == [(2, 3), (7, 8)] -def test_load_yaml_warns_with_lines_and_keeps_the_later_copy(tmp_path: Path) -> None: +def test_load_yaml_warns_once_with_lines_and_keeps_the_later_copy( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: path = tmp_path / "unshackle.yaml" path.write_text("credentials:\n EXAMPLE: first-secret\n EXAMPLE: second-secret\n", encoding="utf-8") - with pytest.warns(UserWarning) as caught: + with caplog.at_level(logging.WARNING, logger="config"): data = load_yaml(path) + load_yaml(path) assert data == {"credentials": {"EXAMPLE": "second-secret"}} - message = str(caught[0].message) - assert message == f"{path}: duplicate key on lines 2 and 3. Unshackle uses the later one." - assert "EXAMPLE" not in message and "secret" not in message + assert caplog.messages == [f"{path}: duplicate key on lines 2 and 3. Unshackle uses the later one."] + assert "EXAMPLE" not in caplog.text and "secret" not in caplog.text -def test_config_from_yaml_warns_on_a_duplicate_key(tmp_path: Path) -> None: +def test_config_from_yaml_warns_on_a_duplicate_key(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: path = tmp_path / "unshackle.yaml" path.write_text("tag: first\ntag: second\n", encoding="utf-8") - with pytest.warns(UserWarning, match="duplicate key on lines 1 and 2"): + with caplog.at_level(logging.WARNING, logger="config"): assert Config.from_yaml(path).tag == "second" + assert "duplicate key on lines 1 and 2" in caplog.text @pytest.mark.parametrize( @@ -40,9 +43,11 @@ def test_config_from_yaml_warns_on_a_duplicate_key(tmp_path: Path) -> None: ["a: 1\nb: [1, 2]\n", "", "a: &a\n x: 1\nb: &b\n y: 2\nc:\n <<: *a\n <<: *b\n"], ids=["valid", "empty", "two-merges"], ) -def test_load_yaml_matches_safe_load_without_a_warning(tmp_path: Path, text: str) -> None: +def test_load_yaml_matches_safe_load_without_a_warning( + tmp_path: Path, text: str, caplog: pytest.LogCaptureFixture +) -> None: path = tmp_path / "config.yaml" path.write_text(text, encoding="utf-8") - with warnings.catch_warnings(): - warnings.simplefilter("error") + with caplog.at_level(logging.WARNING, logger="config"): assert load_yaml(path) == yaml.safe_load(text) + assert not caplog.records diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 5c98975f..1a36e8ad 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import re import warnings from pathlib import Path @@ -411,6 +412,9 @@ def duplicate_key_message(name: str, pairs: list[tuple[int, int]], line: int = 0 return f"{name}: duplicate key on {where}. Unshackle uses the later one." +_warned: set[str] = set() + + def load_yaml(path: Path) -> Any: """yaml.safe_load a file, and warn with the lines of each duplicate key, which PyYAML takes silently.""" loader = yaml.SafeLoader(path.read_text(encoding="utf8")) @@ -418,8 +422,9 @@ def load_yaml(path: Path) -> Any: node = loader.get_single_node() if node is None: return None - if pairs := duplicate_key_lines(node): - warnings.warn(duplicate_key_message(str(path), pairs), stacklevel=2) + if (pairs := duplicate_key_lines(node)) and (message := duplicate_key_message(str(path), pairs)) not in _warned: + _warned.add(message) + logging.getLogger("config").warning(message) return loader.construct_document(node) finally: loader.dispose()