Repository: rochacbruno/dynaconf
Pull request #219 (2019-09-03 22:59:22)

Adds `from_env` method and change `_store` to be a `DynaBox`
Ref #218 
`settings._store` is now a `DynaBox` and that allows first level keys to be lower case.

Ref #211 
`settings` now has a `from_env` method to allow switching branches without side effects.

## Switching working environments

You can switch between existing environments using:

- `from_env`: (**recommended**) Will create a new settings instance pointing to defined env.
- `setenv`: Will set the existing instance to defined env.
- `using_env`: Context manager that will have defined env only inside its scope.

### from_env

> **New in 2.0.5**

Return a new isolated settings object pointing to specified env.

Example of settings.toml::

```ini
[development]
message = 'This is in dev'
foo = 1
[other]
message = 'this is in other env'
bar = 2
```

Program::

```py
>>> from dynaconf import settings
>>> print(settings.MESSAGE)
'This is in dev'
>>> print(settings.FOO)
1
>>> print(settings.BAR)
AttributeError: settings object has no attribute 'BAR'
```

Then you can use `from_env`:

```py
>>> print(settings.from_env('other').MESSAGE)
'This is in other env'
>>> print(settings.from_env('other').BAR)
2
>>> print(settings.from_env('other').FOO)
AttributeError: settings object has no attribute 'FOO'
```

The existing `settings` object remains the same.

```py
>>> print(settings.MESSAGE)
'This is in dev'
```

You can assign new settings objects to different `envs` like:

```py
development_settings = settings.from_env('development')
other_settings = settings.from_env('other')
```

And you can choose if the variables from different `envs` will be chained and overridden in a sequence:

```py
all_settings = settings.from_env('development', keep=True).from_env('other', keep=True)

>>> print(all_settings.MESSAGE)
'This is in other env'
>>> print(all_settings.FOO)
1
>>> print(all_settings.BAR)
2
```

The variables from [development] are loaded keeping pre-loaded values, then the variables from [other] are loaded keeping pre-loaded from [development] and overriding it.

### setenv

Will change `in_place` the `env` for the existing object.

```python
from dynaconf import settings

settings.setenv('other')
# now values comes from [other] section of config
assert settings.MESSAGE == 'This is in other env'

settings.setenv()
# now working env are back to previous
```

### using_env

Using context manager

```python
from dynaconf import settings

with settings.using_env('other'):
    # now values comes from [other] section of config
    assert settings.MESSAGE == 'This is in other env'

# existing settings back to normal after the context manager scope
assert settings.MESSAGE == 'This is in dev'
```

codecov[bot]: # [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=h1) Report
> Merging [#219](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=desc) into [master](https://codecov.io/gh/rochacbruno/dynaconf/commit/329aadf9a89c5065b4bfe6eb278140081e66bc6c?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/rochacbruno/dynaconf/pull/219/graphs/tree.svg?width=650&token=GXszVzJcqW&height=150&src=pr)](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #219   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          18     18           
  Lines         764    763    -1     
=====================================
- Hits          764    763    -1
```


| [Impacted Files](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [dynaconf/contrib/django\_dynaconf\_v2.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/219/diff?src=pr&el=tree#diff-ZHluYWNvbmYvY29udHJpYi9kamFuZ29fZHluYWNvbmZfdjIucHk=) | `100% <ø> (ø)` | :arrow_up: |
| [dynaconf/cli.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/219/diff?src=pr&el=tree#diff-ZHluYWNvbmYvY2xpLnB5) | `100% <ø> (ø)` | :arrow_up: |
| [dynaconf/utils/\_\_init\_\_.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/219/diff?src=pr&el=tree#diff-ZHluYWNvbmYvdXRpbHMvX19pbml0X18ucHk=) | `100% <ø> (ø)` | :arrow_up: |
| [dynaconf/loaders/ini\_loader.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/219/diff?src=pr&el=tree#diff-ZHluYWNvbmYvbG9hZGVycy9pbmlfbG9hZGVyLnB5) | `100% <ø> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=footer). Last update [329aadf...2d22e44](https://codecov.io/gh/rochacbruno/dynaconf/pull/219?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

rochacbruno: @austinnichols101
rochacbruno: @kedark3 I guess this `from_env` will be useful for your use case.

Patch:
diff --git a/docs/guides/advanced_usage.md b/docs/guides/advanced_usage.md
index eeaf6ca..277e04a 100644
--- a/docs/guides/advanced_usage.md
+++ b/docs/guides/advanced_usage.md
@@ -24,31 +24,119 @@ Now you can use `export MYPROGRAM_FOO=bar` instead of `DYNACONF_FOO=bar`
 
 ## Switching working environments
 
-To switch the `environment` programatically you can use `setenv` or `using_env`.
+You can switch between existing environments using:
 
-Using context manager
+- `from_env`: (**recommended**) Will create a new settings instance pointing to defined env.
+- `setenv`: Will set the existing instance to defined env.
+- `using_env`: Context manager that will have defined env only inside its scope.
 
-```python
-from dynaconf import settings
+### from_env
+
+> **New in 2.0.5**
+
+Return a new isolated settings object pointing to specified env.
+
+Example of settings.toml::
+
+```ini
+[development]
+message = 'This is in dev'
+foo = 1
+[other]
+message = 'this is in other env'
+bar = 2
+```
+
+Program::
+
+```py
+>>> from dynaconf import settings
+>>> print(settings.MESSAGE)
+'This is in dev'
+>>> print(settings.FOO)
+1
+>>> print(settings.BAR)
+AttributeError: settings object has no attribute 'BAR'
+```
+
+Then you can use `from_env`:
+
+```py
+>>> print(settings.from_env('other').MESSAGE)
+'This is in other env'
+>>> print(settings.from_env('other').BAR)
+2
+>>> print(settings.from_env('other').FOO)
+AttributeError: settings object has no attribute 'FOO'
+```
+
+The existing `settings` object remains the same.
+
+```py
+>>> print(settings.MESSAGE)
+'This is in dev'
+```
+
+You can assign new settings objects to different `envs` like:
 
-with settings.using_env('envname'):
-    # now values comes from [envmane] section of config
-    assert settings.HOST == 'host.com
+```py
+development_settings = settings.from_env('development')
+other_settings = settings.from_env('other')
+```
+
+And you can choose if the variables from different `envs` will be chained and overridden in a sequence:
+
+```py
+all_settings = settings.from_env('development', keep=True).from_env('other', keep=True)
+
+>>> print(all_settings.MESSAGE)
+'This is in other env'
+>>> print(all_settings.FOO)
+1
+>>> print(all_settings.BAR)
+2
 ```
 
-Using env setter
+The variables from [development] are loaded keeping pre-loaded values, then the variables from [other] are loaded keeping pre-loaded from [development] and overriding it.
+
+It is also possible to pass additional [configuration](configuration.html) variables to `from_env` method.
+
+```py
+new_settings = settings.from_env('production', keep=True, SETTINGS_FILE_FOR_DYNACONF='another_file_path.yaml')
+```
+
+Then the `new_settings` will inherit all the variables from existing env and also load the `another_file_path.yaml` production env.
+
+### setenv
+
+Will change `in_place` the `env` for the existing object.
 
 ```python
 from dynaconf import settings
 
-settings.setenv('envname')
-# now values comes from [envmane] section of config
-assert settings.HOST == 'host.com'
+settings.setenv('other')
+# now values comes from [other] section of config
+assert settings.MESSAGE == 'This is in other env'
 
 settings.setenv()
 # now working env are back to previous
 ```
 
+### using_env
+
+Using context manager
+
+```python
+from dynaconf import settings
+
+with settings.using_env('other'):
+    # now values comes from [other] section of config
+    assert settings.MESSAGE == 'This is in other env'
+
+# existing settings back to normal after the context manager scope
+assert settings.MESSAGE == 'This is in dev'
+```
+
 ## Populating objects
 
 > **New in 2.0.0**
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 0e66c59..861e269 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -18,6 +18,7 @@ from dynaconf.utils import ensure_a_list
 from dynaconf.utils import missing
 from dynaconf.utils import object_merge
 from dynaconf.utils import raw_logger
+from dynaconf.utils import RENAMED_VARS
 from dynaconf.utils.boxing import DynaBox
 from dynaconf.utils.files import find_file
 from dynaconf.utils.functional import empty
@@ -168,7 +169,8 @@ class Settings(object):
         self._loaded_envs = []
         self._loaded_files = []
         self._deleted = set()
-        self._store = {}
+        self._store = DynaBox(box_it_up=True)
+        self._env_cache = {}
         self._loaded_by_loaders = {}
         self._loaders = []
         self._defaults = {}
@@ -244,13 +246,15 @@ class Settings(object):
         """
         ctx_mgr = suppress() if env is None else self.using_env(env)
         with ctx_mgr:
-            data = self.store.copy()
+            data = self.store.to_dict().copy()
             # if not internal remove internal settings
             if not internal:
                 for name in dir(default_settings):
                     data.pop(name, None)
             return data
 
+    to_dict = as_dict  # backwards compatibility
+
     def _dotted_get(self, dotted_key, default=None, **kwargs):
         """
         Perform dotted key lookups and keep track of where we are.
@@ -285,13 +289,13 @@ class Settings(object):
         :param dotted_lookup: Should perform dotted-path lookup?
         :return: The value if found, default or None
         """
-        key = key.upper()
 
         if "." in key and dotted_lookup:
             return self._dotted_get(
                 dotted_key=key, default=default, cast=cast, fresh=fresh
             )
 
+        key = key.upper()
         if key in self._deleted:
             return default
 
@@ -395,6 +399,63 @@ class Settings(object):
         """Gets the internal mapping of LOADER -> values"""
         return self._loaded_by_loaders
 
+    def from_env(self, env, keep=False, **kwargs):
+        """Return a new isolated settings object pointing to specified env.
+
+        Example of settings.toml::
+
+            [development]
+            message = 'This is in dev'
+            [other]
+            message = 'this is in other env'
+
+        Program::
+
+            >>> from dynaconf import settings
+            >>> print(settings.MESSAGE)
+            'This is in dev'
+            >>> print(settings.from_env('other').MESSAGE)
+            'This is in other env'
+            # The existing settings object remains the same.
+            >>> print(settings.MESSAGE)
+            'This is in dev'
+
+        Arguments:
+            env {str} -- Env to load (development, production, custom)
+
+        Keyword Arguments:
+            keep {bool} -- Keep pre-existing values (default: {False})
+            kwargs {dict} -- Passed directly to new instance.
+        """
+        cache_key = "{}_{}_{}".format(env, keep, kwargs)
+        if cache_key in self._env_cache:
+            self.logger.debug("Settings instance in env: %s from cache", env)
+            return self._env_cache[cache_key]
+
+        new_data = {
+            key: self.get(key)
+            for key in dir(default_settings)
+            if key.isupper() and key not in RENAMED_VARS
+        }
+
+        if keep:
+            # keep existing values from current env
+            new_data.update(
+                {
+                    key: value
+                    for key, value in self.store.to_dict().copy().items()
+                    if key.isupper() and key not in RENAMED_VARS
+                }
+            )
+
+        new_data.update(kwargs)
+        new_data["ENV_FOR_DYNACONF"] = env
+
+        new_settings = LazySettings(**new_data)
+        self.logger.debug("New settings instance in env: %s", env)
+        self._env_cache[cache_key] = new_settings
+        return new_settings
+
     @contextmanager
     def using_env(self, env, clean=True, silent=True, filename=None):
         """
diff --git a/dynaconf/cli.py b/dynaconf/cli.py
index 6e601f3..846aa97 100644
--- a/dynaconf/cli.py
+++ b/dynaconf/cli.py
@@ -1,6 +1,5 @@
 import importlib
 import io
-import json
 import os
 import pprint
 import sys
diff --git a/dynaconf/contrib/django_dynaconf_v2.py b/dynaconf/contrib/django_dynaconf_v2.py
index 5c26605..7bfc3b1 100644
--- a/dynaconf/contrib/django_dynaconf_v2.py
+++ b/dynaconf/contrib/django_dynaconf_v2.py
@@ -57,7 +57,9 @@ def load(django_settings_module_name=None, **kwargs):  # pragma: no cover
         k: v for k, v in django_settings_module.__dict__.items() if k.isupper()
     }
     options.update(kwargs)
-    options.setdefault("SKIP_FILES_FOR_DYNACONF", [settings_file])
+    options.setdefault(
+        "SKIP_FILES_FOR_DYNACONF", [settings_file, "dynaconf_merge"]
+    )
     options.setdefault("ROOT_PATH_FOR_DYNACONF", _root_path)
     options.setdefault("ENVVAR_PREFIX_FOR_DYNACONF", "DJANGO")
     options.setdefault("ENV_SWITCHER_FOR_DYNACONF", "DJANGO_ENV")
diff --git a/dynaconf/loaders/__init__.py b/dynaconf/loaders/__init__.py
index c2e7fbe..9bbebf2 100644
--- a/dynaconf/loaders/__init__.py
+++ b/dynaconf/loaders/__init__.py
@@ -82,6 +82,7 @@ def settings_loader(
     found_files = []
     modules_names = []
     for item in files:
+        item = str(item)  # Ensure str in case of LocalPath/Path is passed.
         if item.endswith(ct.ALL_EXTENSIONS + (".py",)):
             p_root = obj._root_path or (
                 os.path.dirname(found_files[0]) if found_files else None
diff --git a/dynaconf/loaders/ini_loader.py b/dynaconf/loaders/ini_loader.py
index 5644b74..b4c49b0 100644
--- a/dynaconf/loaders/ini_loader.py
+++ b/dynaconf/loaders/ini_loader.py
@@ -8,7 +8,7 @@ from dynaconf.utils import object_merge
 
 try:
     from configobj import ConfigObj
-except ImportError as e:  # pragma: no cover
+except ImportError:  # pragma: no cover
     ConfigObj = None
 
 
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
index f5cb81e..f3a901b 100644
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -169,7 +169,7 @@ def warn_deprecations(data):
     if data.get("MERGE_ENABLED_FOR_DYNACONF"):
         warnings.warn(
             "MERGE_ENABLED_FOR_DYNACONF is deprecated "
-            "instead it is prefered to use the local merge feature "
+            "instead it is preferred to use the local merge feature "
             "see: https://dynaconf.readthedocs.io/en/latest/guides/usage.html"
             "#merging-existing-values",
             DeprecationWarning,
diff --git a/example/django_example_compat/foo/settings.py b/example/django_example_compat/foo/settings.py
index bda6fd1..f955454 100644
--- a/example/django_example_compat/foo/settings.py
+++ b/example/django_example_compat/foo/settings.py
@@ -31,7 +31,9 @@ settings = dynaconf.DjangoDynaconf(
 
 
 # test
-assert settings.ENVVAR_PREFIX_FOR_DYNACONF == "MYWEBAPP"
+assert (
+    settings.ENVVAR_PREFIX_FOR_DYNACONF == "MYWEBAPP"
+), settings.ENVVAR_PREFIX_FOR_DYNACONF
 assert settings.GLOBAL_ENV_FOR_DYNACONF == "MYWEBAPP"
 assert settings.PLUGIN_ENABLED is True
 assert settings.PLUGIN_LIST == ["plugin1", "plugin2"]


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 7c1f7a0..0d180f9 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -2,6 +2,8 @@ import os
 
 import pytest
 
+from dynaconf import LazySettings
+from dynaconf.loaders import toml_loader
 from dynaconf.utils.parse_conf import true_values
 
 
@@ -424,3 +426,79 @@ def test_dotted_set_with_merge(settings):
     assert settings.DATABASES != start_data
     assert settings.DATABASES["default"].keys() == start_data["default"].keys()
     assert settings.DATABASES.default.ATTRS == {"x": 26}
+
+
+def test_from_env_method(clean_env, tmpdir):
+    data = {
+        "default": {"a_default": "From default env"},
+        "development": {
+            "value": "From development env",
+            "only_in_development": True,
+        },
+        "other": {"value": "From other env", "only_in_other": True},
+    }
+    toml_path = str(tmpdir.join("base_settings.toml"))
+    toml_loader.write(toml_path, data, merge=False)
+    settings = LazySettings(SETTINGS_FILE_FOR_DYNACONF=toml_path)
+    settings.set("ARBITRARY_KEY", "arbitrary value")
+
+    assert settings.VALUE == "From development env"
+    assert settings.A_DEFAULT == "From default env"
+    assert settings.ONLY_IN_DEVELOPMENT is True
+    assert settings.ARBITRARY_KEY == "arbitrary value"
+    assert settings.get("ONLY_IN_OTHER") is None
+
+    # clone the settings object pointing to a new env
+    other_settings = settings.from_env("other")
+    assert other_settings.VALUE == "From other env"
+    assert other_settings.A_DEFAULT == "From default env"
+    assert other_settings.ONLY_IN_OTHER is True
+    assert other_settings.get("ARBITRARY_KEY") is None
+    assert other_settings.get("ONLY_IN_DEVELOPMENT") is None
+    with pytest.raises(AttributeError):
+        # values set programatically are not cloned
+        other_settings.ARBITRARY_KEY
+    with pytest.raises(AttributeError):
+        # values set only in a specific env not cloned
+        other_settings.ONLY_IN_DEVELOPMENT
+    # assert it is cached not created twice
+    assert other_settings is settings.from_env("other")
+
+    # Now the same using keep=True
+    other_settings = settings.from_env("other", keep=True)
+    assert other_settings.VALUE == "From other env"
+    assert other_settings.A_DEFAULT == "From default env"
+    assert other_settings.ONLY_IN_OTHER is True
+    assert other_settings.ONLY_IN_DEVELOPMENT is True
+    assert settings.ARBITRARY_KEY == "arbitrary value"
+
+    # assert it is created not cached
+    assert other_settings is not settings.from_env("other")
+
+    # settings remains the same
+    assert settings.VALUE == "From development env"
+    assert settings.A_DEFAULT == "From default env"
+    assert settings.ONLY_IN_DEVELOPMENT is True
+    assert settings.ARBITRARY_KEY == "arbitrary value"
+    assert settings.get("ONLY_IN_OTHER") is None
+
+    # additional kwargs
+    data = {
+        "default": {"a_default": "From default env"},
+        "production": {"value": "From prod env", "only_in_prod": True},
+        "other": {"value": "From other env", "only_in_other": True},
+    }
+    toml_path = str(tmpdir.join("other_settings.toml"))
+    toml_loader.write(toml_path, data, merge=False)
+
+    new_other_settings = other_settings.from_env(
+        "production", keep=True, SETTINGS_FILE_FOR_DYNACONF=toml_path
+    )
+
+    # production values
+    assert new_other_settings.VALUE == "From prod env"
+    assert new_other_settings.ONLY_IN_PROD is True
+    # keep=True values
+    assert new_other_settings.ONLY_IN_OTHER is True
+    assert new_other_settings.ONLY_IN_DEVELOPMENT is True
+    assert settings.A_DEFAULT == "From default env"