Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New data key casing must adapt to existing key casing #795

Merged
merged 2 commits into from Sep 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
43 changes: 35 additions & 8 deletions dynaconf/utils/__init__.py
Expand Up @@ -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,
)

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
23 changes: 6 additions & 17 deletions dynaconf/utils/boxing.py
Expand Up @@ -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

Expand Down Expand Up @@ -37,15 +37,15 @@ 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
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):
Expand All @@ -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())
Expand Down
41 changes: 41 additions & 0 deletions tests/test_yaml_loader.py
Expand Up @@ -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