Repository: dynaconf/dynaconf

Issue:
[RFC] Support multidoc yaml files
**Is your feature request related to a problem? Please describe.**
Sometimes it can be difficult or impossible to pass multiple files with config fragments. yaml support multiple documents in one file and `safe_load_all` from pyaml api loads that accordingly. It is standard yaml feature, it would be nice to support it and make in usable in cases when passing one file (composited from more files) would be easier.

**Describe the solution you'd like**
Support `safe_load_all` as yaml loader.

**Describe alternatives you've considered**
Passing multiple files will do the work, however it doesn't have to be always straightforward.

**Additional context**
I have prepared a patch



Discussion:

Resolving patch:
diff --git a/dynaconf/loaders/yaml_loader.py b/dynaconf/loaders/yaml_loader.py
--- a/dynaconf/loaders/yaml_loader.py
+++ b/dynaconf/loaders/yaml_loader.py
@@ -20,6 +20,41 @@
 )
 
 
+class AllLoader(BaseLoader):
+    """YAML Loader to load multi doc files"""
+
+    @staticmethod
+    def _assign_data(data, source_file, content):
+        """Helper to iterate through all docs in a file"""
+        content = tuple(content)
+        if len(content) == 1:
+            data[source_file] = content[0]
+        elif len(content) > 1:
+            for i, doc in enumerate(content):
+                data[f"{source_file}[{i}]"] = doc
+
+    def get_source_data(self, files):
+        data = {}
+        for source_file in files:
+            if source_file.endswith(self.extensions):
+                try:
+                    with open(source_file, **self.opener_params) as open_file:
+                        content = self.file_reader(open_file)
+                        self.obj._loaded_files.append(source_file)
+                        self._assign_data(data, source_file, content)
+                except OSError as e:
+                    if ".local." not in source_file:
+                        warn(
+                            f"{self.identifier}_loader: {source_file} "
+                            f":{str(e)}"
+                        )
+            else:
+                # for tests it is possible to pass string
+                content = self.string_reader(source_file)
+                self._assign_data(data, source_file, content)
+        return data
+
+
 def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     Reads and loads in to "obj" a single key or all keys from source file.
@@ -33,7 +68,8 @@ def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
     """
     # Resolve the loaders
     # https://github.com/yaml/pyyaml/wiki/PyYAML-yaml.load(input)-Deprecation
-    # Possible values are `safe_load, full_load, unsafe_load, load`
+    # Possible values are:
+    #   `safe_load, full_load, unsafe_load, load, safe_load_all`
     yaml_reader = getattr(
         yaml, obj.get("YAML_LOADER_FOR_DYNACONF"), yaml.safe_load
     )
@@ -44,7 +80,11 @@ def load(obj, env=None, silent=True, key=None, filename=None, validate=False):
             " Try to use full_load or safe_load."
         )
 
-    loader = BaseLoader(
+    _loader = BaseLoader
+    if yaml_reader.__name__.endswith("_all"):
+        _loader = AllLoader
+
+    loader = _loader(
         obj=obj,
         env=env,
         identifier="yaml",


Test patch:
diff --git a/tests/test_yaml_loader.py b/tests/test_yaml_loader.py
--- a/tests/test_yaml_loader.py
+++ b/tests/test_yaml_loader.py
@@ -18,6 +18,15 @@ def settings():
     )
 
 
+@pytest.fixture(scope="module")
+def multidoc_settings():
+    return LazySettings(
+        environments=True,
+        ENV_FOR_DYNACONF="PRODUCTION",
+        YAML_LOADER_FOR_DYNACONF="safe_load_all",
+    )
+
+
 YAML = """
 # the below is just to ensure `,` will not break string YAML
 a: 'a,b'
@@ -59,6 +68,8 @@ def settings():
 
 YAMLS = [YAML, YAML2]
 
+MULTIDOC = "\n---\n".join(YAMLS)
+
 
 def test_load_from_yaml(settings):
     """Assert loads from YAML string"""
@@ -108,6 +119,33 @@ def test_load_from_multiple_yaml(settings):
     assert settings.PASSWORD == 11111
 
 
+def test_load_from_multidoc_yaml(multidoc_settings):
+    """Assert loads from YAML string"""
+    settings = multidoc_settings
+    load(settings, filename=MULTIDOC)
+    assert settings.HOST == "otheryaml.com"
+    assert settings.PASSWORD == 123456
+    assert settings.SECRET == 42.0
+    assert settings.PORT == 8080
+    assert settings.SERVICE["url"] == "service.com"
+    assert settings.SERVICE.url == "service.com"
+    assert settings.SERVICE.port == 80
+    assert settings.SERVICE.auth.password == "qwerty"
+    assert settings.SERVICE.auth.test == 1234
+    load(settings, filename=YAMLS, env="DEVELOPMENT")
+    assert settings.PORT == 8080
+    assert settings.HOST == "otheryaml.com"
+    load(settings, filename=YAMLS)
+    assert settings.HOST == "otheryaml.com"
+    assert settings.PASSWORD == 123456
+    load(settings, filename=YAML, env="DEVELOPMENT")
+    assert settings.PORT == 8080
+    assert settings.HOST == "devserver.com"
+    load(settings, filename=YAML)
+    assert settings.HOST == "prodserver.com"
+    assert settings.PASSWORD == 11111
+
+
 def test_no_filename_is_none(settings):
     """Assert if passed no filename return is None"""
     assert load(settings) is None
diff --git a/tests_functional/yaml_example/multidoc_file/app.py b/tests_functional/yaml_example/multidoc_file/app.py
new file mode 100644
--- /dev/null
+++ b/tests_functional/yaml_example/multidoc_file/app.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import os
+
+os.environ["YAML_LOADER_FOR_DYNACONF"] = "safe_load_all"
+
+from dynaconf import LazySettings
+from dynaconf import settings
+from dynaconf.strategies.filtering import PrefixFilter
+
+# print all values in the file
+# using [default] + [development] + [global] values
+print("* All values")
+print(settings.HOST)
+print(settings.PORT)
+print(settings.USERNAME)
+print(settings.PASSWORD)
+print(settings.LEVELS)
+print(settings.TEST_LOADERS)
+print(settings.MONEY)
+print(settings.AGE)
+print(settings.ENABLED)
+print(settings.CUSTOM)
+
+print("* Switiching to production")
+# using [production] env values for context
+with settings.using_env("PRODUCTION"):
+    print(settings.CUSTOM)
+    print(settings.HOST)
+
+print("* Switiching to development")
+# back to default [development] env
+print(settings.get("CUSTOM"))
+print(settings.HOST)
+
+print("* Switiching to production")
+# set env to [production]:
+settings.setenv("production")
+print(settings.HOST)
+print(settings.CUSTOM)
+
+print("* Switiching to development")
+# back to [development] env again
+settings.setenv()
+print(settings.HOST)
+print(settings.get("INEXISTENT"))  # None
+
+print(settings.WORKS)
+
+
+assertions = {
+    "HOST": "dev_server.com from yaml",
+    "PORT": 5000,
+    "USERNAME": "admin",
+    "PASSWORD": "Secret",
+    "LEVELS": ["debug", "info", "warning"],
+    "MONEY": 500.5,
+    "AGE": 42,
+    "ENABLED": True,
+    "WORKS": "yaml_example in dev env",
+    "CUSTOM": "this is custom from [development]",
+    "PREFIX_CUSTOM": "this is custom when prefix is set",
+    "TEST_LOADERS": {"dev": "test_dev", "prod": "test_prod"},
+}
+
+for key, value in assertions.items():
+    found = settings.get(key)
+    assert found == getattr(settings, key)
+    assert found == value, f"expected: {key}: [{value}] found: [{found}]"
+
+
+assertions = {
+    "HOST": "prod_server.com from yaml",
+    "PORT": 5000,
+    "USERNAME": "admin",
+    "PASSWORD": "Secret",
+    "LEVELS": ["debug", "info", "warning"],
+    "MONEY": 500.5,
+    "AGE": 42,
+    "ENABLED": True,
+    "WORKS": "yaml_example in prod env",
+    "CUSTOM": "this is custom from [production]",
+    "PREFIX_CUSTOM": "this is custom when prefix is set",
+    "TEST_LOADERS": {"dev": "test_dev", "prod": "test_prod"},
+}
+
+
+for key, value in assertions.items():
+    found = settings.from_env("production").get(key)
+    assert found == getattr(settings.from_env("production"), key)
+    assert found == value, f"expected: {key}: [{value}] found: [{found}]"
+
+settings = LazySettings(
+    settings_files=["settings.yaml"],
+    filter_strategy=PrefixFilter("prefix"),
+    environments=True,
+)
+with settings.using_env("default"):
+    assert settings.CUSTOM == "this is custom when prefix is set"
+assert (
+    settings.from_env("production").CUSTOM
+    == "this is custom when prefix is set"
+)
diff --git a/tests_functional/yaml_example/multidoc_file/settings.yaml b/tests_functional/yaml_example/multidoc_file/settings.yaml
new file mode 100644
--- /dev/null
+++ b/tests_functional/yaml_example/multidoc_file/settings.yaml
@@ -0,0 +1,31 @@
+default:
+  host: server.com from YAML
+  port: 5000
+  username: admin
+  PASSWORD: Secret
+  levels:
+    - debug
+    - info
+    - warning
+  money: 500.5
+  age: 42
+  enabled: true
+  works: yaml_example
+  custom: 'this is custom from [default]'
+  prefix_custom: "this is custom when prefix is set"
+  test_loaders:
+    dev: test_dev
+    prod: test_prod
+development:
+  custom: this will be overwritten
+  host: this will be overwritten
+  works: this will be overwritten
+---
+development:
+  custom: 'this is custom from [development]'
+  host: dev_server.com from yaml
+  works: yaml_example in dev env
+production:
+  custom: 'this is custom from [production]'
+  host: prod_server.com from yaml
+  works: yaml_example in prod env