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..c208c24df 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 @@ -1,10 +1,10 @@ -import asyncio 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,22 +20,8 @@ LOGGER = logging.getLogger(__name__) -pytest_plugins = ("aiohttp.pytest_plugin",) - -try: - del aiohttp.pytest_plugin.loop -except AttributeError: - pass - - -@pytest.fixture(autouse=True) -def loop(event_loop): - """ - Preventing creation of new loop by `aiohttp.pytest_plugin` loop fixture in favor of pytest-asyncio one - And set loop pytest-asyncio created loop as default for thread - """ - asyncio.set_event_loop(event_loop) - return event_loop +# Fixtures +aiohttp_client = aiohttp_client @pytest.fixture(scope="function") @@ -98,7 +84,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/LICENSE b/lib/dl_settings/LICENSE new file mode 100644 index 000000000..74ba5f6c7 --- /dev/null +++ b/lib/dl_settings/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 YANDEX LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lib/dl_settings/README.md b/lib/dl_settings/README.md new file mode 100644 index 000000000..617492af0 --- /dev/null +++ b/lib/dl_settings/README.md @@ -0,0 +1 @@ +# dl_settings diff --git a/lib/dl_settings/dl_settings/__init__.py b/lib/dl_settings/dl_settings/__init__.py new file mode 100644 index 000000000..c1fe44436 --- /dev/null +++ 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/py.typed similarity index 100% rename from lib/dl_auth_api_lib/dl_auth_api_lib/utils/__init__.py rename to lib/dl_settings/dl_settings/py.typed diff --git a/lib/dl_settings/dl_settings_tests/__init__.py b/lib/dl_settings/dl_settings_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/dl_settings/dl_settings_tests/unit/__init__.py b/lib/dl_settings/dl_settings_tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/dl_settings/dl_settings_tests/unit/base/__init__.py b/lib/dl_settings/dl_settings_tests/unit/base/__init__.py new file mode 100644 index 000000000..e69de29bb 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..8fb1b4689 --- /dev/null +++ b/lib/dl_settings/dl_settings_tests/unit/base/test_settings.py @@ -0,0 +1,154 @@ +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": { + "field1": "config_value", + "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 new file mode 100644 index 000000000..171f5da74 --- /dev/null +++ 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/lib/dl_settings/pyproject.toml b/lib/dl_settings/pyproject.toml new file mode 100644 index 000000000..129900176 --- /dev/null +++ b/lib/dl_settings/pyproject.toml @@ -0,0 +1,39 @@ + +[tool.poetry] +name = "dl-settings" +version = "0.0.1" +description = "" +authors = ["DataLens Team "] +packages = [{include = "dl_settings"}] +license = "Apache 2.0" +readme = "README.md" + + +[tool.poetry.dependencies] +attrs = ">=22.2.0" +python = ">=3.10, <3.13" +pydantic = ">=2.7.0" +pydantic-settings = ">=2.2.0" + +[tool.poetry.group.tests.dependencies] +pytest = ">=7.2.2" + +[build-system] +build-backend = "poetry.core.masonry.api" +requires = [ + "poetry-core", +] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra" +testpaths = [] + +[datalens_ci] +skip_test = true + +[tool.mypy] +warn_unused_configs = true +disallow_untyped_defs = true +check_untyped_defs = true +strict_optional = true diff --git a/metapkg/poetry.lock b/metapkg/poetry.lock index 5cf0d4233..0ac16e1d6 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" @@ -2845,6 +2846,24 @@ ujson = ">=1.35" type = "directory" url = "../lib/dl_s3" +[[package]] +name = "dl-settings" +version = "0.0.1" +description = "" +optional = false +python-versions = ">=3.10, <3.13" +files = [] +develop = true + +[package.dependencies] +attrs = ">=22.2.0" +pydantic = ">=2.7.0" +pydantic-settings = ">=2.2.0" + +[package.source] +type = "directory" +url = "../lib/dl_settings" + [[package]] name = "dl-sqlalchemy-bitrix" version = "0.0.1" @@ -8326,4 +8345,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.10, <3.13" -content-hash = "946847c38e227d8445213d8d8362a57db98162a67939511a6dac571af1c27a9d" +content-hash = "98cd4b66a61d41513ebecc7b42987a45cd650aa97c32a331f62e3c432e2a1ab5" diff --git a/metapkg/pyproject.toml b/metapkg/pyproject.toml index a821cc5e2..2d3213a8c 100644 --- a/metapkg/pyproject.toml +++ b/metapkg/pyproject.toml @@ -162,6 +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", develop = true} [tool.poetry.group.dev.dependencies] black = "==23.12.1"