Repository: rochacbruno/dynaconf (python)

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?

Pull request:
Fix #272 allow lowercase keys
Fix #272 allow access of lowercase keys

- `settings.lowercase_key` is allowed
- `settings.dynaconf` is a proxy to internal methods
- `settings.__reserved_attributes` validates key names
- `LazySettings __init__ parameters can receive lower case configs`

Patch:
diff --git a/Makefile b/Makefile
index 8adef78..dc36a12 100644
--- a/Makefile
+++ b/Makefile
@@ -39,6 +39,7 @@ test_examples:
 	cd example/toml_with_secrets/;pwd;python program.py
 	cd example/envs;pwd;python app.py
 	cd example/envless_mode;pwd;python app.py
+	cd example/lower_read;pwd;python app.py
 	cd example/custom_loader;pwd;python app.py
 	cd example/get_fresh;pwd;python app.py
 	cd example/includes;pwd;python app.py
diff --git a/docs/guides/advanced_usage.md b/docs/guides/advanced_usage.md
index 69b6a26..6e71d47 100644
--- a/docs/guides/advanced_usage.md
+++ b/docs/guides/advanced_usage.md
@@ -17,7 +17,7 @@ Now you want to use Dynaconf, open that `conf.py` or `conf/__init__.py` and do:
 # coding: utf-8
 from dynaconf import LazySettings
 
-config = LazySettings(ENVVAR_PREFIX_FOR_DYNACONF="MYPROGRAM")
+config = LazySettings(envvar_prefix="MYPROGRAM")
 ```
 
 Now you can use `export MYPROGRAM_FOO=bar` instead of `DYNACONF_FOO=bar`
@@ -250,9 +250,9 @@ The environment variables wins precedence over all!
 
 # load dynaconf
 settings = LazySettings(
-    ENVVAR_PREFIX_FOR_DYNACONF=ENVVAR_PREFIX_FOR_DYNACONF,
-    ENVVAR_FOR_DYNACONF=ENVVAR_FOR_DYNACONF,
-    ENV_SWITCHER_FOR_DYNACONF=ENV_SWITCHER_FOR_DYNACONF
+    envvar_prefix=ENVVAR_PREFIX_FOR_DYNACONF,
+    envvar=ENVVAR_FOR_DYNACONF,
+    env_switcher=ENV_SWITCHER_FOR_DYNACONF
 )
 ```
 
@@ -288,9 +288,9 @@ Useful for plugin based apps.
 from dynaconf import LazySettings
 
 settings = LazySettings(
-  PRELOAD_FOR_DYNACONF=["/path/*", "other/settings.toml"],                # <-- Loaded first
-  SETTINGS_FILE_FOR_DYNACONF="/etc/foo/settings.py",                      # <-- Loaded second (the main file)
-  INCLUDES_FOR_DYNACONF=["other.module.settings", "other/settings.yaml"]  # <-- Loaded at the end
+  preload=["/path/*", "other/settings.toml"],                # <-- Loaded first
+  settings_file="/etc/foo/settings.py",                      # <-- Loaded second (the main file)
+  includes=["other.module.settings", "other/settings.yaml"]  # <-- Loaded at the end
 )
 
 ```
diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md
index 425fc02..816c071 100644
--- a/docs/guides/configuration.md
+++ b/docs/guides/configuration.md
@@ -14,9 +14,9 @@ Or when using your own Dynaconf instance you can pass as parameters directly:
 ```py
 from dynaconf import LazySettings
 settings = LazySettings(
-    DEBUG_LEVEL_FOR_DYNACONF='DEBUG',
-    ENVVAR_PREFIX_FOR_DYNACONF='MYPROGRAM',
-    ENVVAR_FOR_DYNACONF='MYPROGRAM_SETTINGS',
+    debug_level='DEBUG',
+    envvar_prefix='MYPROGRAM',
+    envvar='MYPROGRAM_SETTINGS',
 )
 ```
 
@@ -42,13 +42,14 @@ It can also be passed as parameters to extensions like `FlaskDynaconf` or set in
     ENV | str | Working environment. | “development” | ENV_FOR_DYNACONF=production
     *FORCE_ENV | str | Force the working environment | None | FORCE_ENV_FOR_DYNACONF=other
     ENV_SWITCHER | str | Variable used to change working env. | ENV_FOR_DYNACONF | ENV_SWITCHER_FOR_DYNACONF=MYPROGRAM_ENV
-    ENVLESS_MODE | str | Ignore env layering and load everything from file. | ENVLESS_MODE | ENVLESS_MODE_FOR_DYNACONF=true
+    ENVLESS_MODE | bool | Ignore env layering and load everything from file. | False | ENVLESS_MODE_FOR_DYNACONF=true
     ENVVAR | str | The envvar which holds the list of settings files. | ‘SETTINGS_FILE_FOR_DYNACONF’ | ENVVAR_FOR_DYNACONF=MYPROGRAM_SETTINGS
     ENVVAR_PREFIX | str | Prefix for exporting parameters as env vars. Example: If your program is called *MYPROGRAM* you may want users to use *MYPROGRAM_FOO=bar* instead of *DYNACONF_FOO=bar* on envvars. | “DYNACONF” | ENVVAR_PREFIX_FOR_DYNACONF=MYPROGRAM (loads MYPROGRAM_VAR) ENVVAR_PREFIX_FOR_DYNACONF=’’ (loads _VAR) ENVVAR_PREFIX_FOR_DYNACONF=false (loads VAR)
     FRESH_VARS | list | A list of vars to be re-loaded on every access. | [] | FRESH_VARS_FOR_DYNACONF=[“HOST”, “PORT”]
     INCLUDES | list | A list of paths or a glob to load can be a toml-like list, or sep by , or ; | [] | INCLUDES_FOR_DYNACONF=”[‘path1.ext’, ‘folder/*’]” INCLUDES_FOR_DYNACONF=”path1.toml;path2.toml” INCLUDES_FOR_DYNACONF=”path1.toml,path2.toml” INCLUDES_FOR_DYNACONF=”single_path.toml” INCLUDES_FOR_DYNACONF=”single_path/glob/.toml”
     INSTANCE **used only by** *$dynaconf** *cli*. | str | Custom instance of LazySettings Must be an importable Python module. | None | INSTANCE_FOR_DYNACONF=myapp.settings
     LOADERS | list | A list of enabled external loaders. |	[‘dynaconf.loaders.env_loader’] | LOADERS_FOR_DYNACONF=’[‘module.mycustomloader’, …]’
+    LOWERCASE_READ | bool | If True first level keys can be accessed via lower case attrs |	False | LOWERCASE_READ_FOR_DYNACONF=true
     MERGE_ENABLED | bool | A bool to activate the global merge feature | False | MERGE_ENABLED_FOR_DYNACONF=1
     NESTED_SEPARATOR | str | Separator for nested assignment like `export DYNACONF_DATABASES__NAME='foo'` | `__` double underline | NESTED_SEPARATOR_FOR_DYNACONF='___'
     PRELOAD | list | A list of paths or glob to be pre-loaded before main settings file. | [] | PRELOAD_FOR_DYNACONF="['path1.ext', 'folder/*']"
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 9e88156..433a536 100644
--- 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 @@ class LazySettings(LazyObject):
 
         :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 @@ class LazySettings(LazyObject):
             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 @@ class LazySettings(LazyObject):
             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 @@ class LazySettings(LazyObject):
         """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 @@ class Settings(object):
 
     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 @@ class Settings(object):
             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
index 68d4c1c..613b674 100644
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -63,8 +63,6 @@ class BaseLoader(object):
         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 @@ class BaseLoader(object):
                         ),
                     ) 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/.env b/example/lower_read/.env
new file mode 100644
index 0000000..e69de29
diff --git a/example/lower_read/app.py b/example/lower_read/app.py
new file mode 100644
index 0000000..7e27798
--- /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"
diff --git a/example/lower_read/settings.yaml b/example/lower_read/settings.yaml
new file mode 100644
index 0000000..0e086f3
--- /dev/null
+++ b/example/lower_read/settings.yaml
@@ -0,0 +1,2 @@
+server: foo.com
+port: 8080


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index e57d356..f550127 100644
--- 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