From 2c1cc4bd4390ae989a25641c67c723af3fe19c89 Mon Sep 17 00:00:00 2001 From: ovsds Date: Mon, 16 Dec 2024 18:12:21 +0100 Subject: [PATCH] feat: BI-5983 add dl-settings --- lib/dl_auth_api_lib/dl_auth_api_lib/app.py | 6 +- .../dl_auth_api_lib/oauth/google.py | 2 +- .../dl_auth_api_lib/oauth/yandex.py | 2 +- .../dl_auth_api_lib/settings.py | 64 +---- .../dl_auth_api_lib/utils/pydantic.py | 17 -- .../dl_auth_api_lib_tests/conftest.py | 13 +- lib/dl_auth_api_lib/pyproject.toml | 2 + lib/dl_settings/dl_settings/__init__.py | 18 ++ lib/dl_settings/dl_settings/base/__init__.py | 20 ++ lib/dl_settings/dl_settings/base/settings.py | 56 ++++ lib/dl_settings/dl_settings/base/typed.py | 108 ++++++++ .../dl_settings_tests/unit/base}/__init__.py | 0 .../unit/base/test_settings.py | 149 +++++++++++ .../dl_settings_tests/unit/base/test_typed.py | 244 ++++++++++++++++++ .../dl_settings_tests/unit/conftest.py | 11 + .../dl_settings_tests/utils/__init__.py | 6 + .../dl_settings_tests/utils/tmp_configs.py | 36 +++ metapkg/poetry.lock | 5 +- metapkg/pyproject.toml | 2 +- 19 files changed, 669 insertions(+), 92 deletions(-) delete mode 100644 lib/dl_auth_api_lib/dl_auth_api_lib/utils/pydantic.py create mode 100644 lib/dl_settings/dl_settings/base/__init__.py create mode 100644 lib/dl_settings/dl_settings/base/settings.py create mode 100644 lib/dl_settings/dl_settings/base/typed.py rename lib/{dl_auth_api_lib/dl_auth_api_lib/utils => dl_settings/dl_settings_tests/unit/base}/__init__.py (100%) create mode 100644 lib/dl_settings/dl_settings_tests/unit/base/test_settings.py create mode 100644 lib/dl_settings/dl_settings_tests/unit/base/test_typed.py create mode 100644 lib/dl_settings/dl_settings_tests/utils/__init__.py create mode 100644 lib/dl_settings/dl_settings_tests/utils/tmp_configs.py diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/app.py b/lib/dl_auth_api_lib/dl_auth_api_lib/app.py index 927d0fae3..392868387 100644 --- a/lib/dl_auth_api_lib/dl_auth_api_lib/app.py +++ b/lib/dl_auth_api_lib/dl_auth_api_lib/app.py @@ -21,7 +21,7 @@ from dl_auth_api_lib.oauth.yandex import YandexOAuthClient from dl_auth_api_lib.settings import ( AuthAPISettings, - register_auth_client, + BaseOAuthClient, ) from dl_auth_api_lib.views import google as google_views from dl_auth_api_lib.views import snowflake as snowflake_views @@ -90,5 +90,5 @@ def create_app(self, app_version: str | None = None) -> web.Application: return app -register_auth_client("yandex", YandexOAuthClient) -register_auth_client("google", GoogleOAuthClient) +BaseOAuthClient.register("yandex", YandexOAuthClient) +BaseOAuthClient.register("google", GoogleOAuthClient) diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/google.py b/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/google.py index 64149ed0f..d047393fd 100644 --- a/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/google.py +++ b/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/google.py @@ -15,7 +15,7 @@ class GoogleOAuthClient(BaseOAuthClient): - auth_type: str = "google" + type: str = pydantic.Field(alias="auth_type", default="google") client_id: str client_secret: str diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/yandex.py b/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/yandex.py index 131c03573..ee63283dc 100644 --- a/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/yandex.py +++ b/lib/dl_auth_api_lib/dl_auth_api_lib/oauth/yandex.py @@ -16,7 +16,7 @@ class YandexOAuthClient(BaseOAuthClient): - auth_type: str = "yandex" + type: str = pydantic.Field(alias="auth_type", default="yandex") client_id: str client_secret: str diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/settings.py b/lib/dl_auth_api_lib/dl_auth_api_lib/settings.py index 5775cdc1e..3213bd8eb 100644 --- a/lib/dl_auth_api_lib/dl_auth_api_lib/settings.py +++ b/lib/dl_auth_api_lib/dl_auth_api_lib/settings.py @@ -1,67 +1,13 @@ -import os -from typing import ( - Annotated, - Any, - Type, -) - import pydantic -import pydantic_settings -from dl_auth_api_lib.utils.pydantic import make_dict_factory +import dl_settings -class BaseOAuthClient(pydantic.BaseModel): +class BaseOAuthClient(dl_settings.TypedBaseModel): + type: str = pydantic.Field(alias="auth_type") conn_type: str - auth_type: str - - @classmethod - def factory(cls, data: Any) -> "BaseOAuthClient": - if isinstance(data, BaseOAuthClient): - return data - - assert isinstance(data, dict), "OAuthClient settings must be a dict" - assert "auth_type" in data, "No auth_type in client" - assert data["auth_type"] in _REGISTRY, f"No such OAuth type: {data['auth_type']}" - - config_class = _REGISTRY[data["auth_type"]] - return config_class.model_validate(data) - - -_REGISTRY: dict[str, type[BaseOAuthClient]] = {} -def register_auth_client( - name: str, - auth_type: type[BaseOAuthClient], -) -> None: - _REGISTRY[name] = auth_type - - -class AuthAPISettings(pydantic_settings.BaseSettings): - model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__") - - auth_clients: Annotated[ - dict[str, pydantic.SerializeAsAny[BaseOAuthClient]], - pydantic.BeforeValidator(make_dict_factory(BaseOAuthClient.factory)), - ] = pydantic.Field(default=dict()) - +class AuthAPISettings(dl_settings.BaseRootSettings): + auth_clients: dl_settings.TypedDictAnnotation[BaseOAuthClient] = pydantic.Field(default=dict()) sentry_dsn: str | None = pydantic.Field(default=None) - - @classmethod - def settings_customise_sources( - cls, - settings_cls: Type[pydantic_settings.BaseSettings], - init_settings: pydantic_settings.PydanticBaseSettingsSource, - env_settings: pydantic_settings.PydanticBaseSettingsSource, - dotenv_settings: pydantic_settings.PydanticBaseSettingsSource, - file_secret_settings: pydantic_settings.PydanticBaseSettingsSource, - ) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: - return ( - env_settings, - pydantic_settings.YamlConfigSettingsSource( - settings_cls, - yaml_file=os.environ.get("CONFIG_PATH", None), - ), - init_settings, - ) diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/utils/pydantic.py b/lib/dl_auth_api_lib/dl_auth_api_lib/utils/pydantic.py deleted file mode 100644 index d9109f52f..000000000 --- a/lib/dl_auth_api_lib/dl_auth_api_lib/utils/pydantic.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import ( - Any, - Callable, - Hashable, - TypeVar, -) - - -T = TypeVar("T") - - -def make_dict_factory(factory: Callable[[Any], T]) -> Callable[[Any], dict[Hashable, T]]: - def dict_factory(v: Any) -> dict[Hashable, T]: - assert isinstance(v, dict) - return {name: factory(item) for name, item in v.items()} - - return dict_factory diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib_tests/conftest.py b/lib/dl_auth_api_lib/dl_auth_api_lib_tests/conftest.py index cc0675a3a..610e0f5a4 100644 --- a/lib/dl_auth_api_lib/dl_auth_api_lib_tests/conftest.py +++ b/lib/dl_auth_api_lib/dl_auth_api_lib_tests/conftest.py @@ -2,9 +2,10 @@ import logging import os -import aiohttp.pytest_plugin +from aiohttp.pytest_plugin import aiohttp_client from aiohttp.typedefs import Middleware import pytest +import pytest_asyncio from dl_api_commons.base_models import ( NoAuthData, @@ -20,12 +21,8 @@ LOGGER = logging.getLogger(__name__) -pytest_plugins = ("aiohttp.pytest_plugin",) - -try: - del aiohttp.pytest_plugin.loop -except AttributeError: - pass +# Fixtures +aiohttp_client = aiohttp_client @pytest.fixture(autouse=True) @@ -98,7 +95,7 @@ def oauth_app(loop, aiohttp_client, oauth_app_settings): return loop.run_until_complete(aiohttp_client(app)) -@pytest.fixture(scope="function") +@pytest_asyncio.fixture(scope="function") async def oauth_app_client(oauth_app) -> DLCommonAPIClient: async with get_default_aiohttp_session() as session: yield DLCommonAPIClient( diff --git a/lib/dl_auth_api_lib/pyproject.toml b/lib/dl_auth_api_lib/pyproject.toml index 60b7512c9..d230174dd 100644 --- a/lib/dl_auth_api_lib/pyproject.toml +++ b/lib/dl_auth_api_lib/pyproject.toml @@ -20,6 +20,7 @@ typing-extensions = ">=4.9.0" dl-api-commons = {path = "../dl_api_commons"} dl-core = {path = "../dl_core"} dl-constants = {path = "../dl_constants"} +dl-settings = {path = "../dl_settings"} [tool.poetry.group.tests.dependencies] pytest = ">=7.2.2" @@ -48,6 +49,7 @@ target_path = "ext" labels = ["ext_public"] [tool.mypy] +exclude = ["dl_auth_api_lib_tests/"] warn_unused_configs = true disallow_untyped_defs = true check_untyped_defs = true diff --git a/lib/dl_settings/dl_settings/__init__.py b/lib/dl_settings/dl_settings/__init__.py index e69de29bb..c1fe44436 100644 --- a/lib/dl_settings/dl_settings/__init__.py +++ b/lib/dl_settings/dl_settings/__init__.py @@ -0,0 +1,18 @@ +from .base import ( + BaseRootSettings, + BaseSettings, + TypedAnnotation, + TypedBaseModel, + TypedDictAnnotation, + TypedListAnnotation, +) + + +__all__ = [ + "BaseSettings", + "BaseRootSettings", + "TypedBaseModel", + "TypedAnnotation", + "TypedListAnnotation", + "TypedDictAnnotation", +] diff --git a/lib/dl_settings/dl_settings/base/__init__.py b/lib/dl_settings/dl_settings/base/__init__.py new file mode 100644 index 000000000..6bd60046e --- /dev/null +++ b/lib/dl_settings/dl_settings/base/__init__.py @@ -0,0 +1,20 @@ +from .settings import ( + BaseRootSettings, + BaseSettings, +) +from .typed import ( + TypedAnnotation, + TypedBaseModel, + TypedDictAnnotation, + TypedListAnnotation, +) + + +__all__ = [ + "BaseSettings", + "BaseRootSettings", + "TypedBaseModel", + "TypedAnnotation", + "TypedListAnnotation", + "TypedDictAnnotation", +] diff --git a/lib/dl_settings/dl_settings/base/settings.py b/lib/dl_settings/dl_settings/base/settings.py new file mode 100644 index 000000000..bcc315812 --- /dev/null +++ b/lib/dl_settings/dl_settings/base/settings.py @@ -0,0 +1,56 @@ +import os +import typing + +import pydantic_settings + + +class BaseSettings(pydantic_settings.BaseSettings): + ... + + +class BaseRootSettings(BaseSettings): + model_config = pydantic_settings.SettingsConfigDict( + env_nested_delimiter="__", + ) + + @classmethod + def _get_yaml_source_paths(cls) -> list[str]: + config_paths = os.environ.get("CONFIG_PATH", None) + + if not config_paths: + return [] + + return [path.strip() for path in config_paths.split(",")] + + @classmethod + def _get_yaml_sources(cls) -> list[pydantic_settings.YamlConfigSettingsSource]: + return [ + pydantic_settings.YamlConfigSettingsSource( + cls, + yaml_file=yaml_file, + ) + for yaml_file in cls._get_yaml_source_paths() + ] + + @classmethod + def settings_customise_sources( + cls, + settings_cls: typing.Type[pydantic_settings.BaseSettings], + init_settings: pydantic_settings.PydanticBaseSettingsSource, + env_settings: pydantic_settings.PydanticBaseSettingsSource, + dotenv_settings: pydantic_settings.PydanticBaseSettingsSource, + file_secret_settings: pydantic_settings.PydanticBaseSettingsSource, + ) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: + return ( + env_settings, + *cls._get_yaml_sources(), + dotenv_settings, + file_secret_settings, + init_settings, + ) + + +__all__ = [ + "BaseSettings", + "BaseRootSettings", +] diff --git a/lib/dl_settings/dl_settings/base/typed.py b/lib/dl_settings/dl_settings/base/typed.py new file mode 100644 index 000000000..f7466a559 --- /dev/null +++ b/lib/dl_settings/dl_settings/base/typed.py @@ -0,0 +1,108 @@ +import typing + +import pydantic +import pydantic._internal._model_construction as pydantic_model_construction +import pydantic.fields + +import dl_settings.base.settings as base_settings + + +class TypedMeta(pydantic_model_construction.ModelMetaclass): + def __init__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]): + cls._classes: dict[str, type[base_settings.BaseSettings]] = {} + + +class TypedBaseModel(base_settings.BaseSettings, metaclass=TypedMeta): + type: str = pydantic.Field(alias="type") + + @classmethod + def __get_type_field_name(cls) -> str: + alias = cls.model_fields["type"].alias + + if alias is None: + raise ValueError("Field 'type' must have alias") + + return alias + + @classmethod + def register(cls, name: str, class_: typing.Type) -> None: + if name in cls._classes: + raise ValueError(f"Class with name '{name}' already registered") + + if not issubclass(class_, cls): + raise ValueError(f"Class '{class_}' must be subclass of '{cls}'") + + cls._classes[name] = class_ + + @classmethod + def factory(cls, data: typing.Any) -> base_settings.BaseSettings: + if isinstance(data, base_settings.BaseSettings): + return data + + if not isinstance(data, dict): + raise ValueError("Data must be dict") + + class_name = data[cls.__get_type_field_name()] + if class_name not in cls._classes: + raise ValueError(f"Unknown type: {class_name}") + + class_ = cls._classes[class_name] + + return class_.model_validate(data) + + @classmethod + def list_factory(cls, data: list[typing.Any]) -> typing.List[base_settings.BaseSettings]: + if not isinstance(data, list): + raise ValueError("Data must be sequence for list factory") + + return [cls.factory(item) for item in data] + + @classmethod + def dict_factory(cls, data: dict[str, typing.Any]) -> typing.Dict[str, base_settings.BaseSettings]: + if not isinstance(data, dict): + raise ValueError("Data must be mapping for dict factory") + + import logging + + logging.error(data) + + return {key: cls.factory(value) for key, value in data.items()} + + +TypedBaseModelT = typing.TypeVar("TypedBaseModelT", bound=TypedBaseModel) + + +if typing.TYPE_CHECKING: + TypedAnnotation = typing.Annotated[TypedBaseModelT, ...] + TypedListAnnotation = typing.Annotated[list[TypedBaseModelT], ...] + TypedDictAnnotation = typing.Annotated[dict[str, TypedBaseModelT], ...] +else: + + class TypedAnnotation: + def __class_getitem__(cls, base_class: TypedBaseModelT) -> typing.Any: + return typing.Annotated[ + pydantic.SerializeAsAny[base_class], + pydantic.BeforeValidator(base_class.factory), + ] + + class TypedListAnnotation: + def __class_getitem__(cls, base_class: TypedBaseModelT) -> typing.Any: + return typing.Annotated[ + list[pydantic.SerializeAsAny[base_class]], + pydantic.BeforeValidator(base_class.list_factory), + ] + + class TypedDictAnnotation: + def __class_getitem__(cls, base_class: TypedBaseModelT) -> typing.Any: + return typing.Annotated[ + dict[str, pydantic.SerializeAsAny[base_class]], + pydantic.BeforeValidator(base_class.dict_factory), + ] + + +__all__ = [ + "TypedBaseModel", + "TypedAnnotation", + "TypedListAnnotation", + "TypedDictAnnotation", +] diff --git a/lib/dl_auth_api_lib/dl_auth_api_lib/utils/__init__.py b/lib/dl_settings/dl_settings_tests/unit/base/__init__.py similarity index 100% rename from lib/dl_auth_api_lib/dl_auth_api_lib/utils/__init__.py rename to lib/dl_settings/dl_settings_tests/unit/base/__init__.py diff --git a/lib/dl_settings/dl_settings_tests/unit/base/test_settings.py b/lib/dl_settings/dl_settings_tests/unit/base/test_settings.py new file mode 100644 index 000000000..028ba586b --- /dev/null +++ b/lib/dl_settings/dl_settings_tests/unit/base/test_settings.py @@ -0,0 +1,149 @@ +import pydantic +import pytest + +import dl_settings +import dl_settings_tests.utils as test_utils + + +def test_raise_no_value() -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + with pytest.raises(pydantic.ValidationError): + Settings() + + +def test_envs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + monkeypatch.setenv("FIELD", "value") + + settings = Settings() + assert settings.field == "value" + + +def test_config( + monkeypatch: pytest.MonkeyPatch, + tmp_configs: test_utils.TmpConfigs, +) -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + config = {"field": "value"} + config_path = tmp_configs.add(config) + + monkeypatch.setenv("CONFIG_PATH", str(config_path)) + + settings = Settings() + + assert settings.field == "value" + + +def test_init() -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + settings = Settings(field="value") + + assert settings.field == "value" + + +def test_env_overrides_config( + monkeypatch: pytest.MonkeyPatch, + tmp_configs: test_utils.TmpConfigs, +) -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + config = {"field": "config_value"} + config_path = tmp_configs.add(config) + + monkeypatch.setenv("FIELD", "env_value") + monkeypatch.setenv("CONFIG_PATH", str(config_path)) + + settings = Settings() + + assert settings.field == "env_value" + + +def test_multiple_configs( + monkeypatch: pytest.MonkeyPatch, + tmp_configs: test_utils.TmpConfigs, +) -> None: + class Settings(dl_settings.BaseRootSettings): + field1: str = NotImplemented + field2: str = NotImplemented + + config1 = {"field1": "config1_value"} + config2 = {"field2": "config2_value"} + + config1_path = tmp_configs.add(config1) + config2_path = tmp_configs.add(config2) + + monkeypatch.setenv("CONFIG_PATH", f"{config1_path}, {config2_path}") + + settings = Settings() + + assert settings.field1 == "config1_value" + assert settings.field2 == "config2_value" + + +def test_multiple_configs_priority( + monkeypatch: pytest.MonkeyPatch, + tmp_configs: test_utils.TmpConfigs, +) -> None: + class Settings(dl_settings.BaseRootSettings): + field: str = NotImplemented + + config1 = {"field": "config1_value"} + config2 = {"field": "config2_value"} + + config1_path = tmp_configs.add(config1) + config2_path = tmp_configs.add(config2) + + monkeypatch.setenv("CONFIG_PATH", f"{config1_path}, {config2_path}") + + settings = Settings() + + assert settings.field == "config1_value" + + +def test_nested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class NestedSettings(dl_settings.BaseSettings): + field: str = NotImplemented + + class Settings(dl_settings.BaseRootSettings): + nested: NestedSettings = pydantic.Field(default_factory=NestedSettings) + + monkeypatch.setenv("NESTED__FIELD", "value") + + settings = Settings() + assert settings.nested.field == "value" + + +def test_nested_multiple_sources( + monkeypatch: pytest.MonkeyPatch, + tmp_configs: test_utils.TmpConfigs, +) -> None: + class NestedSettings(dl_settings.BaseSettings): + field1: str = NotImplemented + field2: str = NotImplemented + + class Settings(dl_settings.BaseRootSettings): + nested: NestedSettings = pydantic.Field(default_factory=NestedSettings) + + config = {"nested": {"field2": "config_value"}} + config_path = tmp_configs.add(config) + + monkeypatch.setenv("NESTED__FIELD1", "env_value") + monkeypatch.setenv("CONFIG_PATH", str(config_path)) + + settings = Settings() + + assert settings.nested.field1 == "env_value" + assert settings.nested.field2 == "config_value" diff --git a/lib/dl_settings/dl_settings_tests/unit/base/test_typed.py b/lib/dl_settings/dl_settings_tests/unit/base/test_typed.py new file mode 100644 index 000000000..3f9168e28 --- /dev/null +++ b/lib/dl_settings/dl_settings_tests/unit/base/test_typed.py @@ -0,0 +1,244 @@ +import typing + +import pydantic +import pytest + +import dl_settings + + +def test_default_settings() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + class Child2(Base): + ... + + Base.register("child", Child) + Base.register("child2", Child2) + + assert isinstance(Base.factory({"type": "child"}), Child) + assert isinstance(Base.factory({"type": "child2"}), Child2) + + +def test_type_field_name_alias() -> None: + class Base(dl_settings.TypedBaseModel): + type: str = pydantic.Field(alias="test_type_field_name") + + class Child(Base): + ... + + Base.register("child", Child) + + assert isinstance(Base.factory({"test_type_field_name": "child"}), Child) + + +def test_already_deseialized() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + child = Child.model_validate({"type": "child"}) + + assert isinstance(Base.factory(child), Child) + + +def test_not_a_dict_data() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + class Root(dl_settings.BaseSettings): + child: Child + + with pytest.raises(ValueError): + Root.model_validate({"child": ""}) + + +def test_already_registered() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + with pytest.raises(ValueError): + Base.register("child", Child) + + +def test_not_subclass() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Base2(dl_settings.TypedBaseModel): + ... + + class Child(Base2): + ... + + with pytest.raises(ValueError): + Base.register("child", Child) + + +def test_unknown_type() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + with pytest.raises(ValueError): + Base.factory({"type": "child"}) + + +def test_multiple_bases() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Base2(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + class Child2(Base2): + ... + + Base.register("child", Child) + Base2.register("child", Child2) + + assert isinstance(Base.factory({"type": "child"}), Child) + assert isinstance(Base2.factory({"type": "child"}), Child2) + + +def test_list_factory_default() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + children = Base.list_factory([{"type": "child"}]) + + assert isinstance(children[0], Child) + + +def test_list_factory_not_sequence() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + with pytest.raises(ValueError): + Base.list_factory(typing.cast(list, "test")) + + +def test_dict_factory_default() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + children = Base.dict_factory({"child": {"type": "child"}}) + + assert isinstance(children["child"], Child) + + +def test_dict_factory_not_dict() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + with pytest.raises(ValueError): + Base.dict_factory(typing.cast(dict, "test")) + + +def test_annotation() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + class Root(dl_settings.BaseSettings): + child: dl_settings.TypedAnnotation[Base] + + root = Root.model_validate({"child": {"type": "child"}}) + + assert isinstance(root.child, Child) + + +def test_list_annotation() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + class Root(dl_settings.BaseSettings): + children: dl_settings.TypedListAnnotation[Base] = pydantic.Field(default_factory=list) + + root = Root.model_validate({"children": [{"type": "child"}]}) + + assert isinstance(root.children[0], Child) + + +def test_dict_annotation() -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + ... + + Base.register("child", Child) + + class Root(dl_settings.BaseSettings): + children: dl_settings.TypedDictAnnotation[Base] = pydantic.Field(default_factory=dict) + + root = Root.model_validate({"children": {"child": {"type": "child"}}}) + + assert isinstance(root.children["child"], Child) + + +def test_dict_annotation_with_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Base(dl_settings.TypedBaseModel): + ... + + class Child(Base): + secret: str + + Base.register("child", Child) + + class Root(dl_settings.BaseRootSettings): + children: dl_settings.TypedDictAnnotation[Base] = pydantic.Field(default_factory=dict) + + monkeypatch.setenv("CHILDREN__CHILD__SECRET", "secret_value") + root = Root(children={"child": {"type": "child"}}) # type: ignore + + assert isinstance(root.children["child"], Child) + assert root.children["child"].secret == "secret_value" diff --git a/lib/dl_settings/dl_settings_tests/unit/conftest.py b/lib/dl_settings/dl_settings_tests/unit/conftest.py index e69de29bb..171f5da74 100644 --- a/lib/dl_settings/dl_settings_tests/unit/conftest.py +++ b/lib/dl_settings/dl_settings_tests/unit/conftest.py @@ -0,0 +1,11 @@ +import typing + +import pytest + +import dl_settings_tests.utils as test_utils + + +@pytest.fixture(name="tmp_configs") +def fixture_tmp_configs() -> typing.Generator[test_utils.TmpConfigs, None, None]: + with test_utils.TmpConfigs() as tmp_configs: + yield tmp_configs diff --git a/lib/dl_settings/dl_settings_tests/utils/__init__.py b/lib/dl_settings/dl_settings_tests/utils/__init__.py new file mode 100644 index 000000000..8a8bf60e5 --- /dev/null +++ b/lib/dl_settings/dl_settings_tests/utils/__init__.py @@ -0,0 +1,6 @@ +from .tmp_configs import TmpConfigs + + +__all__ = [ + "TmpConfigs", +] diff --git a/lib/dl_settings/dl_settings_tests/utils/tmp_configs.py b/lib/dl_settings/dl_settings_tests/utils/tmp_configs.py new file mode 100644 index 000000000..18f6c5e33 --- /dev/null +++ b/lib/dl_settings/dl_settings_tests/utils/tmp_configs.py @@ -0,0 +1,36 @@ +import pathlib +import tempfile +import typing +import uuid + +import attr +import typing_extensions +import yaml + + +@attr.s() +class TmpConfigs: + _dir: tempfile.TemporaryDirectory = attr.ib(factory=tempfile.TemporaryDirectory) + + def __enter__(self) -> typing_extensions.Self: + return self + + def __exit__(self, exc_type: typing.Any, exc_value: typing.Any, traceback: typing.Any) -> None: + self.cleanup() + + def cleanup(self) -> None: + self._dir.cleanup() + + def add( + self, + content: dict[str, typing.Any], + ) -> pathlib.Path: + file_path = pathlib.Path(self._dir.name) / f"{uuid.uuid4()}.yaml" + + with file_path.open("w") as file: + yaml.safe_dump(content, file) + + return file_path + + +__all__ = ["TmpConfigs"] diff --git a/metapkg/poetry.lock b/metapkg/poetry.lock index 9b51cf7c9..ed68249ce 100644 --- a/metapkg/poetry.lock +++ b/metapkg/poetry.lock @@ -1621,6 +1621,7 @@ attrs = ">=22.2.0" dl-api-commons = {path = "../dl_api_commons"} dl-constants = {path = "../dl_constants"} dl-core = {path = "../dl_core"} +dl-settings = {path = "../dl_settings"} marshmallow = ">=3.19.0" pydantic = ">=2.7.0" pydantic-settings = ">=2.2.0 <2.3.4" @@ -2852,7 +2853,7 @@ description = "" optional = false python-versions = ">=3.10, <3.13" files = [] -develop = false +develop = true [package.dependencies] attrs = ">=22.2.0" @@ -8342,4 +8343,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.10, <3.13" -content-hash = "6c824afd7cc15ba7a6f3afefc0f63bbd32f88724f44fb153524b677904ebed3d" +content-hash = "98cd4b66a61d41513ebecc7b42987a45cd650aa97c32a331f62e3c432e2a1ab5" diff --git a/metapkg/pyproject.toml b/metapkg/pyproject.toml index 523face39..2d3213a8c 100644 --- a/metapkg/pyproject.toml +++ b/metapkg/pyproject.toml @@ -162,7 +162,7 @@ dl-auth-api-lib = {path = "../lib/dl_auth_api_lib", develop = true} dl-type-transformer = {path = "../lib/dl_type_transformer", develop = true} dl-s3 = {path = "../lib/dl_s3", develop = true} dl-auth-native = {path = "../lib/dl_auth_native", develop = true} -dl-settings = {path = "../lib/dl_settings"} +dl-settings = {path = "../lib/dl_settings", develop = true} [tool.poetry.group.dev.dependencies] black = "==23.12.1"