Repository: dynaconf/dynaconf (python)

Issue:
[RFC] Implement TypedSettings

```python

#### Internal Dynaconf ####

from typing import Annotated, cast, TYPE_CHECKING, reveal_type
from dataclasses import dataclass
from dynaconf import Validator, Dynaconf


class TypedSettings:
    @classmethod
    def build(cls, **options):
        # Extract validators from cls.__annotations__[x].__metadata__
        return cast(cls, Dynaconf(**options))

#### Usage

from dynaconf import TypedSettings, Validator


@dataclass
class MySchema(TypedSettings):
    name: Annotated[str, Validator(ne="test")]
    port: int


# builder pattern
settings: MySchema = MySchema.build(name="test")


```





Pull request description:
feat: Typed Settings
closes #1104

We can release as a Tech Preview as it is a non-breaking feature

```python
from typing import Annotated
from dynaconf.typed import Dynaconf, Validator, Options

class Settings(Dynaconf):
    dynaconf_options = Options(
        envvar_prefix="MYAPP", 
        settings_file="settings.yaml",
    )

    host: str = "server.com"
    port: Annotated[int, Validator(gt=999)]
    validate_ssl: bool = True


settings = Settings(port=1080)

assert settings.HOST == "server.com"
assert settings.port >= 999
assert settings.get("validate_SSL") is True
```

Patch:
diff --git a/dynaconf/typed.py b/dynaconf/typed.py
new file mode 100644
index 0000000..0bdf9a6
--- /dev/null
+++ b/dynaconf/typed.py
@@ -0,0 +1,112 @@
+"""Implements wrappers, types and helpers for Schema based settings."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from dataclasses import dataclass
+from pathlib import Path
+from typing import cast
+
+from dynaconf.base import LazySettings as OriginalDynaconf
+from dynaconf.base import Settings as BaseDynaconfSettings
+from dynaconf.validator import ValidationError  # noqa
+from dynaconf.validator import Validator
+
+
+class Empty: ...
+
+
+empty = Empty()
+
+
+@dataclass
+class Options:
+    """Options to configure the initialization of Dynaconf Object.
+
+    NOTE: This must be used only with typed implementation of Dynaconf.
+
+    All options are just bypassed to the Dynaconf `__init__` method,
+    except those starting with `_`.
+
+    Options starting with `_` are only used on `__new__` when building the
+    new Dynaconf object, those values assumes opinionated defaults and rarely
+    needs to be changed.
+    """
+
+    # NOTE: On a dataclass the sentinel must be a Type not an instance
+
+    env: str | type = Empty
+    environments: bool | type = Empty
+    envvar_prefix: str | type = Empty
+    root_path: str | Path | type = Empty
+    settings_files: list[str] | type = Empty
+    settings_file: str | type = Empty
+
+    _trigger_validation: bool = True
+
+    def as_dict(self):
+        options_dict = asdict(self)
+        options_dict = {
+            k: v
+            for k, v in options_dict.items()
+            if (not k.startswith("_")) and v is not Empty
+        }
+        return options_dict
+
+
+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)
+
+        # Set init options for Dynaconf coming first from Options on Schema
+        init_options = options.as_dict()
+        # Add defaults defined on class as it was passed to Dynaconf(k=v)
+        init_options.update(defaults)
+        # Override from kwargs as if matching key was passed to Settings(k=v)
+        init_options.update(kwargs)
+
+        # Instantiate the new Dynaconf instance
+        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()
+
+        return cast(cls, new_cls)


Test patch:
diff --git a/tests/test_typed.py b/tests/test_typed.py
new file mode 100644
index 0000000..8bc45f9
--- /dev/null
+++ b/tests/test_typed.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from typing import Annotated
+
+import pytest
+
+from dynaconf.typed import Dynaconf
+from dynaconf.typed import Options
+from dynaconf.typed import ValidationError
+from dynaconf.typed import Validator
+
+
+def test_immediate_validation():
+    class Settings(Dynaconf):
+        host: Annotated[str, Validator(ne="denied.com")]
+
+    with pytest.raises(ValidationError):
+        Settings(host="denied.com")
+
+
+def test_lazy_validation_with_options():
+    class Settings(Dynaconf):
+        dynaconf_options = Options(_trigger_validation=False)
+        host: Annotated[str, Validator(ne="denied.com")]
+
+    # Dont trigger validation immediately
+    settings = Settings(host="denied.com")
+
+    with pytest.raises(ValidationError):
+        settings.validators.validate()
+
+
+def test_validator_from_types():
+    class Settings(Dynaconf):
+        host: str = "server.com"
+        port: int
+
+    with pytest.raises(ValidationError) as exc:
+        Settings()
+
+    assert "port is required" in str(exc)
+
+
+def test_default_value_from_types():
+    class Settings(Dynaconf):
+        host: str = "batata.com"
+        port: int = 999
+
+    settings = Settings()
+    assert settings.host == "batata.com"
+    assert settings.PORT == 999
+
+
+def test_options_contains_only_valid_and_set_attributes():
+    options = Options(
+        env="dev",
+        envvar_prefix="BATATA",
+        settings_file="foo.json",
+        _trigger_validation=False,  # not included
+    )
+    options_dict = options.as_dict()
+    assert options_dict == {
+        "env": "dev",
+        "envvar_prefix": "BATATA",
+        "settings_file": "foo.json",
+    }