From e6de5fc7ea5c71a66adcae130a5fcc469ab28457 Mon Sep 17 00:00:00 2001 From: kkey <134298472+Imkkey@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:10:28 -0800 Subject: [PATCH 1/2] Remove stale keys when reloading YAML sources --- confuse/sources.py | 1 + docs/changelog.rst | 8 +++-- test/test_yaml.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/confuse/sources.py b/confuse/sources.py index cc31276..9eaca73 100644 --- a/confuse/sources.py +++ b/confuse/sources.py @@ -101,6 +101,7 @@ def load(self) -> None: value = yaml_util.load_yaml(self.filename, loader=self.loader) or {} if isinstance(value, dict): + self.clear() self.update(value) else: # We enforce that the loaded YAML is a mapping (dict) diff --git a/docs/changelog.rst b/docs/changelog.rst index 680346e..6d27131 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,9 +13,11 @@ Unreleased New features ~~~~~~~~~~~~ -.. - Bug fixes - ~~~~~~~~~ +Bug fixes +~~~~~~~~~ + +- Remove stale keys when reloading YAML sources, allowing deleted overrides to + fall back to lower-priority sources. .. For plugin developers diff --git a/test/test_yaml.py b/test/test_yaml.py index 7b636ac..b1cb9c2 100644 --- a/test/test_yaml.py +++ b/test/test_yaml.py @@ -114,3 +114,89 @@ def test_list_unchanged(self): def test_invalid_yaml_string_unchanged(self): v = confuse.yaml_util.parse_as_scalar("!", confuse.Loader) assert v == "!" + + +class TestYamlReload: + def test_removed_keys_reveal_defaults(self, tmp_path): + path = tmp_path / "config.yaml" + path.write_text("foo: override\nremoved: old\nkept: before\n") + config = confuse.Configuration("test", read=False) + config.add({"foo": "default"}) + config.set_file(str(path)) + config.set({"memory": "preserved"}) + foo = config["foo"] + assert foo.get() == "override" + + path.write_text("kept: after\nadded: new\n") + config.reload() + + assert foo.get() == "default" + assert not config["removed"].exists() + assert config["kept"].get() == "after" + assert config["added"].get() == "new" + assert config["memory"].get() == "preserved" + + @pytest.mark.parametrize("contents", ["", "{}", "# Empty configuration\n"]) + def test_empty_file_clears_source(self, tmp_path, contents): + path = tmp_path / "config.yaml" + path.write_text("foo: bar\n") + source = confuse.YamlSource(str(path)) + + path.write_text(contents) + source.load() + + assert source == {} + + def test_missing_optional_file_clears_source(self, tmp_path): + path = tmp_path / "config.yaml" + path.write_text("foo: bar\n") + source = confuse.YamlSource(str(path), optional=True) + + path.unlink() + source.load() + + assert source == {} + path.write_text("new: value\n") + source.load() + assert source == {"new": "value"} + + @pytest.mark.parametrize( + "contents, error", [("foo: [", confuse.ConfigReadError), ("- item", TypeError)] + ) + def test_failed_reload_preserves_source(self, tmp_path, contents, error): + path = tmp_path / "config.yaml" + path.write_text("foo: bar\n") + source = confuse.YamlSource(str(path)) + + path.write_text(contents) + with pytest.raises(error): + source.load() + + assert source == {"foo": "bar"} + + def test_missing_required_file_preserves_source(self, tmp_path): + path = tmp_path / "config.yaml" + path.write_text("foo: bar\n") + source = confuse.YamlSource(str(path)) + + path.unlink() + with pytest.raises(confuse.ConfigReadError): + source.load() + + assert source == {"foo": "bar"} + + def test_reload_preserves_source_metadata_and_order(self, tmp_path): + path = tmp_path / "config.yaml" + path.write_text("first: before\nsecond: before\n") + source = confuse.YamlSource(str(path), default=True, base_for_paths=True) + config = confuse.Configuration("test", read=False) + config.add(source) + + path.write_text("second: after\nfirst: after\n") + config.reload() + + assert config.sources[0] is source + assert list(source) == ["second", "first"] + assert source.filename == str(path) + assert source.default is True + assert source.base_for_paths is True From c523030e150ba5bc969f51098f56f6cb5567fafd Mon Sep 17 00:00:00 2001 From: kkey <134298472+Imkkey@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:44:36 -0800 Subject: [PATCH 2/2] Preserve YAML source data on falsey non-mapping reloads --- confuse/sources.py | 4 +++- test/test_yaml.py | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/confuse/sources.py b/confuse/sources.py index 9eaca73..f295171 100644 --- a/confuse/sources.py +++ b/confuse/sources.py @@ -98,7 +98,9 @@ def load(self) -> None: elif self.filename is None: raise TypeError("filename is required for YamlSource") else: - value = yaml_util.load_yaml(self.filename, loader=self.loader) or {} + value = yaml_util.load_yaml(self.filename, loader=self.loader) + if value is None: + value = {} if isinstance(value, dict): self.clear() diff --git a/test/test_yaml.py b/test/test_yaml.py index b1cb9c2..3dfc57f 100644 --- a/test/test_yaml.py +++ b/test/test_yaml.py @@ -136,7 +136,9 @@ def test_removed_keys_reveal_defaults(self, tmp_path): assert config["added"].get() == "new" assert config["memory"].get() == "preserved" - @pytest.mark.parametrize("contents", ["", "{}", "# Empty configuration\n"]) + @pytest.mark.parametrize( + "contents", ["", "{}", "# Empty configuration\n", "null", "~"] + ) def test_empty_file_clears_source(self, tmp_path, contents): path = tmp_path / "config.yaml" path.write_text("foo: bar\n") @@ -161,7 +163,16 @@ def test_missing_optional_file_clears_source(self, tmp_path): assert source == {"new": "value"} @pytest.mark.parametrize( - "contents, error", [("foo: [", confuse.ConfigReadError), ("- item", TypeError)] + "contents, error", + [ + ("foo: [", confuse.ConfigReadError), + ("- item", TypeError), + ("[]", TypeError), + ("false", TypeError), + ("0", TypeError), + ("0.0", TypeError), + ('""', TypeError), + ], ) def test_failed_reload_preserves_source(self, tmp_path, contents, error): path = tmp_path / "config.yaml"