Repository: dynaconf/dynaconf

Issue:
[RFC] Allow dynaconf cli to use a customized settings object
I have a customized settings object:

```python
from dynaconf import LazySettings

settings = LazySettings(
    GLOBAL_ENV_FOR_DYNACONF='MYPROJECT',
    MERGE_ENABLED_FOR_DYNACONF=True,
)
```

When I run `dynaconf` cli tool, it knows nothing about my customization. How can I tell `dynaconf` cli tool to use my customized version of settings?


Discussion:
Hi @sirex 
The dynaconf CLI is based on the environment variables only, in your case
```bash
GLOBAL_ENV_FOR_DYNACONF='MYPROJECT' MERGE_ENABLED_FOR_DYNACONF=True dynaconf list
```

Currently, there is no way to point to a customized object, but we can think about adding this functionality

**Ideas**:
1.
```bash
export INSTANCE_FOR_DYNACONF=myproject/folder/module.py   # dynaconf will look to a `LazySettings` inside it
```

2.
```bash
export INSTANCE_FOR_DYNACONF=myproject.module.other:settings 
```

3.
```bash
dynaconf list --instance=myproject.module.other.settings
```

I'll keep this issue as a tracker for this implementation
I like 2nd and 3rd options. And I can probably implement it myself. What RFC stands for? Ready for comments or Ready for commit?
@sirex it is **R**equest **F**or **C**omments.
It will be nice if you can implement and send the PR.

I guess importlib allows to easily import based on a provided string.

```python
import importlib
instance_arg = "foo.bar:settings"  # read from command line or environment variable

try:
    mod_name, settings_object_name = instance_arg.rsplit(':')
except ValueError:  # no ":" separator found
    mod_name = instance_arg
    settings_object_name = "settings"  # assume the `settings` name if not provided

mod = importlib.import_module(mod_name)
return getattr(mod, settings_object_name)

```



Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -83,8 +83,6 @@ def __init__(self, **kwargs):
         """
         compat_kwargs(kwargs)
         self._kwargs = kwargs
-        for k, v in kwargs.items():
-            setattr(default_settings, k.upper(), v)
         super(LazySettings, self).__init__()
 
     def __getattr__(self, name):
@@ -113,7 +111,8 @@ def __call__(self, *args, **kwargs):
 
     def _setup(self):
         """Initial setup, run once."""
-        environment_variable = default_settings.ENVVAR_FOR_DYNACONF
+        environment_variable = self._kwargs.get(
+            'ENVVAR_FOR_DYNACONF', default_settings.ENVVAR_FOR_DYNACONF)
         settings_module = os.environ.get(environment_variable)
         self._wrapped = Settings(
             settings_module=settings_module, **self._kwargs
diff --git a/dynaconf/cli.py b/dynaconf/cli.py
--- a/dynaconf/cli.py
+++ b/dynaconf/cli.py
@@ -9,44 +9,75 @@
 from pathlib import Path
 from dynaconf import default_settings
 from dynaconf import constants
-from dynaconf.validator import Validator
+from dynaconf import LazySettings
+from dynaconf.validator import Validator, ValidationError
 from dynaconf.utils.parse_conf import parse_conf_data
 from dotenv import cli as dotenv_cli
 from contextlib import suppress
 
+CWD = Path.cwd()
+ENVS = ['default', 'development', 'staging', 'testing', 'production', 'global']
+EXTS = ['ini', 'toml', 'yaml', 'json', 'py', 'env']
+WRITERS = ['ini', 'toml', 'yaml', 'json', 'py', 'redis', 'vault', 'env']
 
-flask_app = None
-django_app = None
+ENC = default_settings.ENCODING_FOR_DYNACONF
 
-if 'FLASK_APP' in os.environ:  # pragma: no cover
-    with suppress(ImportError, click.UsageError):
-        from flask.cli import ScriptInfo
-        flask_app = ScriptInfo().load_app()
-        settings = flask_app.config
-        click.echo(click.style('Flask app detected', fg='white', bg='black'))
 
+def set_settings(instance=None):
+    """Pick correct settings instance and set it to a global variable."""
 
-if 'DJANGO_SETTINGS_MODULE' in os.environ:  # pragma: no cover
-    sys.path.insert(0, os.path.abspath('.'))
-    with suppress(Exception):
-        import dynaconf.contrib.django_dynaconf  # noqa
-        from django.conf import settings as django_settings
-        django_settings.configure()
-        settings = django_settings
-        django_app = True
-        click.echo(click.style('Django app detected', fg='white', bg='black'))
+    global settings
 
+    settings = None
 
-if not django_app and not flask_app:
-    from dynaconf import settings
+    if instance:
+        settings = import_settings(instance)
 
+    elif 'INSTANCE_FOR_DYNACONF' in os.environ:
+        settings = import_settings(os.environ['INSTANCE_FOR_DYNACONF'])
 
-CWD = Path.cwd()
-ENVS = ['default', 'development', 'staging', 'testing', 'production', 'global']
-EXTS = ['ini', 'toml', 'yaml', 'json', 'py', 'env']
-WRITERS = ['ini', 'toml', 'yaml', 'json', 'py', 'redis', 'vault', 'env']
+    elif 'FLASK_APP' in os.environ:  # pragma: no cover
+        with suppress(ImportError, click.UsageError):
+            from flask.cli import ScriptInfo
+            flask_app = ScriptInfo().load_app()
+            settings = flask_app.config
+            click.echo(click.style(
+                'Flask app detected', fg='white', bg='black'))
+
+    elif 'DJANGO_SETTINGS_MODULE' in os.environ:  # pragma: no cover
+        sys.path.insert(0, os.path.abspath('.'))
+        with suppress(Exception):
+            import dynaconf.contrib.django_dynaconf  # noqa
+            from django.conf import settings as django_settings
+            django_settings.configure()
+            settings = django_settings
+            click.echo(click.style(
+                'Django app detected', fg='white', bg='black'))
+
+    if settings is None:
+        settings = LazySettings()
 
-ENC = default_settings.ENCODING_FOR_DYNACONF
+
+def import_settings(dotted_path):
+    """Import settings instance from python dotted path.
+
+    Last item in dotted path must be settings instace.
+
+    Example: import_settings('path.to.settings')
+    """
+    if '.' in dotted_path:
+        module, name = dotted_path.rsplit('.', 1)
+    else:
+        raise click.UsageError(
+            "invalid path to settings instance: {}".format(dotted_path))
+    try:
+        module = importlib.import_module(module)
+    except ImportError as e:
+        raise click.UsageError(e)
+    try:
+        return getattr(module, name)
+    except AttributeError as e:
+        raise click.UsageError(e)
 
 
 def split_vars(_vars):
@@ -86,6 +117,7 @@ def show_banner(ctx, param, value):
     """Shows dynaconf awesome banner"""
     if not value or ctx.resilient_parsing:
         return
+    set_settings()
     click.echo(settings.dynaconf_banner)
     click.echo('Learn more at: http://github.com/rochacbruno/dynaconf')
     ctx.exit()
@@ -98,10 +130,13 @@ def show_banner(ctx, param, value):
               is_eager=True, help="Open documentation in browser")
 @click.option('--banner', is_flag=True, callback=show_banner,
               expose_value=False, is_eager=True, help="Show awesome banner")
-def main():
+@click.option('--instance', '-i', default=None,
+              help="Custom instance of LazySettings")
+def main(instance):
     """Dynaconf - Command Line Interface\n
     Documentation: http://dynaconf.readthedocs.io/
     """
+    set_settings(instance)
 
 
 @main.command()
@@ -401,10 +436,12 @@ def validate(path):  # pragma: no cover
     # for each section register the validator for specific env
     # call validate
 
+    path = Path(path)
+
     if not str(path).endswith('.toml'):
         path = path / "dynaconf_validators.toml"
 
-    if not Path(path).exists():  # pragma: no cover  # noqa
+    if not path.exists():  # pragma: no cover  # noqa
         click.echo(click.style(
             "{} not found".format(path), fg="white", bg="red"
         ))
@@ -412,6 +449,7 @@ def validate(path):  # pragma: no cover
 
     validation_data = toml.load(open(str(path)))
 
+    success = True
     for env, name_data in validation_data.items():
         for name, data in name_data.items():
             if not isinstance(data, dict):  # pragma: no cover
@@ -419,15 +457,21 @@ def validate(path):  # pragma: no cover
                     "Invalid rule for parameter '{}'".format(name),
                     fg="white", bg="yellow"
                 ))
-            else:  # pragma: no cover
+            else:
                 data.setdefault('env', env)
                 click.echo(click.style(
                     "Validating '{}' with '{}'".format(name, data),
                     fg="white", bg="blue"
                 ))
-                Validator(name, **data).validate(settings)
-
-    # pragma: no cover
-    click.echo(click.style(
-        "Validation success!", fg="white", bg="green"
-    ))
+                try:
+                    Validator(name, **data).validate(settings)
+                except ValidationError as e:
+                    click.echo(click.style(
+                        "Error: {}".format(e), fg="white", bg="red"
+                    ))
+                    success = False
+
+    if success:
+        click.echo(click.style(
+            "Validation success!", fg="white", bg="green"
+        ))
diff --git a/dynaconf/default_settings.py b/dynaconf/default_settings.py
--- a/dynaconf/default_settings.py
+++ b/dynaconf/default_settings.py
@@ -148,3 +148,8 @@ def start_dotenv(obj=None):
 DOTENV_PATH_FOR_DYNACONF = get('DOTENV_PATH_FOR_DYNACONF', None)
 DOTENV_VERBOSE_FOR_DYNACONF = get('DOTENV_VERBOSE_FOR_DYNACONF', False)
 DOTENV_OVERRIDE_FOR_DYNACONF = get('DOTENV_OVERRIDE_FOR_DYNACONF', False)
+
+# Currently this is only used by cli. INSTANCE_FOR_DYNACONF specifies python
+# dotted path to custom LazySettings instance. Last dotted path item should be
+# instance of LazySettings.
+INSTANCE_FOR_DYNACONF = get('INSTANCE_FOR_DYNACONF', None)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,7 +3,7 @@
 import pytest
 from pathlib import Path
 from click.testing import CliRunner
-from dynaconf import default_settings
+from dynaconf import LazySettings, default_settings
 from dynaconf.cli import main, EXTS, WRITERS, ENVS, read_file_in_root_directory
 from dotenv import cli as dotenv_cli
 
@@ -11,8 +11,8 @@
 runner = CliRunner()
 
 
-def run(cmd):
-    result = runner.invoke(main, cmd)
+def run(cmd, env=None):
+    result = runner.invoke(main, cmd, env=env, catch_exceptions=False)
     # assert result.exit_code == 0
     return result.output
 
@@ -87,9 +87,9 @@ def test_init_with_path(fileformat, tmpdir):
             [
                 'init',
                 '--format={}'.format(fileformat),
-                '--path={}'.format(str(path)),
+                '--path={}'.format(str(tmpdir)),
                 '-y'
-            ]
+            ],
         )
 
     sets = Path(str(path))
@@ -147,6 +147,61 @@ def test_list_with_env(env):
     assert "Working in {} environment".format(env) in result
 
 
+settings = LazySettings(OPTION_FOR_TESTS=True)
+
+
+def test_list_with_instance():
+    result = run(
+        [
+            '-i', 'tests.test_cli.settings',
+            'list',
+        ]
+    )
+    assert "OPTION_FOR_TESTS: True" in result
+
+
+def test_list_with_instance_from_env():
+    result = run(
+        [
+            'list',
+        ],
+        {
+            'INSTANCE_FOR_DYNACONF': 'tests.test_cli.settings',
+        }
+    )
+    assert "OPTION_FOR_TESTS: True" in result
+
+
+def test_instance_attribute_error():
+    result = run(
+        [
+            '-i', 'tests.test_cli.idontexist',
+            'list',
+        ]
+    )
+    assert "has no attribute 'idontexist'" in result
+
+
+def test_instance_import_error():
+    result = run(
+        [
+            '-i', 'idontexist.settings',
+            'list',
+        ]
+    )
+    assert "Error: No module named 'idontexist'" in result
+
+
+def test_instance_pypath_error():
+    result = run(
+        [
+            '-i', 'idontexist',
+            'list',
+        ]
+    )
+    assert "Error: invalid path to settings instance: idontexist" in result
+
+
 def test_list_with_key():
     result = run(
         [
@@ -292,21 +347,31 @@ def test_validate(tmpdir):
     toml_invalid = tmpdir.mkdir('invalid').join('settings.toml')
     toml_invalid.write(TOML_INVALID)
 
-    os.chdir(str(Path(str(toml_valid)).parent))
     result = run(
         [
             'validate',
             '-p',
             str(validation_file)
-        ]
+        ],
+        {
+            'SETTINGS_MODULE_FOR_DYNACONF': str(toml_valid),
+        }
     )
+    assert "Validation success!" in result
 
-    os.chdir(str(Path(str(toml_invalid)).parent))
     result = run(
         [
             'validate',
             '-p',
             str(Path(str(validation_file)).parent)
-        ]
+        ],
+        {
+            'SETTINGS_MODULE_FOR_DYNACONF': str(toml_invalid),
+        }
     )
-    print(result)
+    assert "age must lte 30 but it is 35 in env default" in result
+    assert "project must eq hello_world but it is This is not hello_world " \
+           "in env production" in result
+    assert "host must is_not_in ['test.com'] but it is test.com in env " \
+           "production" in result
+    assert "Validation success!" not in result