Repository: dynaconf/dynaconf

Issue:
Env vars are case sensitive (overriding only uppercase params if written in uppercase)  when merge_enabled=True
Let's say i have very simple yaml config with envs:
```yaml
default:
  my_section:
    my_param: "default settings content"
development:
  my_section:
    my_param: "development settings content"
```
And i want to override `my_param` with env variable at some point. What's proper env var name for such case? `DYNACONF_MY_SECTION__MY_PARAM` works if `merge_enabled=False`.
However, if `merge_enabled=True`, `DYNACONF_MY_SECTION__MY_PARAM` will set my_section.MY_PARAM, not my_section.my_param.
Easy reproducer:
```python
import os
import dynaconf


os.environ["DYNACONF_MY_SECTION__MY_PARAM"] = "env var content"

settings = dynaconf.LazySettings(
	environments=True,
	env="development",
	settings_files=["settings.yaml"],
	merge_enabled=True,
)

print(settings.my_section.my_param)
print(settings.my_section.MY_PARAM)

```
Running the code returns the following output:
```
development settings content
env var content
```
setting `merge_enabled=False` and re-running the code returns expected output:
```
env var content
env var content
```
Documentation is kinda confusing about this behavior, so i'm not 100% sure it's a bug:
On the one hand, https://www.dynaconf.com/envvars/ examples are showing uppercase env vars are correctly recognized:
```
export DYNACONF_PERSON__IS_ADMIN=true
assert settings.person.is_admin is True
```
And it works like that with `merge_enabled=False`
On the other -  there's the following line:
```
Also variable access is case insensitive for the first level key
```
And there's https://www.dynaconf.com/configuration/#lowercase_read , so it looks like only the first level keys are designed to be case insensitive.

I'm using dynaconf 3.1.7, OS is macOS 12.2.1 if that matters.


Discussion:
@abalakh this is a bug, env vars are case insensitive only on windows because on windows every env var is forced to be upper case, on linux it should have worked

@abalakh does it work if you set

```bash
DYNACONF_MY_SECTION__my_param=othervalue
```

??

^that must work, however with all upper must also work if the key already exists in the settings.
@rochacbruno yes, with lowercase everything works. That's what i'm currently using as workaround.
@rochacbruno There is a similar issue. An empty lowercase YAML key value isn't overridden by an uppercase environment variable even when `merge_enabled` is false:
```
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

    tmpdir.join("test.yml").write(
        """
        level1:
          key: key_value
          KEY2:
          key3:
          key4:
        """
    )

    _settings = LazySettings(settings_files=['test.yml'], merge_enabled=False)

    # AssertionError: assert 'key_value' == 'new_key_value' if merge_enabled=True
    assert _settings.level1.key == new_key_value  # OK
    assert _settings.level1.key2 == new_key_value  # OK
    assert _settings.level1.key3 == new_key_value  # OK
    assert _settings.level1.key4 == new_key_value  # AssertionError: assert None == 'new_key_value'
```
Dynaconf is from the master branch (3.1.10.dev0)
@bartolootrit I found the problem you described and it has to do with how dictionaries are ordered.

The env_loader is getting data as:

```python
{'LEVEL1': {'KEY4': 'new_key_value', 'key': 'key_value', 'KEY2': None, 'key3': None, 'key4': None}}
```

And as dicts considers only latest added elements the None is there, I am thinking on a way to solve it.

Resolving patch:
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
--- 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
--- 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())


Test patch:
diff --git a/tests/test_yaml_loader.py b/tests/test_yaml_loader.py
--- 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