diff --git a/confuse/util.py b/confuse/util.py index f8ffc34..564744e 100644 --- a/confuse/util.py +++ b/confuse/util.py @@ -112,7 +112,7 @@ def build_dict( save_to = save_to[child_key] # Save - if key in save_to: + if isinstance(save_to.get(key), dict) and isinstance(result, dict): save_to[key].update(result) else: save_to[key] = result diff --git a/docs/changelog.rst b/docs/changelog.rst index 680346e..71decd4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,9 +13,13 @@ Unreleased New features ~~~~~~~~~~~~ -.. - Bug fixes - ~~~~~~~~~ +Bug fixes +~~~~~~~~~ + +- Fix nested environment and command-line overrides colliding with values in + parent mappings. More-specific values replace scalar, list, or null values; + mappings continue to merge. `#215 + `_ .. For plugin developers diff --git a/test/test_env.py b/test/test_env.py index fe476a8..64cc958 100644 --- a/test/test_env.py +++ b/test/test_env.py @@ -270,3 +270,34 @@ def test_with_sep(self): def test_with_prefix_and_sep(self): self.config.set_env(prefix="MYAPP_", sep="_sep_") assert self.config.get() == {"qux": {"nested": "d"}} + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize( + "original", + ["5432", "localhost", "[5432, 5434]", "null", "{fallback: 5432}"], +) +@pytest.mark.parametrize( + "override, expected", + [ + ("5433", 5433), + ("remote", "remote"), + ("[5433, 5435]", [5433, 5435]), + ("null", None), + ("{primary: 5433}", {"primary": 5433}), + ], +) +def test_yaml_nested_leaf_override(original, override, expected, reverse): + entries = [ + ("TEST_DATABASE", f"{{host: localhost, port: {original}}}"), + ("TEST_DATABASE__PORT", override), + ] + if reverse: + entries.reverse() + environment = dict(entries) + with patch.dict("os.environ", environment, clear=True): + source = confuse.EnvSource("TEST_", parse_yaml_docs=True) + assert dict(os.environ) == environment + if original == "{fallback: 5432}" and isinstance(expected, dict): + expected = {"fallback": 5432, **expected} + assert source["database"] == {"host": "localhost", "port": expected} diff --git a/test/test_utils.py b/test/test_utils.py index 176fcdf..367c70f 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,6 +1,7 @@ import unittest from argparse import Namespace from collections import OrderedDict +from copy import deepcopy from typing import Any import pytest @@ -81,3 +82,50 @@ def test_keep_none_with_nested(self): result = confuse.util.build_dict(config.copy(), keep_none=True) assert None is result["foo"]["bar"] + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("namespace", [False, True]) +@pytest.mark.parametrize("original", [5432, "local", [5432], None, {"fallback": 5432}]) +@pytest.mark.parametrize("override", [5433, "remote", [5433], {"primary": 5433}]) +def test_set_args_nested_leaf_override(original, override, namespace, reverse): + entries = [ + ("database", {"host": "localhost", "port": original}), + ("database.port", override), + ] + if reverse: + entries.reverse() + args: dict[str, Any] | Namespace = dict(entries) + if namespace: + args = Namespace(**args) + before = deepcopy(args) + config = confuse.Configuration("test", read=False) + config.set_args(args, dots=True) + expected = override + if isinstance(original, dict) and isinstance(override, dict): + expected = {**original, **override} + assert config["database"].get() == {"host": "localhost", "port": expected} + assert args == before + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_set_args_none_override_remains_ignored(reverse): + entries = [("database", {"port": 5432}), ("database.port", None)] + if reverse: + entries.reverse() + config = confuse.Configuration("test", read=False) + config.set_args(dict(entries), dots=True) + assert config["database"]["port"].get() == 5432 + + +def test_build_dict_mapping_merge_retains_order_and_is_shallow(): + args: dict[str, Any] = { + "database": {"options": {"first": 1, "nested": {"old": 2}}}, + "database.options": {"nested": {"new": 3}, "last": 4}, + } + result = confuse.util.build_dict(args, sep=".") + assert result == { + "database": {"options": {"first": 1, "nested": {"new": 3}, "last": 4}} + } + assert list(result["database"]["options"]) == ["first", "nested", "last"] + assert args["database"]["options"]["nested"] == {"old": 2}