Repository: dynaconf/dynaconf
Pull request #1109 (2024-06-19 18:42:47)

fix: Support Nested Subtypes and Enclosed types
related #1108

> WARNING: This code is not perfect yet, we can merge because we are in tech preview for thisw feature, however the implementation is bad and needs refactoring.


```python
    class Database(Nested):
        host: str = "server.com"
        port: Annotated[int, Validator(gt=999)]
        engine: str = "postgres"
        versions: list[int] = [1, 2, 4]
        extra: Optional[dict]

    class Settings(Dynaconf):
        dynaconf_options = Options(
            envvar_prefix="MYTYPEDAPP",
        )
        database: Database
        batata: Annotated[int, Validator(gt=999)]

    os.environ["MYTYPEDAPP_BATATA"] = "1000"
    os.environ["MYTYPEDAPP_DATABASE__PORT"] = "5000"
    settings = Settings()

    assert settings.database.port == 5000
    assert settings.database.host == "server.com"
    assert settings.database.engine == "postgres"

```

github-actions[bot]: ## Test Results (Python 3.9)
493 tests  +2   493 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") +2   37s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") -1s
    1 suites ±0       0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0 
    1 files   ±0       0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 

Results for commit 66311265. ± Comparison against base commit 0cdc5e73.

[test-results]:data:application/gzip;base64,H4sIAPEnc2YC/13MSw7CIBSF4a00jB2AlAt1M+ZKIbmxLYbHyLh3adU+HH7/Sc6TeRpcYpdGnBqWCuUVfYmYKUyVUlfXJc9b28mfrqlY+5/u9KiJr8EjDYfgYgzxW2KZts8Zx8tP2R4X7w4X7/9sGEfKFQxACnEGdUPjtXSGmw4cKtUqDx65kKCsM71mrzc0a9E9BAEAAA==

github-actions[bot]: ## Test Results (Python 3.12)
493 tests  +2   493 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") +2   46s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") -1s
    1 suites ±0       0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0 
    1 files   ±0       0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 

Results for commit 66311265. ± Comparison against base commit 0cdc5e73.

[test-results]:data:application/gzip;base64,H4sIAP4nc2YC/13MSw7CIBSF4a00jB2AwC11M+ZKIbmxLYbHyLh3adU+HH7/Sc6TeRpcYpdGnBqWCuUVfYmYKUyVCqrrkudNdfKnayrW/qc7PWria/BIwyG4GEP8llim7XPG8fJTtsfFu8PF+z8bxpFyBQOQQpxB39D4VjrDTQcOtVbag0cuJGjrTN+y1xsS/Wi+BAEAAA==


Patch:
diff --git a/.gitignore b/.gitignore
index a989c47..d7102db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ __pycache__/
 env/
 venv/
 .venv/
+.venv*/
 .virtualenv/
 build/
 develop-eggs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 96d4a89..82ca12f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,16 +1,16 @@
-exclude: "(^dynaconf/vendor+.|^venv/+.|^.venv/+.|^env/+.|^.tox/+.|^build/+.|docs)"
+exclude: "(^dynaconf/vendor+.|^venv/+.|^.venv/+.|^env/+.|^.tox/+.|^build/+.|docs|CHANGELOG.md)"
 default_language_version:
     python: python3
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.3.0
+    rev: v4.6.0
     hooks:
       - id: check-yaml
         args: [--allow-multiple-documents]
       - id: check-merge-conflict
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: v0.3.0
+    rev: v0.4.10
     hooks:
       # Sort imports.
       - id: ruff
@@ -20,12 +20,12 @@ repos:
       # Run the formatter.
       - id: ruff-format
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v0.961
+    rev: v1.10.0
     hooks:
       - id: mypy
         files: ^dynaconf/
   - repo: https://github.com/crate-ci/typos
-    rev: v1.18.2
+    rev: v1.22.7
     hooks:
       - id: typos
         args: []  # Overrides default to write changes.
diff --git a/3.x-release-notes.md b/3.x-release-notes.md
deleted file mode 100644
index 0753c63..0000000
--- a/3.x-release-notes.md
+++ /dev/null
@@ -1,151 +0,0 @@
-# Dynaconf 3.0.0
-
-In Dynaconf 3.0.0 we introduced some improvements and with
-those improvements it comes some **breaking changes.**
-
-Some of the changes were discussed on the **1st Dynaconf community meeting** [video is available](https://www.twitch.tv/videos/657033043) and [meeting notes #354](https://github.com/dynaconf/dynaconf/issues/354).
-
-
-## Improvements
-
-- Validators now implements `|` and `&` operators to allow `Validator() &| Validator()` and has more `operations` available such as `len_eq, len_min, len_max, startswith` [#353](https://github.com/dynaconf/dynaconf/pull/353).
-- First level variables are now allowed to be `lowercase` it is now possible to access `settings.foo` or `settings.FOO` [#357](https://github.com/dynaconf/dynaconf/pull/357).
-- All Dependencies are now vendored, so when installing Dynaconf is not needed to install any dependency.
-- Dynaconf configuration options are now aliased so when creating an instance of `LazySettings|FlaskDynaconf|DjangoDynaconf` it is now possible to pass instead of `ENVVAR_PREFIX_FOR_DYNACONF` just `envvar_prefix` and this lowercase alias is now accepted.
-- Fixed bugs in `merge` and deprecated the `@reset` token.
-- Added implementation for `__dir__` to allow auto complete for terminal and IDEs.
-- Add option to override mount point for vault server.
-- `LazySettings` is now aliased to `Dynaconf`
-- `Dynaconf` class now accepts a parameter `validators=[Validator, ...]` that will be immediately evaluated when passed.
-
-## Breaking Changes
-
-> **NOTE 1** `from dynaconf import settings` will keep working until version `4.0.x` to allow users to migrate gradually without having breaking changes but it will raise some DeprecationWarnings.
-
-
-> **NOTE 2** If you are using `FLASK` or `DJANGO` plugins there will be no breaking change on the framework settings, **a.k.a your project will keep working fine!**.
-
-
-#### `dynaconf.settings` global settings instance is now deprecated.
-
-Users are now supposed to create their own instance of `settings` instead of using the deprecated `from dynaconf import settings`.
-
-**project/config.py**
-```python
-from dynaconf import Dynaconf
-
-settings = Dynaconf(**options)
-```
-
-and then in your program you do `from project.config import settings` instead of `from dynaconf import settings`.
-
-The `**options` are any of the [dynaconf config options](https://www.dynaconf.com/configuration/)
-
-ex:
-
-```python
-settings = Dynaconf(
-    settings_file=["settings.toml", ".secrets.toml"],
-    envvar_prefix="MYPROJECT"
-)
-```
-
-> With the above instance your `settings` will load data from those 2 `.toml` files and also environment variables prefixed with `MYPROOJECT` e.g: `export MYPROJECT_FOO=1`
-
-
-#### Dynaconf is now envless by default.
-
-Historically Dynaconf worked in a multi layered environments for
-loading data from files, so you were supposed to have a file like:
-
-```toml
-[default]
-key = 'value'
-
-[production]
-key = 'value'
-```
-
-**Now starting on 3.0.0** the environments are disabled by default, so the same file can be created as.
-
-```toml
-key = 'value'
-```
-
-And you can still have the environments but only if you explicit specify it when creating your instance.
-
-
-To have the previous behavior supporting all environments from the first level of a file.
-
-```python
-settings = Dynaconf(
-    environments=True
-)
-```
-
-or to strictly specify your environments.
-
-```python
-settings = Dynaconf(
-    environments=["default", "prodution", "testing", "xyz"],
-)
-```
-
-Once it is defined all the other functionalities still works such as.
-
-```bash
-export ENV_FOR_DYNACONF=production myprogram.py
-```
-
-#### Automatic load of `settings.*` files are disabled by default.
-
-Starting on 3.x Dynaconf will now only load files passed explicitly to **settings_file** option when creating the instance or specified as env var **SETTINGS_FILE_FOR_DYNACONF**.
-
-When creating the settings instance the file **paths** or **globs** should be specified unless you want only env vars and external loaders to be loaded.
-
-Single file
-
-
-```py
-settings = Dynaconf(
-    settings_file="settings.toml",
-)
-```
-
-Or multiple files.
-
-
-```py
-settings = Dynaconf(
-    settings_file=["settings.toml", "path/*.yaml"],
-)
-```
-
-Dynaconf will respect the exact order informed by you on the `settings_file` parameter.
-
-> **NOTE** the parameters `preload`, `includes` and `secret` continues to work in the same way as before.
-> **NOTE** the parameters `preload`, `includes` and `secret` continues to work in the same way as before.
-
-#### Load of dotenv `.env` is now disabled by default.
-
-Dynaconf will **NO MORE** automatically load the data from `.env` file and will do that only
-if `load_dotenv=True` is provided.
-
-
-```py
-settings = Dynaconf(
-    load_dotenv=True
-)
-```
-
-BY default it keeps searching for files in the current `PROJECT_ROOT` named `.env` and the `dotenv_path` accepts a relative path such as `.env` or  `path/to/.env`
-
-#### DEBUG Logging is now completely removed
-
-There is no more `logging` or `debug` messages being printed, so the variable `DEBUG_LEVEL` has no more effect.
-
-
-## Coming in 3.1.x
-
-- Support for Pydantic BaseSettings for Validators.
-- Support for replacement of `toml` parser on envvars loader.
diff --git a/Makefile b/Makefile
index d52757a..76b9fe9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,5 @@
 SHELL := /bin/bash
-.PHONY: all citest clean mypy dist docs install publish run-pre-commit run-tox setup-pre-commit test test_functional test_only test_redis test_vault help coverage-report
-
+.PHONY: all citest clean mypy dist docs install publish run-pre-commit run-tox setup-pre-commit test test_functional test_only test_redis test_vault help coverage-report watch_test
 help:
 	@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$'
 
@@ -19,6 +18,10 @@ test_redis:
 watch:
 	ls **/**.py | entr py.test -m "not integration" -s -vvv -l --tb=long --maxfail=1 tests/
 
+watch_test:
+	# make watch_test ARGS="tests/test_typed.py -k union"
+	ls **/**.py | entr py.test --showlocals -sx -vv --disable-warnings --tb=short $(ARGS)
+
 watch_django:
 	ls {**/**.py,~/.virtualenvs/dynaconf/**/**.py,.venv/**/**.py} | PYTHON_PATH=. DJANGO_SETTINGS_MODULE=foo.settings entr tests_functional/django_example/manage.py test polls -v 2
 
@@ -106,7 +109,7 @@ run-tox:
 	rm -rf .tox
 
 minify_vendor:
-	# Ensure vendor is source and cleanup vendor_src bck folder
+	# Ensure vendor is source and cleanup vendor_src backup folder
 	ls dynaconf/vendor/source && rm -rf dynaconf/vendor_src
 
 	# Backup dynaconf/vendor folder as dynaconf/vendor_src
diff --git a/docs/release_notes.md b/docs/release_notes.md
index e659e8f..662e321 100644
--- a/docs/release_notes.md
+++ b/docs/release_notes.md
@@ -182,7 +182,7 @@ or to strictly specify your environments.
 
 ```python
 settings = Dynaconf(
-    environments=["default", "prodution", "testing", "xyz"],
+    environments=["default", "production", "testing", "xyz"],
 )
 ```
 
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 4624adb..279f3a0 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -263,7 +263,11 @@ class Settings:
             )
         for key, value in kwargs.items():
             self.set(
-                key, value, loader_identifier="init_kwargs", validate=False
+                key,
+                value,
+                loader_identifier="init_kwargs",
+                validate=False,
+                dotted_lookup=True,
             )
         # execute loaders only after setting defaults got from kwargs
         self._defaults = kwargs
diff --git a/dynaconf/typed.py b/dynaconf/typed.py
index 0bdf9a6..9f270f7 100644
--- a/dynaconf/typed.py
+++ b/dynaconf/typed.py
@@ -1,11 +1,16 @@
 """Implements wrappers, types and helpers for Schema based settings."""
 
-from __future__ import annotations
+from __future__ import annotations  # WARNING: remove this when debugging
 
 from dataclasses import asdict
 from dataclasses import dataclass
 from pathlib import Path
+from typing import Annotated
+from typing import Callable
 from typing import cast
+from typing import get_args
+from typing import get_origin
+from typing import Union
 
 from dynaconf.base import LazySettings as OriginalDynaconf
 from dynaconf.base import Settings as BaseDynaconfSettings
@@ -19,7 +24,7 @@ class Empty: ...
 empty = Empty()
 
 
-@dataclass
+@dataclass  # wanted to pass (kw_only=True) but that works only for 3.10+
 class Options:
     """Options to configure the initialization of Dynaconf Object.
 
@@ -54,32 +59,20 @@ class Options:
         return options_dict
 
 
+class DynaconfSchemaError(Exception): ...
+
+
+class Nested: ...
+
+
 class Dynaconf(BaseDynaconfSettings):
     """Implementation of Dynaconf that is aware of type annotations."""
 
     def __new__(cls, *args, **kwargs):
         options = getattr(cls, "dynaconf_options", Options())
-        validators = kwargs.pop("validators", [])
-        defaults = {}
-
-        # Extract Validators and default value from type Annotations
-        for name, annotation in cls.__annotations__.items():
-            default_value = getattr(cls, name, empty)
-            if default_value is not empty:
-                defaults[name] = default_value
-
-            if _validators := getattr(annotation, "__metadata__", None):
-                for _validator in _validators:
-                    _validator.names = [name]
-                    if default_value is empty:
-                        _validator.must_exist = True  # required
-                    validators.append(_validator)
-            else:
-                _validator = Validator(name, is_type_of=annotation)
-
-                if default_value is empty:
-                    _validator.must_exist = True  # required
-                validators.append(_validator)
+        defaults, validators = extract_defaults_and_validators_from_typing(cls)
+        passed_in_validators = kwargs.pop("validators", [])
+        validators += passed_in_validators
 
         # Set init options for Dynaconf coming first from Options on Schema
         init_options = options.as_dict()
@@ -92,21 +85,215 @@ class Dynaconf(BaseDynaconfSettings):
         new_cls = OriginalDynaconf(*args, **init_options)
         new_cls.__annotations__ = cls.__annotations__
 
-        # Alternative for setting defaults:
-        # WARNING: This triggers the settings initialization
-        # so it is not Lazy anymore, should we consider accumulating
-        # default values somewhere and retrieving on `settings.get` ?
-        # or add default_value to Validator above?
-        #
-        # for name, value in defaults.items():
-        #     if new_cls.get(name, empty) is empty:
-        #         new_cls.set(name, value, loader_identifier="type_annotations")
-
         if validators:
             new_cls.validators.register(*validators)
 
         # Trigger validation if not explicitly disabled
         if options._trigger_validation:
-            new_cls.validators.validate()
+            new_cls.validators.validate_all()
 
         return cast(cls, new_cls)
+
+
+def is_union(annotation) -> bool:
+    """Tell if an annotation is strictly Union."""
+    return get_origin(annotation) is Union
+
+
+def is_optional(annotation) -> bool:
+    """Tell if an annotation is strictly typing.Optional or Union[**, None]"""
+    return is_union(annotation) and type(None) in get_args(annotation)
+
+
+# Types that can be used on `field: list[x]`
+ALLOWED_ENCLOSED_TYPES = (
+    Nested,
+    int,
+    float,
+    str,
+    bool,
+    type(None),
+    list,
+    tuple,
+    dict,
+)
+
+
+def get_annotations(schema_cls) -> dict:
+    """NOTE: move to inspect.get_annotations when Python 3.9 is dropped"""
+    if isinstance(schema_cls, type):
+        return schema_cls.__dict__.get("__annotations__", {})
+    else:
+        return getattr(schema_cls, "__annotations__", {})
+
+
+def get_type_name(_type):
+    if name := getattr(_type, "__name__", None):
+        return name
+    return str(_type)
+
+
+def extract_defaults_and_validators_from_typing(
+    schema_cls,
+    path: tuple | None = None,
+    defaults: dict | None = None,
+    validators: list | None = None,
+) -> tuple[dict, list]:
+    """From schema class extract defaults and validators from annotations.
+
+    Recursively handle all `Nested` subtypes.
+
+    key: int -> Validator(key, is_type_of=int)
+    key: int = 1 -> Validator(key, is_type_of=int) + defaults[key] = 1
+    key: Annotated[int, Validator()] -> Validator(key, is_type_of=int)
+    """
+    path = path or tuple()
+    defaults = defaults or {}
+    validators = validators or []
+    schema_annotations = get_annotations(schema_cls)
+
+    for name, annotation in schema_annotations.items():
+        default_value = getattr(schema_cls, name, empty)
+        full_path = path + (name,)  # E.g, ("path", "to", "name")
+        full_name = ".".join(full_path)  # E.g, path.to.name
+        _annotated_validators = None
+        _type_args: tuple = tuple()
+
+        _type = get_origin(annotation)  # type from X[type,]
+        if _type is Annotated:
+            _type, *_annotated_validators = get_args(annotation)
+        else:
+            _type_args = get_args(annotation)
+
+        if default_value is not empty:
+            defaults[full_name] = default_value
+
+        # it is a field: Nested
+        if isinstance(annotation, type) and issubclass(annotation, Nested):
+            defaults, validators = extract_defaults_and_validators_from_typing(
+                annotation, full_path, defaults, validators
+            )
+        # It is a field: Annotated[type, Validator(...)]
+        elif _annotated_validators:
+            if isinstance(_type, type) and issubclass(_type, Nested):
+                raise DynaconfSchemaError(
+                    "Nested type cannot be Annotated, "
+                    f"set the validators on the {_type.__name__!r} fields "
+                    f"this error is caused by "
+                    f"`{full_name}: Annotated[{_type.__name__}, *]`"
+                )
+
+            for _validator in _annotated_validators:
+                _validator.names = [full_name]
+                _validator.is_type_of = _type
+                if default_value is empty and not is_optional(annotation):
+                    _validator.must_exist = True  # required
+                validators.append(_validator)
+
+        # it is a field: list[Nested] (or any ALLOWED_ENCLOSED_TYPES)
+        elif _type_args and _type in (list, tuple):
+            _validator = Validator(full_name, is_type_of=_type)
+            # ADR: Decided to consider only the first enclosed type
+            # which means T[T] is allowed, but T[T, T] not
+            # This is subject to change in future, PRs welcome.
+            inner_type, *more_enclosed_types = _type_args
+            if more_enclosed_types:
+                raise DynaconfSchemaError(
+                    f"Invalid enclosed type for {full_name} "
+                    "enclosed types supports only one argument "
+                    f"{get_type_name(_type)}[{get_type_name(inner_type)}] "
+                    "is allowed, but this error is caused by "
+                    f"`{full_name}: {get_type_name(_type)}[{get_type_name(inner_type)}, "
+                    f"{', '.join(get_type_name(i) for i in more_enclosed_types)}]`"
+                )
+
+            # # Handle list[Union...]
+            if is_union(inner_type):
+                all_types = get_args(inner_type)
+            else:
+                all_types = (inner_type,)
+
+            for item in all_types:
+                if not issubclass(item, ALLOWED_ENCLOSED_TYPES):
+                    raise DynaconfSchemaError(
+                        f"Invalid enclosed type {get_type_name(item)!r} "
+                        f"must be one of {ALLOWED_ENCLOSED_TYPES} "
+                        f"this error is caused by "
+                        f"`{full_name}: {get_type_name(_type)}[{get_type_name(inner_type)}]`"
+                    )
+
+            enclosed_defaults, enclosed_validators = (
+                extract_defaults_and_validators_from_typing(inner_type)
+            )
+            if enclosed_defaults:
+                raise DynaconfSchemaError(
+                    "List enclosed types cannot define default values. "
+                    f"{inner_type.__name__!r} defines {enclosed_defaults}. "
+                    f"this error is caused by "
+                    f"`{full_name}: {_type.__name__}[{inner_type.__name__}]`"
+                )
+
+            _validator.condition = validator_condition_factory(
+                enclosed_validators, full_name, inner_type
+            )
+
+            if default_value is empty and not is_optional(annotation):
+                _validator.must_exist = True  # required
+
+            validators.append(_validator)
+
+        # It is just a bare type annotation like field: type
+        else:
+            _validator = Validator(full_name, is_type_of=annotation)
+            if default_value is empty and not is_optional(annotation):
+                _validator.must_exist = True  # required
+            validators.append(_validator)
+
+    return defaults, validators
+
+
+def validator_condition_factory(validators, prefix, _type) -> Callable:
+    """takes _type: T and generates a function to validate a value against it"""
+
+    def validator_condition(values):
+        if isinstance(_type, type) and issubclass(_type, Nested):  # a dict
+            validator_messages = {
+                "must_exist_true": prefix
+                + "[].{name} is required in env {env}",
+                "must_exist_false": prefix
+                + "[].{name} cannot exists in env {env}",
+                "condition": prefix
+                + "[].{name} invalid for {function}({value}) in env {env}",
+                "operations": (
+                    prefix + "[].{name} must {operation} {op_value} "
+                    "but it is {value} in env {env}"
+                ),
+            }
+            for value in values:
+                for validator in validators:
+                    validator.messages.update(validator_messages)
+                    validator.validate(BaseDynaconfSettings(**value))
+        else:  # not a dict
+            validator_messages = {
+                "must_exist_true": "{name}[] is required in env {env}",
+                "must_exist_false": "{name}[] cannot exists in env {env}",
+                "condition": "{name}[] invalid for {function}({value}) in env {env}",
+                "operations": (
+                    "{name}[] must {operation} {op_value} "
+                    "but it is {value} in env {env}"
+                ),
+            }
+            for value in values:
+                _settings = BaseDynaconfSettings()
+                _settings.set(prefix, value)
+                _validator = Validator(
+                    prefix,
+                    is_type_of=_type,
+                    messages=validator_messages,
+                )
+                _validator.validate(_settings)
+
+        # No validation error
+        return True
+
+    return validator_condition
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index 5b3477f..c0ab7d6 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -72,6 +72,8 @@ class Validator:
         len_ne: len(value) != other
         len_min: len(value) > other
         len_max: len(value) < other
+        startswith: value.startswith(term)
+        endswith:  value.endswith(term)
 
     `env` is which env to be checked, can be a list or
     default is used.
@@ -107,6 +109,14 @@ class Validator:
         }
     )
 
+    def __repr__(self):
+        return (
+            f"{self.__class__.__name__}("
+            f"{','.join(self.names)}, "
+            f"required={bool(self.must_exist)}, "
+            f"operations={self.operations})"
+        )
+
     def __init__(
         self,
         *names: str,
diff --git a/dynaconf/validator_conditions.py b/dynaconf/validator_conditions.py
index 039dede..e4ad713 100644
--- a/dynaconf/validator_conditions.py
+++ b/dynaconf/validator_conditions.py
@@ -5,6 +5,10 @@ Implement basic assertions to be used in assertion action
 
 from __future__ import annotations
 
+from typing import get_args
+from typing import get_origin
+from typing import Union
+
 
 def eq(value, other):
     """Equal"""
@@ -43,6 +47,10 @@ def identity(value, other):
 
 def is_type_of(value, other):
     """Type check"""
+    # Support Union[T, Y, Z] for python 3.9
+    # on python 3.10 isinstance can handle Union
+    if get_origin(other) is Union:
+        other = get_args(other)
     return isinstance(value, other)
 
 
diff --git a/ruff.toml b/ruff.toml
index a2a2901..e7fb960 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -40,5 +40,7 @@ ignore = [
 [lint.isort]
 force-single-line = true
 order-by-type = false
-required-imports = ["from __future__ import annotations"]
+# Cant require the future annotations because it breaks typed settings.
+# typed settings are supposed to work only with Python 3.10+
+# required-imports = ["from __future__ import annotations"]
 known-first-party = ["dynaconf"]


Test patch:
diff --git a/tests/test_typed.py b/tests/test_typed.py
index 8bc45f9..d3ce3ff 100644
--- a/tests/test_typed.py
+++ b/tests/test_typed.py
@@ -1,10 +1,13 @@
-from __future__ import annotations
-
+import os
 from typing import Annotated
+from typing import Optional
+from typing import Union
 
 import pytest
 
 from dynaconf.typed import Dynaconf
+from dynaconf.typed import DynaconfSchemaError
+from dynaconf.typed import Nested
 from dynaconf.typed import Options
 from dynaconf.typed import ValidationError
 from dynaconf.typed import Validator
@@ -23,7 +26,7 @@ def test_lazy_validation_with_options():
         dynaconf_options = Options(_trigger_validation=False)
         host: Annotated[str, Validator(ne="denied.com")]
 
-    # Dont trigger validation immediately
+    # Don't trigger validation immediately
     settings = Settings(host="denied.com")
 
     with pytest.raises(ValidationError):
@@ -35,11 +38,9 @@ def test_validator_from_types():
         host: str = "server.com"
         port: int
 
-    with pytest.raises(ValidationError) as exc:
+    with pytest.raises(ValidationError, match="port is required"):
         Settings()
 
-    assert "port is required" in str(exc)
-
 
 def test_default_value_from_types():
     class Settings(Dynaconf):
@@ -64,3 +65,280 @@ def test_options_contains_only_valid_and_set_attributes():
         "envvar_prefix": "BATATA",
         "settings_file": "foo.json",
     }
+
+
+def test_enclosed_type_raises_for_defaults():
+    class Plugin(Nested):
+        name: str = "default_name"
+
+    class Settings(Dynaconf):
+        plugins: list[Plugin]
+
+    msg = "List enclosed types cannot define default values"
+    with pytest.raises(DynaconfSchemaError, match=msg):
+        Settings()
+
+
+def test_enclosed_type_raises_for_invalid_enclosed():
+    class Dummy: ...
+
+    class Settings(Dynaconf):
+        plugins: list[Dummy]
+
+    msg = "Invalid enclosed type"
+    with pytest.raises(DynaconfSchemaError, match=msg):
+        Settings()
+
+
+def test_union_enclosed_type_raises_for_invalid_enclosed():
+    class A: ...
+
+    class Settings(Dynaconf):
+        numbers: list[Union[int, float, bool, A]]
+
+    msg = "Invalid enclosed type 'A'"
+    with pytest.raises(DynaconfSchemaError, match=msg):
+        Settings()
+
+
+def test_annotated_forbidden_with_nested():
+    class Plugin(Nested):
+        name: str
+
+    class Settings(Dynaconf):
+        # This is not allowed, should add validators on the nested type itself
+        plug: Annotated[Plugin, Validator()]
+
+    msg = "Nested type cannot be Annotated"
+    with pytest.raises(DynaconfSchemaError, match=msg):
+        Settings()
+
+
+def test_enclosed_forbidden_with_more_than_one_arg():
+    class Plugin(Nested):
+        value: list[Union[int, float, bool], int, float]
+
+    class Settings(Dynaconf):
+        plug: Plugin
+
+    msg = "Invalid enclosed type for plug.value enclosed types"
+    with pytest.raises(DynaconfSchemaError, match=msg):
+        Settings()
+
+
+def test_enclosed_type_validates(monkeypatch):
+    class Plugin(Nested):
+        name: str
+
+    class Settings(Dynaconf):
+        plugins: list[Plugin]
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_PLUGINS", '@json [{"name": true}]')
+        msg = r"plugins\[].name must is_type_of"
+        with pytest.raises(ValidationError, match=msg):
+            Settings()
+
+
+def test_union_type_validates(monkeypatch):
+    class Settings(Dynaconf):
+        number: Union[int, float, bool]
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_NUMBER", "one")
+        msg = r"number must is_type_of"
+        with pytest.raises(ValidationError, match=msg):
+            Settings()
+
+
+def test_union_enclosed_type_validates(monkeypatch):
+    class Settings(Dynaconf):
+        numbers: list[Union[int, float, bool]]
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_NUMBERS", '["banana"]')
+        msg = r"numbers\[] must is_type_of"
+        with pytest.raises(ValidationError, match=msg):
+            Settings()
+
+
+def test_union_enclosed_type_works(monkeypatch):
+    class Settings(Dynaconf):
+        numbers: list[Union[int, float, bool]]
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_NUMBERS", "[1, 4.2, true]")
+        settings = Settings()
+        assert settings.numbers == [1, 4.2, True]
+
+
+def test_deeply_enclosed_type_validates(monkeypatch):
+    class Plugin(Nested):
+        name: str
+
+    class Extra(Nested):
+        plugins: list[Plugin]
+
+    class Database(Nested):
+        extra: Extra
+
+    class Settings(Dynaconf):
+        database: Database
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_DATABASE__EXTRA__PLUGINS", '@json [{"name": null}]')
+        msg = r"database.extra.plugins\[].name must is_type_of"
+        with pytest.raises(ValidationError, match=msg):
+            Settings()
+
+
+def test_deeply_enclosed_type_validates_with_bare_type(monkeypatch):
+    class Extra(Nested):
+        plugins: list[str]
+
+    class Database(Nested):
+        extra: Extra
+
+    class Settings(Dynaconf):
+        database: Database
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_DATABASE__EXTRA__PLUGINS", "@json [12]")
+        msg = r"database.extra.plugins\[] must is_type_of"
+        with pytest.raises(ValidationError, match=msg):
+            Settings()
+
+
+def test_deeply_enclosed_type_validates_with_bare_type_allows_empty(
+    monkeypatch,
+):
+    """Ensure empty list is valid when list has enclosed type.
+
+    list[T] will validate [T] but also an empty list []
+    NOTE: It does not look right for me, but looks like it is valid.
+    https://mypy-play.net/?gist=d16da1574497dbe585eab679f09432ef
+    """
+
+    class Extra(Nested):
+        plugins: list[str]
+
+    class Database(Nested):
+        extra: Extra
+
+    class Settings(Dynaconf):
+        database: Database
+
+    with monkeypatch.context() as m:
+        m.setenv("DYNACONF_DATABASE__EXTRA__PLUGINS", "@json []")
+        assert Settings().database.extra.plugins == []
+
+
+def test_sub_types_works(monkeypatch):
+    class Plugin(Nested):
+        name: str
+        path: Optional[
+            str
+        ]  # NOTE: This must mark path as not required on dict
+        order: Union[int, bool, None]  # This is the same as above ^
+
+    class Auth(Nested):
+        username: str
+        password: int
+
+    class Extra(Nested):
+        log: bool = True
+        socket_type: Annotated[int, Validator(is_in=[1, 2, 3])] = 1
+        transactions: bool
+        auth: Auth
+        plugins: list[Plugin]
+        # Make this work
+        batatas: list[int] = [1, 2]
+
+    class Database(Nested):
+        host: str = "server.com"
+        port: Annotated[int, Validator(gt=999)]
+        engine: str = "postgres"
+        extra: Extra
+
+    class Settings(Dynaconf):
+        dynaconf_options = Options(
+            envvar_prefix="MYTYPEDAPP",
+        )
+        version: Union[int, float, bool]
+        database: Database
+        batata: Annotated[int, Validator(gt=999)]
+
+    with monkeypatch.context() as m:
+        m.setenv("MYTYPEDAPP_version", "4.4")
+        m.setenv("MYTYPEDAPP_BATATA", "1000")
+        m.setenv("MYTYPEDAPP_DATABASE__PORT", "5000")
+        m.setenv("MYTYPEDAPP_DATABASE__EXTRA__TRANSACTIONS", "true")
+        m.setenv("MYTYPEDAPP_DATABASE__EXTRA__AUTH__USERNAME", "admin")
+        m.setenv("MYTYPEDAPP_DATABASE__EXTRA__AUTH__PASSWORD", "1234")
+        m.setenv("MYTYPEDAPP_database__extra__plugins___0__name", "MyPlugin")
+        m.setenv("MYTYPEDAPP_database__extra__plugins___0__order", "1")
+        # NOTE: This key is not in the Plugin spec, must be disallowed
+        # and raise validation
+        m.setenv("MYTYPEDAPP_database__extra__plugins___0__batata", "1")
+
+        settings = Settings()
+        assert settings.database.port == 5000
+        assert settings.database.host == "server.com"
+        assert settings.database.engine == "postgres"
+        assert isinstance(settings.database.extra.plugins[0]["name"], str)
+
+
+def test_sub_types_fail_validation(monkeypatch):
+    class Database(Nested):
+        host: str = "server.com"
+        port: Annotated[int, Validator(gt=999)]
+        engine: str = "postgres"
+
+    class Settings(Dynaconf):
+        dynaconf_options = Options(
+            envvar_prefix="MYTYPEDAPP",
+        )
+        database: Database
+        batata: Annotated[int, Validator(gt=999)]
+
+    with monkeypatch.context() as m:
+        m.setenv("MYTYPEDAPP_DATABASE__PORT", "5000")
+        os.environ["MYTYPEDAPP_DATABASE__PORT"] = "888"
+
+        error_msg = "database.port must gt 999 but it is 888"
+        with pytest.raises(ValidationError, match=error_msg):
+            Settings()
+
+
+def test_deeply_nested_fail_validation(monkeypatch):
+    """Ensure validation happens on deeply nested type"""
+
+    class Auth(Nested):
+        username: str
+        password: int
+
+    class Extra(Nested):
+        log: bool = True
+        socket_type: Annotated[int, Validator(is_in=[1, 2, 3])] = 1
+        auth: Auth
+
+    class Database(Nested):
+        host: str = "server.com"
+        port: Annotated[int, Validator(gt=999)]
+        engine: str = "postgres"
+        extra: Extra
+
+    class Settings(Dynaconf):
+        dynaconf_options = Options(
+            envvar_prefix="MYTYPEDAPP",
+        )
+        database: Database
+        batata: Annotated[int, Validator(gt=999)]
+
+    with monkeypatch.context() as m:
+        m.setenv("MYTYPEDAPP_DATABASE__PORT", "5000")
+        os.environ["MYTYPEDAPP_DATABASE__PORT"] = "1000"
+
+        error_msg = "database.extra.auth.username is required"
+        with pytest.raises(ValidationError, match=error_msg):
+            Settings()