diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py index e55ec14ad..a77caa8bf 100644 --- a/dynaconf/utils/__init__.py +++ b/dynaconf/utils/__init__.py @@ -55,23 +55,33 @@ def object_merge( existing_value = recursive_get(old, full_path) # doesnt handle None # Need to make every `None` on `_store` to be an wrapped `LazyNone` - for key, value in old.items(): - + # data coming from source, in `new` can be mix case: KEY4|key4|Key4 + # data existing on `old` object has the correct case: key4|KEY4|Key4 + # So we need to ensure that new keys matches the existing keys + for new_key in list(new.keys()): + correct_case_key = find_the_correct_casing(new_key, old) + if correct_case_key: + new[correct_case_key] = new.pop(new_key) + + for old_key, value in old.items(): + + # This is for when the dict exists internally + # but the new value on the end of full path is the same if ( existing_value is not None - and key.lower() == full_path[-1].lower() + and old_key.lower() == full_path[-1].lower() and existing_value is value ): # Here Be The Dragons # This comparison needs to be smarter continue - if key not in new: - new[key] = value + if old_key not in new: + new[old_key] = value else: object_merge( value, - new[key], + new[old_key], full_path=full_path[1:] if full_path else None, ) @@ -89,7 +99,7 @@ def recursive_get( """ if not names: return - head, tail = names[0], names[1:] + head, *tail = names result = getattr(obj, head, None) if not tail: return result @@ -106,7 +116,6 @@ def handle_metavalues( # MetaValue instances if getattr(new[key], "_dynaconf_reset", False): # pragma: no cover # a Reset on `new` triggers reasign of existing data - # @reset is deprecated on v3.0.0 new[key] = new[key].unwrap() elif getattr(new[key], "_dynaconf_del", False): # a Del on `new` triggers deletion of existing data @@ -424,3 +433,21 @@ def isnamedtupleinstance(value): if not isinstance(f, tuple): return False return all(type(n) == str for n in f) + + +def find_the_correct_casing(key: str, data: dict[str, Any]) -> str | None: + """Given a key, find the proper casing in data + + Arguments: + key {str} -- A key to be searched in data + data {dict} -- A dict to be searched + + Returns: + str -- The proper casing of the key in data + """ + if key in data: + return key + for k in data.keys(): + if k.lower() == key.lower(): + return k + return None diff --git a/dynaconf/utils/boxing.py b/dynaconf/utils/boxing.py index 0ae93b8ee..ff78f1246 100644 --- a/dynaconf/utils/boxing.py +++ b/dynaconf/utils/boxing.py @@ -3,8 +3,8 @@ import inspect from functools import wraps +from dynaconf.utils import find_the_correct_casing from dynaconf.utils import recursively_evaluate_lazy_format -from dynaconf.utils import upperfy from dynaconf.utils.functional import empty from dynaconf.vendor.box import Box @@ -37,7 +37,7 @@ def __getattr__(self, item, *args, **kwargs): try: return super().__getattr__(item, *args, **kwargs) except (AttributeError, KeyError): - n_item = item.lower() if item.isupper() else upperfy(item) + n_item = find_the_correct_casing(item, self) or item return super().__getattr__(n_item, *args, **kwargs) @evaluate_lazy_format @@ -45,7 +45,7 @@ def __getitem__(self, item, *args, **kwargs): try: return super().__getitem__(item, *args, **kwargs) except (AttributeError, KeyError): - n_item = item.lower() if item.isupper() else upperfy(item) + n_item = find_the_correct_casing(item, self) or item return super().__getitem__(n_item, *args, **kwargs) def __copy__(self): @@ -60,22 +60,11 @@ def copy(self): box_settings=self._box_config.get("box_settings"), ) - def _case_insensitive_get(self, item, default=None): - """adds a bit of overhead but allows case insensitive get - See issue: #486 - """ - lower_self = {k.casefold(): v for k, v in self.items()} - return lower_self.get(item.casefold(), default) - @evaluate_lazy_format def get(self, item, default=None, *args, **kwargs): - if item not in self: # toggle case - item = item.lower() if item.isupper() else upperfy(item) - value = super().get(item, empty, *args, **kwargs) - if value is empty: - # see Issue: #486 - return self._case_insensitive_get(item, default) - return value + n_item = find_the_correct_casing(item, self) or item + value = super().get(n_item, empty, *args, **kwargs) + return value if value is not empty else default def __dir__(self): keys = list(self.keys()) diff --git a/tests/test_yaml_loader.py b/tests/test_yaml_loader.py index 62ccd0529..221b4a37d 100644 --- a/tests/test_yaml_loader.py +++ b/tests/test_yaml_loader.py @@ -493,3 +493,44 @@ def test_should_NOT_duplicate_when_explicit_set(tmpdir): "script1.sh", # merge_unique does not duplicate, but overrides the order ] + + +def test_empty_yaml_key_overriding(tmpdir): + new_key_value = "new_key_value" + os.environ["DYNACONF_LEVEL1__KEY"] = new_key_value + os.environ["DYNACONF_LEVEL1__KEY2"] = new_key_value + os.environ["DYNACONF_LEVEL1__key3"] = new_key_value + os.environ["DYNACONF_LEVEL1__KEY4"] = new_key_value + os.environ["DYNACONF_LEVEL1__KEY5"] = new_key_value + + tmpdir.join("test.yml").write( + """ + level1: + key: key_value + KEY2: + key3: + keY4: + """ + ) + + for merge_state in (True, False): + _settings = LazySettings( + settings_files=["test.yml"], merge_enabled=merge_state + ) + assert _settings.level1.key == new_key_value + assert _settings.level1.key2 == new_key_value + assert _settings.level1.key3 == new_key_value + assert _settings.level1.get("KEY4") == new_key_value + assert _settings.level1.get("key4") == new_key_value + assert _settings.level1.get("keY4") == new_key_value + assert _settings.level1.get("keY6", "foo") == "foo" + assert _settings.level1.get("KEY6", "bar") == "bar" + assert _settings.level1["Key4"] == new_key_value + assert _settings.level1.Key4 == new_key_value + assert _settings.level1.KEy4 == new_key_value + assert _settings.level1.KEY4 == new_key_value + assert _settings.level1.key4 == new_key_value + with pytest.raises(AttributeError): + _settings.level1.key6 + _settings.level1.key7 + _settings.level1.KEY8