Repository: dynaconf/dynaconf

Issue:
Inconsistent/undefined behavior in when accessing settings with lower-case names
Hey, I'd like to discuss this behavior I just encountered when I was using a Dynaconf Settings object alongside a regular dict, and trying to use the same code for accessing both of them.  Assuming there is a Dynaconf Settings instance named `settings` that has the parameter `PORT` set to 8080, the following behavior can be observed when accessing the parameter:

1. Using key ("bracket") notation:

    ```python
    >>> settings["PORT"]
    8080 
    
    >>> settings["port"]
    8080
    ```

1. Using .get:

    ```python
    >>> settings.get("PORT")
    8080 
    
    >>> settings.get("port")
    8080
    ```

1. Using attribute ("dot") notation:

    ```python
    >>> settings.PORT  
    8080 
    
    >>> settings.port
    …
    AttributeError: 'Settings' object has no attribute 'port'
    ```

The outlier here is obviously the lower-case dot notation, right? But here is where the behavior actually differs from a dictionary:

```python
>>> "PORT" in settings
True

>>> "port" in settings
False
```

Taking into account _all_ returned results above, the last statement should _also_ respond `True`, because it contradicts the "dict-like" ways to access it (`.get` and key notation) where the lower-case variable is indeed "in settings".

As far as I can see the documentation does not mention this, which is why I'd like to propose defining a consistent behavior regarding upper/lower-casing parameters in Dynaconf. IMHO the behavior should be consistent across all access patterns, and I think Dynaconf should internally be case-insensitive and support both lower-case and upper-case parameter access. The casing should only matter on environment variables (always to be upper-case) where lower-case variables don't generally work.

What do you think?


Discussion:

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -1,5 +1,6 @@
 import glob
 import importlib
+import inspect
 import os
 from contextlib import contextmanager
 from contextlib import suppress
@@ -87,12 +88,12 @@ def __init__(self, **kwargs):
 
         :param kwargs: values that overrides default_settings
         """
-        self.resolve_config_aliases(kwargs)
+        self.__resolve_config_aliases(kwargs)
         compat_kwargs(kwargs)
         self._kwargs = kwargs
         super(LazySettings, self).__init__()
 
-    def resolve_config_aliases(self, kwargs):
+    def __resolve_config_aliases(self, kwargs):
         """takes aliases for _FOR_DYNACONF configurations
 
         e.g: ROOT_PATH='/' is transformed into `ROOT_PATH_FOR_DYNACONF`
@@ -103,10 +104,15 @@ def resolve_config_aliases(self, kwargs):
             if key.endswith("_FOR_DYNACONF")
         }
         aliases = {
-            key for key in kwargs if f"{key}_FOR_DYNACONF" in for_dynaconf_keys
+            key.upper()
+            for key in kwargs
+            if f"{key.upper()}_FOR_DYNACONF" in for_dynaconf_keys
         }
         for alias in aliases:
-            kwargs[f"{alias}_FOR_DYNACONF"] = kwargs.pop(alias)
+            value = kwargs.pop(alias, empty)
+            if value is empty:
+                value = kwargs.pop(alias.lower())
+            kwargs[f"{alias}_FOR_DYNACONF"] = value
 
     @evaluate_lazy_format
     def __getattr__(self, name):
@@ -117,6 +123,15 @@ def __getattr__(self, name):
             raise AttributeError(
                 f"Attribute {name} was deleted, " "or belongs to different env"
             )
+
+        if name not in self.__reserved_attrs:
+            lowercase_mode = self._kwargs.get(
+                "LOWERCASE_READ_FOR_DYNACONF",
+                default_settings.LOWERCASE_READ_FOR_DYNACONF,
+            )
+            if lowercase_mode is True:
+                name = name.upper()
+
         if (
             name.isupper()
             and (
@@ -169,6 +184,38 @@ def configured(self):
         """If wrapped is configured"""
         return self._wrapped is not empty
 
+    @property
+    def __reserved_attrs(self):
+        return (
+            [
+                item[0]
+                for item in inspect.getmembers(LazySettings)
+                if not item[0].startswith("__")
+            ]
+            + [
+                item[0]
+                for item in inspect.getmembers(Settings)
+                if not item[0].startswith("__")
+            ]
+            + [
+                "_kwargs",
+                "_logger",
+                "_fresh",
+                "_loaded_envs",
+                "_loaded_files",
+                "_deleted",
+                "_store",
+                "_env_cache",
+                "_loaded_by_loaders",
+                "_loaders",
+                "_defaults",
+                "environ",
+                "SETTINGS_MODULE",
+                "_not_installed_warnings",
+                "_memoized",
+            ]
+        )
+
 
 class Settings(object):
     """
@@ -231,7 +278,7 @@ def __delattr__(self, name):
 
     def __contains__(self, item):
         "Respond to `item in settings`"
-        return item in self.store
+        return item.upper() in self.store or item.lower() in self.store
 
     def __getitem__(self, item):
         """Allow getting variables as dict keys `settings['KEY']`"""
@@ -1078,3 +1125,12 @@ def populate_obj(self, obj, keys=None, ignore=None):
             value = self.get(key, empty)
             if value is not empty:
                 setattr(obj, key, value)
+
+    def dynaconf(self):  # pragma: no cover
+        """A proxy to access internal methods and attributes
+
+        Starting in 3.0.0 Dynaconf now allows first level lower case
+        keys that are not reserved keyword, so this is a proxy to
+        internal methods and attrs.
+        """
+        return  # TOBE IMPLEMENTED
diff --git a/dynaconf/loaders/base.py b/dynaconf/loaders/base.py
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -63,8 +63,6 @@ def load(self, filename=None, key=None, silent=True):
         else:  # it is already a list/tuple
             files = filename
 
-        self.obj._loaded_files.extend(files)
-
         source_data = self.get_source_date(files)
 
         if self.obj.get("ENVLESS_MODE_FOR_DYNACONF") is True:
@@ -87,6 +85,7 @@ def get_source_date(self, files):
                         ),
                     ) as open_file:
                         content = self.file_reader(open_file)
+                        self.obj._loaded_files.append(source_file)
                         if content:
                             data[source_file] = content
                             self.obj.logger.debug(
diff --git a/example/lower_read/app.py b/example/lower_read/app.py
new file mode 100644
--- /dev/null
+++ b/example/lower_read/app.py
@@ -0,0 +1,12 @@
+from dynaconf import LazySettings
+
+settings = LazySettings(envless_mode=True, lowercase_read=True)
+
+assert settings.server == "foo.com"
+assert settings.SERVER == "foo.com"
+assert settings["SERVER"] == "foo.com"
+assert settings["server"] == "foo.com"
+assert settings("SERVER") == "foo.com"
+assert settings("server") == "foo.com"
+assert settings.get("SERVER") == "foo.com"
+assert settings.get("server") == "foo.com"


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -791,3 +791,27 @@ def test_envless_mode(tmpdir):
     assert settings.HELLO == "world"
     assert settings.DEFAULT == 1
     assert settings.DATABASES.default.port == 8080
+
+
+def test_lowercase_read_mode(tmpdir):
+    data = {
+        "foo": "bar",
+        "hello": "world",
+        "default": 1,
+        "databases": {"default": {"port": 8080}},
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data)
+
+    settings = LazySettings(ENVLESS_MODE=True, lowercase_read=True)
+
+    assert settings.FOO == "bar"
+    assert settings.foo == "bar"
+    assert settings.HELLO == "world"
+    assert settings.hello == "world"
+    assert settings.DEFAULT == 1
+    assert settings.default == 1
+    assert settings.DATABASES.default.port == 8080
+    assert settings.Databases.default.port == 8080
+
+    assert "foo" in settings
+    assert "FOO" in settings