Repository: dynaconf/dynaconf

Issue:
[bug] BoxKeyError: "dynaconf_merge_unique" with specific YAML files
**Describe the bug**
I'm getting `BoxKeyError: "dynaconf_merge_unique"` and `AttributeError: 'int' object has no attribute 'lower'` when I have a particularly nested YAML config file.

**To Reproduce**
Steps to reproduce the behavior:

1. Having the following folder structure
<details>
<summary> Project structure </summary>

```bash
.
├── config.py
├── settings_test.local.yaml
└── settings_test.yaml

1 directory, 3 files
```
</details>

2. Having the following config files:

<details>
<summary> Config files </summary>

`settings_test.yaml`
```bash
default:
  a_mapping:
    1: ["a string"]
```

and

`settings_test.local.yaml`
```bash
default:
  a_value: .inf
```

</details>

3. Having the following app code:

<details>
<summary> Code </summary>

**config.py**
```python
from pathlib import Path

from dynaconf import Dynaconf

settings = Dynaconf(
    settings_files=[Path(__file__).parent / "settings_test.yaml"],
    merge_enabled=True,
)

print(settings.as_dict())
```
</details>

4. Executing under the following environment

<details>
<summary> Execution </summary>

```bash
$ python config.py
```
</details>

**Expected behavior**
The program should print a `dict` with the settings.

**Environment (please complete the following information):**
 - OS: MacOS 13.5.2 (22G91). Mac M1.
 - Dynaconf Version: 3.2.2
 - Frameworks in use: Nothing.

**Additional context**
**Works in Dynaconf 3.2.1**.

<details>
<summary> Full traceback: </summary>

```python
Traceback (most recent call last):
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/boxing.py", line 49, in __getitem__
    return super().__getitem__(item, *args, **kwargs)
  File "/Users/nix/Dev/dynaconf/dynaconf/vendor/box/box.py", line 341, in __getitem__
    raise BoxKeyError(str(err)) from None
dynaconf.vendor.box.exceptions.BoxKeyError: "'dynaconf_merge_unique'"

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "config.py", line 10, in <module>
    print(settings.as_dict())
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 122, in __getattr__
    self._setup()
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 183, in _setup
    self._wrapped = self._wrapper_class(
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 271, in __init__
    self.execute_loaders()
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 1160, in execute_loaders
    settings_loader(
  File "/Users/nix/Dev/dynaconf/dynaconf/loaders/__init__.py", line 284, in settings_loader
    loader["loader"].load(
  File "/Users/nix/Dev/dynaconf/dynaconf/loaders/yaml_loader.py", line 96, in load
    loader.load(
  File "/Users/nix/Dev/dynaconf/dynaconf/loaders/base.py", line 83, in load
    self._envless_load(source_data, silent, key)
  File "/Users/nix/Dev/dynaconf/dynaconf/loaders/base.py", line 124, in _envless_load
    self._set_data_to_obj(
  File "/Users/nix/Dev/dynaconf/dynaconf/loaders/base.py", line 210, in _set_data_to_obj
    self.obj.update(
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 1060, in update
    self.set(
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 994, in set
    parsed, source_metadata = self._merge_before_set(
  File "/Users/nix/Dev/dynaconf/dynaconf/base.py", line 1103, in _merge_before_set
    value = object_merge(existing, value)
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/__init__.py", line 104, in object_merge
    handle_metavalues(old, new)
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/__init__.py", line 169, in handle_metavalues
    "dynaconf_merge", new[key].pop("dynaconf_merge_unique", None)
  File "/Users/nix/Dev/dynaconf/dynaconf/vendor/box/box.py", line 430, in pop
    item = self[key]
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/boxing.py", line 21, in evaluate
    value = f(dynabox, item, *args, **kwargs)
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/boxing.py", line 51, in __getitem__
    n_item = find_the_correct_casing(item, self) or item
  File "/Users/nix/Dev/dynaconf/dynaconf/utils/__init__.py", line 476, in find_the_correct_casing
    if k.lower() == key.lower():
AttributeError: 'int' object has no attribute 'lower'
```

</details>

Edit: was able to reproduce without the "exclude" key, so I changed the example.
Edit 2: was able to reproduce with an even simple example. Maybe it's expected?


Discussion:
Did a bisect, looks like 9ca687a4e6df9d67cf060a1fe092a782a256ec24 introduced the issue.

If you have an idea of where to start looking (the commit is quite big), let me know and I can look into this.
Hey @sebastian-correa,
Not sure about `merge_unique` exception, but the `int` exception is probably related to [this](https://github.com/dynaconf/dynaconf/commit/9ca687a4e6df9d67cf060a1fe092a782a256ec24#diff-81216dc760c0d7472d00bab2b35a37d32985b9b0e07ffa9877ed2bbb664373c1R410) (see #969 for context). TLDR, we should allow arbitrary types in setting keys (the fix should keep that).

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -476,23 +476,24 @@ def get(
             sysenv_fallback = self._store.get("SYSENV_FALLBACK_FOR_DYNACONF")
 
         nested_sep = self._store.get("NESTED_SEPARATOR_FOR_DYNACONF")
-        if nested_sep and nested_sep in key:
-            # turn FOO__bar__ZAZ in `FOO.bar.ZAZ`
-            key = key.replace(nested_sep, ".")
-
-        if dotted_lookup is empty:
-            dotted_lookup = self._store.get("DOTTED_LOOKUP_FOR_DYNACONF")
-
-        if "." in key and dotted_lookup:
-            return self._dotted_get(
-                dotted_key=key,
-                default=default,
-                cast=cast,
-                fresh=fresh,
-                parent=parent,
-            )
+        if isinstance(key, str):
+            if nested_sep and nested_sep in key:
+                # turn FOO__bar__ZAZ in `FOO.bar.ZAZ`
+                key = key.replace(nested_sep, ".")
+
+            if dotted_lookup is empty:
+                dotted_lookup = self._store.get("DOTTED_LOOKUP_FOR_DYNACONF")
+
+            if "." in key and dotted_lookup:
+                return self._dotted_get(
+                    dotted_key=key,
+                    default=default,
+                    cast=cast,
+                    fresh=fresh,
+                    parent=parent,
+                )
 
-        key = upperfy(key)
+            key = upperfy(key)
 
         # handles system environment fallback
         if default is None:
@@ -913,7 +914,7 @@ def set(
     ):
         """Set a value storing references for the loader
 
-        :param key: The key to store
+        :param key: The key to store. Can be of any type.
         :param value: The raw value to parse and store
         :param loader_identifier: Optional loader name e.g: toml, yaml etc.
                                   Or isntance of SourceMetadata
@@ -938,20 +939,21 @@ def set(
         if dotted_lookup is empty:
             dotted_lookup = self.get("DOTTED_LOOKUP_FOR_DYNACONF")
         nested_sep = self.get("NESTED_SEPARATOR_FOR_DYNACONF")
-        if nested_sep and nested_sep in key:
-            key = key.replace(nested_sep, ".")  # FOO__bar -> FOO.bar
-
-        if "." in key and dotted_lookup is True:
-            return self._dotted_set(
-                key,
-                value,
-                loader_identifier=source_metadata,
-                tomlfy=tomlfy,
-                validate=validate,
-            )
+        if isinstance(key, str):
+            if nested_sep and nested_sep in key:
+                key = key.replace(nested_sep, ".")  # FOO__bar -> FOO.bar
+
+            if "." in key and dotted_lookup is True:
+                return self._dotted_set(
+                    key,
+                    value,
+                    loader_identifier=source_metadata,
+                    tomlfy=tomlfy,
+                    validate=validate,
+                )
+            key = upperfy(key.strip())
 
         parsed = parse_conf_data(value, tomlfy=tomlfy, box_settings=self)
-        key = upperfy(key.strip())
 
         # Fix for #869 - The call to getattr trigger early evaluation
         existing = (
@@ -1001,7 +1003,12 @@ def set(
         # Set the parsed value
         self.store[key] = parsed
         self._deleted.discard(key)
-        super().__setattr__(key, parsed)
+
+        # check if str because we can't directly set/get non-str with obj. e.g.
+        #     setting.1
+        #     settings.(1,2)
+        if isinstance(key, str):
+            super().__setattr__(key, parsed)
 
         # Track history for inspect, store the raw_value
         if source_metadata in self._loaded_by_loaders:
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -461,7 +461,9 @@ def isnamedtupleinstance(value):
 
 
 def find_the_correct_casing(key: str, data: dict[str, Any]) -> str | None:
-    """Given a key, find the proper casing in data
+    """Given a key, find the proper casing in data.
+
+    Return 'None' for non-str key types.
 
     Arguments:
         key {str} -- A key to be searched in data
@@ -473,6 +475,8 @@ def find_the_correct_casing(key: str, data: dict[str, Any]) -> str | None:
     if not isinstance(key, str) or key in data:
         return key
     for k in data.keys():
+        if not isinstance(k, str):
+            return None
         if k.lower() == key.lower():
             return k
         if k.replace(" ", "_").lower() == key.lower():


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -513,6 +513,26 @@ def test_set_new_merge_issue_241_5(tmpdir):
     }
 
 
+def test_set_with_non_str_types():
+    """This replicates issue #1005 in a simplified setup."""
+    settings = Dynaconf(merge_enabled=True)
+    settings.set("a", {"b": {1: "foo"}})
+    settings.set("a", {"b": {"c": "bar"}})
+    assert settings["a"]["b"][1] == "foo"
+    assert settings["a"]["b"]["c"] == "bar"
+
+
+def test_set_with_non_str_types_on_first_level():
+    """Non-str key types on first level."""
+    settings = Dynaconf(merge_enabled=True)
+    settings.set(1, {"b": {1: "foo"}})
+    settings.set("a", {"1": {1: "foo"}})
+    assert settings[1]["b"][1] == "foo"
+    assert settings[1].b[1] == "foo"
+    assert settings.get(1).b[1] == "foo"
+    assert settings["a"]["1"][1] == "foo"
+
+
 def test_exists(settings):
     settings.set("BOOK", "TAOCP")
     assert settings.exists("BOOK") is True
diff --git a/tests_functional/issues/1005-key-type-error/Makefile b/tests_functional/issues/1005-key-type-error/Makefile
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/1005-key-type-error/Makefile
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+	pytest -v app_test.py
diff --git a/tests_functional/issues/1005-key-type-error/app_test.py b/tests_functional/issues/1005-key-type-error/app_test.py
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/1005-key-type-error/app_test.py
@@ -0,0 +1,37 @@
+"""
+https://github.com/dynaconf/dynaconf/issues/1005
+"""
+from __future__ import annotations
+
+from textwrap import dedent
+
+from dynaconf import Dynaconf
+
+
+def create_file(fn: str, data: str):
+    with open(fn, "w") as file:
+        file.write(dedent(data))
+    return fn
+
+
+def test_issue_1005(tmp_path):
+    file_a = create_file(
+        tmp_path / "t.yaml",
+        """\
+        default:
+          a_mapping:
+            1: ["a_string"]
+        """,
+    )
+    create_file(
+        tmp_path / "t.local.yaml",
+        """\
+        default:
+          a_value: .inf
+        """,
+    )
+
+    settings = Dynaconf(settings_file=file_a, merge_enabled=True)
+
+    assert settings.as_dict()["DEFAULT"]["a_mapping"]
+    assert settings.as_dict()["DEFAULT"]["a_value"]