Repository: dynaconf/dynaconf

Issue:
[RFC] Allow python.module.path on INCLUDES_FOR_DYNACONF
Dynaconf right now can load includes, the includes should be a list of paths like `['/path/to/file.yaml', '/path/to/glob/*.toml']`

**Problem**

It should also allow includes by `['python.module.paths'..]` instead of only `glob-able` paths.

**Describe the solution you'd like**
I want to be able to use:

```py
INCLUDES_FOR_DYNACONF=['python_module.submodule.settings', ...]
```

**Additional context**

This is request by  https://pulp.plan.io/issues/5290

The changes should go on lines:

https://github.com/rochacbruno/dynaconf/blob/master/dynaconf/base.py#L757-L799

And dynaconf already has ability to load Python modules on: https://github.com/rochacbruno/dynaconf/blob/master/dynaconf/loaders/py_loader.py



Discussion:

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -7,6 +7,7 @@
 from dynaconf import default_settings
 from dynaconf.loaders import default_loader
 from dynaconf.loaders import enable_external_loaders
+from dynaconf.loaders import py_loader
 from dynaconf.loaders import settings_loader
 from dynaconf.loaders import yaml_loader
 from dynaconf.utils import BANNER
@@ -777,6 +778,14 @@ def load_file(self, path=None, env=None, silent=True, key=None):
             already_loaded = set()
             for _filename in files:
                 self.logger.debug("Processing file %s", _filename)
+
+                if py_loader.try_to_load_from_py_module_name(
+                    obj=self, name=_filename, silent=True
+                ):
+                    # if it was possible to load from module name
+                    # continue the loop.
+                    continue
+
                 filepath = os.path.join(self._root_path, _filename)
                 self.logger.debug("File path is %s", filepath)
                 # Handle possible *.globs sorted alphanumeric
diff --git a/dynaconf/loaders/py_loader.py b/dynaconf/loaders/py_loader.py
--- a/dynaconf/loaders/py_loader.py
+++ b/dynaconf/loaders/py_loader.py
@@ -3,6 +3,7 @@
 import inspect
 import io
 import types
+from contextlib import suppress
 from pathlib import Path
 
 from dynaconf import default_settings
@@ -24,6 +25,12 @@ def load(obj, settings_module, identifier="py", silent=False, key=None):
         )
         return
 
+    load_from_python_object(obj, mod, settings_module, key, identifier)
+
+
+def load_from_python_object(
+    obj, mod, settings_module, key=None, identifier=None
+):
     for setting in dir(mod):
         if setting.isupper():
             if key is None or key == setting:
@@ -35,10 +42,33 @@ def load(obj, settings_module, identifier="py", silent=False, key=None):
                     identifier,
                 )
                 obj.set(setting, setting_value, loader_identifier=identifier)
-
     obj._loaded_files.append(mod.__file__)
 
 
+def try_to_load_from_py_module_name(
+    obj, name, key=None, identifier="py", silent=False
+):
+    """Try to load module by its string name.
+
+    Arguments:
+        obj {LAzySettings} -- Dynaconf settings instance
+        name {str} -- Name of the module e.g: foo.bar.zaz
+
+    Keyword Arguments:
+        key {str} -- Single key to be loaded (default: {None})
+        identifier {str} -- Name of identifier to store (default: 'py')
+        silent {bool} -- Weather to raise or silence exceptions.
+    """
+    ctx = suppress(ImportError, TypeError) if silent else suppress()
+
+    with ctx:
+        mod = importlib.import_module(name)
+        load_from_python_object(obj, mod, name, key, identifier)
+        return True  # loaded ok!
+    # if it reaches this point that means exception occurred, module not found.
+    return False
+
+
 def get_module(obj, filename, silent=False):
     logger = raw_logger()
     try:
@@ -51,6 +81,7 @@ def get_module(obj, filename, silent=False):
         if mod and not mod._is_error:
             loaded_from = "filename"
         else:
+            # it is important to return None in case of not loaded
             loaded_from = None
     return mod, loaded_from
 


Test patch:
diff --git a/tests/test_nested_loading.py b/tests/test_nested_loading.py
--- a/tests/test_nested_loading.py
+++ b/tests/test_nested_loading.py
@@ -319,3 +319,58 @@ def test_programmatically_file_load(tmpdir):
     # to persist it needs to go to `INCLUDES_FOR_DYNACONF` variable
     with pytest.raises(AttributeError):
         assert settings.PLUGIN_VALUE == "plugin"
+
+
+def test_include_via_python_module_name(tmpdir):
+    """Check if an include can be a Python module name"""
+    settings_file = tmpdir.join("settings.toml")
+    settings_file.write(
+        """
+       [default]
+       default_var = 'default'
+    """
+    )
+
+    dummy_folder = tmpdir.mkdir("dummy")
+    dummy_folder.join("dummy_module.py").write('FOO = "164110"')
+    dummy_folder.join("__init__.py").write('print("initing dummy...")')
+
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(settings_file),
+        INCLUDES_FOR_DYNACONF=["dummy.dummy_module"],
+    )
+
+    assert settings.DEFAULT_VAR == "default"
+    assert settings.FOO == "164110"
+
+
+def test_include_via_python_module_name_and_otjers(tmpdir):
+    """Check if an include can be a Python module name plus others"""
+    settings_file = tmpdir.join("settings.toml")
+    settings_file.write(
+        """
+       [default]
+       default_var = 'default'
+    """
+    )
+
+    dummy_folder = tmpdir.mkdir("dummy")
+    dummy_folder.join("dummy_module.py").write('FOO = "164110"')
+    dummy_folder.join("__init__.py").write('print("initing dummy...")')
+
+    yaml_file = tmpdir.join("otherfile.yaml")
+    yaml_file.write(
+        """
+       default:
+         yaml_value: 748632
+    """
+    )
+
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(settings_file),
+        INCLUDES_FOR_DYNACONF=["dummy.dummy_module", "otherfile.yaml"],
+    )
+
+    assert settings.DEFAULT_VAR == "default"
+    assert settings.FOO == "164110"
+    assert settings.YAML_VALUE == 748632
diff --git a/tests/test_py_loader.py b/tests/test_py_loader.py
--- a/tests/test_py_loader.py
+++ b/tests/test_py_loader.py
@@ -1,8 +1,11 @@
 import io
 import os
 
+import pytest
+
 from dynaconf import default_settings
 from dynaconf.loaders.py_loader import load
+from dynaconf.loaders.py_loader import try_to_load_from_py_module_name
 from dynaconf.utils import DynaconfDict
 
 
@@ -33,3 +36,28 @@ def test_py_loader_from_module(tmpdir):
     load(settings, "dummy.dummy_module")
 
     assert settings.exists("FOO")
+
+
+def test_try_to_load_from_py_module_name(tmpdir):
+    settings = DynaconfDict()
+    dummy_folder = tmpdir.mkdir("dummy")
+
+    dummy_folder.join("dummy_module.py").write('FOO = "bar"')
+    dummy_folder.join("__init__.py").write('print("initing dummy...")')
+
+    try_to_load_from_py_module_name(settings, "dummy.dummy_module")
+
+    assert settings.exists("FOO")
+
+
+def test_negative_try_to_load_from_py_module_name(tmpdir):
+    settings = DynaconfDict()
+    with pytest.raises(ImportError):
+        try_to_load_from_py_module_name(settings, "foo.bar.dummy")
+
+
+def test_silently_try_to_load_from_py_module_name(tmpdir):
+    settings = DynaconfDict()
+    try_to_load_from_py_module_name(settings, "foo.bar.dummy", silent=True)
+
+    assert settings.exists("FOO") is False