diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd222a..7437d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### [#5](https://github.com/Frost-Leo/Leviftas/issues/5) - Bootstrap configuration model + +- test: Add comprehensive test suite for bootstrap settings models ([15734f3](https://github.com/Frost-Leo/Leviftas/commit/15734f3)) +- refactor: Improve bootstrap settings inheritance and type safety ([683b2fa](https://github.com/Frost-Leo/Leviftas/commit/683b2fa)) +- build: Add platformdirs dependency ([4b2bd18](https://github.com/Frost-Leo/Leviftas/commit/4b2bd18)) +- feat: Add bootstrap configuration model with config center support ([a7ee9de](https://github.com/Frost-Leo/Leviftas/commit/a7ee9de)) +- build: Add pydantic-settings dependency ([132c83c](https://github.com/Frost-Leo/Leviftas/commit/132c83c)) + ### [#3](https://github.com/Frost-Leo/Leviftas/issues/3) - Internal BaseModel foundation - test: Add InternalBaseModel test suite ([1dd6798](https://github.com/Frost-Leo/Leviftas/commit/1dd6798)) diff --git a/pyproject.toml b/pyproject.toml index 75c03ff..7b4082f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ # # - Author : FrostLeo # - Created : 2025/12/5 -# - Modified : 2025/12/9 +# - Modified : 2025/12/19 # Build system configuration - declares build backend and dependencies [build-system] @@ -75,7 +75,9 @@ classifiers = [ ] requires-python = ">=3.13" dependencies = [ + "platformdirs>=4.5.1", "pydantic>=2.12.3", + "pydantic_settings>=2.12.0", "typing_extensions>=4.15.0", ] diff --git a/src/leviftas/models/base/__init__.py b/src/leviftas/models/base/__init__.py index c9b6844..bf984eb 100644 --- a/src/leviftas/models/base/__init__.py +++ b/src/leviftas/models/base/__init__.py @@ -25,7 +25,9 @@ """ from leviftas.models.base.internal_base_model import InternalBaseModel +from leviftas.models.base.internal_base_settings import InternalBaseSettings __all__ = [ "InternalBaseModel", + "InternalBaseSettings", ] diff --git a/src/leviftas/models/base/internal_base_settings.py b/src/leviftas/models/base/internal_base_settings.py new file mode 100644 index 0000000..354f9f3 --- /dev/null +++ b/src/leviftas/models/base/internal_base_settings.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +internal_base_settings.py + +Internal base settings model for Leviftas system components. This module provides +a strictly configured Pydantic BaseSettings designed for internal use only, +with security-hardened settings and consistent validation behavior. + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import ClassVar + + +class InternalBaseSettings(BaseSettings): + """ + Base settings model for all internal Leviftas system components. + + This class provides a security-hardened, strictly-typed foundation for + internal settings models. It enforces consistent validation, serialization, + and security policies across all internal system components. + + Subclasses should override model_config to set their own env_prefix while + keeping other settings consistent. + + Attributes: + model_config (SettingsConfigDict): Pydantic settings configuration with + security-hardened settings for internal system use. + + Note: + This model is intended for internal system use only. For public-facing + APIs or user-defined models, use the appropriate public model classes. + """ + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + # Environment variable prefix for all settings. + env_prefix="LEVIFTAS_", + # Case insensitive by default. + case_sensitive=False, + # Load configuration from .env file in the current working directory. + env_file=".env", + # Use UTF-8 encoding for .env file to avoid cross-platform issues. + env_file_encoding="utf-8", + # Ignore empty environment variables, use default values instead. + env_ignore_empty=True, + # Parse "null" string as None value. + env_parse_none_str="null", + # Use double underscore as nested delimiter for environment variables. + env_nested_delimiter="__", + # Disable CLI argument parsing by default, subclasses can enable if needed. + cli_parse_args=False, + # Parse "null" string as None value in CLI arguments. + cli_parse_none_str="null", + # Enable implicit flags: --debug equals --debug=true, --no-debug equals --debug=false. + cli_implicit_flags=True, + # Use kebab-case for CLI arguments: --node-id instead of --node_id. + cli_kebab_case=True, + # Disable secrets directory by default for cross-platform support. + secrets_dir=None, + ) diff --git a/src/leviftas/models/bootstrap/__init__.py b/src/leviftas/models/bootstrap/__init__.py new file mode 100644 index 0000000..d999dcd --- /dev/null +++ b/src/leviftas/models/bootstrap/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +__init__.py + + + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" \ No newline at end of file diff --git a/src/leviftas/models/bootstrap/bootstrap_configuration_model.py b/src/leviftas/models/bootstrap/bootstrap_configuration_model.py new file mode 100644 index 0000000..4cf817f --- /dev/null +++ b/src/leviftas/models/bootstrap/bootstrap_configuration_model.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +bootstrap_configuration_model.py + +Bootstrap configuration model for Leviftas system initialization. This module +provides the minimal configuration required to start the system and connect +to the configuration center. It supports loading from environment variables, +CLI arguments, and .env files. + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" + +import platform +import socket +import uuid + +from platformdirs import user_config_dir +from pydantic import Field, model_validator +from pydantic_settings import SettingsConfigDict +from typing_extensions import ClassVar, Literal, Self + +from leviftas.models.base.internal_base_settings import InternalBaseSettings +from leviftas.models.bootstrap.config_center import ConfigCenterBaseModel + + +class BootstrapConfigurationModel(InternalBaseSettings): + """ + Bootstrap configuration for Leviftas system startup. + + This class provides the minimal configuration required to initialize the + system and establish connection to the configuration center. All other + runtime configurations (MQ, database, etc.) should be fetched from the + configuration center after bootstrap. + + Attributes: + model_config (SettingsConfigDict): Pydantic settings configuration + for environment variables and CLI argument parsing. + + Note: + Configuration priority (low to high): + 1. Model default values + 2. .env file + 3. Environment variables + 4. CLI arguments + """ + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + # Inherit all settings from parent class, override specific values. + **{ + **InternalBaseSettings.model_config, + # Program name displayed in CLI help message. + "cli_prog_name": "leviftas", + # Enable CLI argument parsing as this is the entry point. + "cli_parse_args": True, + # Load secrets from Docker/Kubernetes secrets directory (Linux/container). + "secrets_dir": "/run/secrets", + }, + ) + + hostname: str = Field( + default_factory=socket.gethostname, + description="Host name of the machine.", + ) + """Host name of the machine, auto-detected from system.""" + + os_type: str = Field( + default_factory=platform.system, + description="Operating system type (Windows, Linux, Darwin).", + ) + """Operating system type, auto-detected from system.""" + + os_version: str = Field( + default_factory=platform.version, + description="Operating system version.", + ) + """Operating system version, auto-detected from system.""" + + platform_arch: str = Field( + default_factory=platform.machine, + description="Platform architecture (x86_64, arm64, etc.).", + ) + """Platform architecture, auto-detected from system.""" + + node_id: str = Field( + default_factory=socket.gethostname, + description="Node identifier, shared by all instances on the same machine.", + ) + """Node identifier from environment variable, defaults to hostname.""" + + instance_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique instance identifier, generated on each startup.", + ) + """Unique instance identifier, auto-generated UUID for each process.""" + + service_name: str = Field( + description="Service name for registration and discovery.", + ) + """Service name, required field from environment variable or CLI.""" + + env: str = Field( + default="dev", + description="Runtime environment (dev, staging, prod).", + ) + """Runtime environment, defaults to dev.""" + + config_source: Literal["remote", "local"] = Field( + default="remote", + description="Configuration source: 'remote' for config center, 'local' for file.", + ) + """Configuration source, defaults to remote (config center).""" + + config_center_type: str | None = Field( + default=None, + description="Configuration center type (e.g., 'nacos', 'apollo').", + ) + """Configuration center type, required when config_source is 'remote'.""" + + config_center: ConfigCenterBaseModel | None = Field( + default=None, + description="Configuration center connection settings, auto-loaded by type.", + ) + """Configuration center settings, auto-instantiated from registry.""" + + config_dir: str = Field( + default_factory=lambda: user_config_dir("leviftas"), + description="Local configuration directory path.", + ) + """Local config directory, defaults to platform-specific user config dir.""" + + @model_validator(mode="after") + def validate_and_load_config(self) -> Self: + """Validate config_source and auto-load config_center from registry.""" + if self.config_source == "remote": + if self.config_center_type is None: + raise ValueError( + "config_center_type is required when config_source is 'remote'" + ) + if self.config_center is None: + self.config_center = ConfigCenterBaseModel.get_by_type( + self.config_center_type + ) + return self diff --git a/src/leviftas/models/bootstrap/config_center/__init__.py b/src/leviftas/models/bootstrap/config_center/__init__.py new file mode 100644 index 0000000..1dea1ac --- /dev/null +++ b/src/leviftas/models/bootstrap/config_center/__init__.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +config_center/__init__.py + +Configuration center connection models package for bootstrap phase. + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" + +from leviftas.models.bootstrap.config_center.base import ConfigCenterBaseModel +from leviftas.models.bootstrap.config_center.nacos_conn_config_model import ( + NacosConnConfigModel, +) + +__all__ = [ + "ConfigCenterBaseModel", + "NacosConnConfigModel", +] diff --git a/src/leviftas/models/bootstrap/config_center/base.py b/src/leviftas/models/bootstrap/config_center/base.py new file mode 100644 index 0000000..d55c88a --- /dev/null +++ b/src/leviftas/models/bootstrap/config_center/base.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +base.py + +Base model for configuration center connection models. Implements the Registry +pattern for auto-discovery and instantiation of configuration center models. + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" + +from pydantic_settings import SettingsConfigDict +from typing_extensions import Any, ClassVar + +from leviftas.models.base.internal_base_settings import InternalBaseSettings + + +class ConfigCenterBaseModel(InternalBaseSettings): + """ + Base class for all configuration center connection models. + + Implements an auto-registration mechanism via the `registry` class attribute. + Subclasses are automatically registered by their `type_name` defined in Meta. + + Attributes: + registry (ClassVar[dict[str, type[ConfigCenterBaseModel]]]): + Central registry for all configuration center models. + Maps type names to their corresponding model classes. + """ + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + # Inherit all settings from parent class, override env_prefix. + **{**InternalBaseSettings.model_config, "env_prefix": ""}, + ) + + registry: ClassVar[dict[str, type["ConfigCenterBaseModel"]]] = {} + + class Meta: + """ + Metadata configuration for configuration center models. + + Attributes: + abstract (bool): If True, this class won't be registered. + type_name (str): Unique identifier for the config center type. + """ + + abstract: bool = True + type_name: str = "" + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + """ + Hook called when a class is subclassed. + + Registers the subclass to the global registry unless it is marked as abstract. + """ + super().__pydantic_init_subclass__(**kwargs) + + if getattr(cls.Meta, "abstract", False): + return + + type_name = getattr(cls.Meta, "type_name", "") + if type_name: + if type_name in ConfigCenterBaseModel.registry: + existing_cls = ConfigCenterBaseModel.registry[type_name] + raise ValueError( + f"Duplicate config center type_name '{type_name}': " + f"{cls.__name__} conflicts with {existing_cls.__name__}" + ) + ConfigCenterBaseModel.registry[type_name] = cls + + @classmethod + def get_by_type(cls, type_name: str) -> "ConfigCenterBaseModel": + """ + Get and instantiate a configuration center model by type name. + + The model will automatically load values from environment variables + based on its configured env_prefix. + + Args: + type_name: The type identifier (e.g., "nacos", "apollo"). + + Returns: + An instantiated configuration center model. + + Raises: + ValueError: If the type name is not found in the registry. + """ + model_cls = cls.registry.get(type_name) + if not model_cls: + available = ", ".join(cls.registry.keys()) or "none" + raise ValueError( + f"Unknown config center type: '{type_name}'. Available types: {available}" + ) + return model_cls() + + @classmethod + def get_available_types(cls) -> list[str]: + """ + Get a list of all registered configuration center types. + + Returns: + List of available type names. + """ + return list(cls.registry.keys()) diff --git a/src/leviftas/models/bootstrap/config_center/nacos_conn_config_model.py b/src/leviftas/models/bootstrap/config_center/nacos_conn_config_model.py new file mode 100644 index 0000000..5172d7f --- /dev/null +++ b/src/leviftas/models/bootstrap/config_center/nacos_conn_config_model.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +nacos_conn_config_model.py + +Minimal Nacos configuration center connection model for bootstrap phase. +Full Nacos client configuration should be defined in the Nacos module. + +- Author : FrostLeo +- Created : 2025/12/19 +- Modified : 2025/12/19 +""" + +from pydantic import Field, SecretStr +from pydantic_settings import SettingsConfigDict +from typing_extensions import ClassVar + +from leviftas.models.bootstrap.config_center.base import ConfigCenterBaseModel + + +class NacosConnConfigModel(ConfigCenterBaseModel): + """ + Minimal Nacos configuration center connection settings for bootstrap. + + This model contains only the essential fields required to establish + initial connection to Nacos. Full Nacos client configuration should + be defined in the Nacos module. + """ + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + # Inherit all settings from parent class, override env_prefix. + **{**ConfigCenterBaseModel.model_config, "env_prefix": "NACOS_"}, + ) + + class Meta: + """Nacos configuration center metadata.""" + + abstract = False + type_name = "nacos" + + server_address: str = Field( + description="Nacos server address (e.g., 'localhost:8848').", + ) + """Nacos server address, required field.""" + + namespace_id: str = Field( + default="", + description="Nacos namespace ID, empty string for public namespace.", + ) + """Nacos namespace ID, defaults to public namespace.""" + + username: str | None = Field( + default=None, + description="Nacos authentication username.", + ) + """Nacos username for authentication.""" + + password: SecretStr | None = Field( + default=None, + description="Nacos authentication password.", + ) + """Nacos password for authentication.""" + + access_key: SecretStr | None = Field( + default=None, + description="Alibaba Cloud access key ID for ACM mode.", + ) + """Alibaba Cloud access key for ACM authentication.""" + + secret_key: SecretStr | None = Field( + default=None, + description="Alibaba Cloud secret key for ACM mode.", + ) + """Alibaba Cloud secret key for ACM authentication.""" diff --git a/tests/models/base/internal_base_model/test_alias.py b/tests/models/base/internal_base_model/test_alias.py index 98fffe0..fc2ca7e 100644 --- a/tests/models/base/internal_base_model/test_alias.py +++ b/tests/models/base/internal_base_model/test_alias.py @@ -201,3 +201,4 @@ class OuterModel(InternalBaseModel): assert "innerValue" in data["nested"] logger.info("Nested serialization uses original names") + diff --git a/tests/models/base/internal_base_model/test_enum_handling.py b/tests/models/base/internal_base_model/test_enum_handling.py index 6977307..c795850 100644 --- a/tests/models/base/internal_base_model/test_enum_handling.py +++ b/tests/models/base/internal_base_model/test_enum_handling.py @@ -162,3 +162,4 @@ class EnumModel(InternalBaseModel): assert model.status == "inactive" logger.info("Enum assignment correctly stored value") + diff --git a/tests/models/base/internal_base_model/test_from_attributes.py b/tests/models/base/internal_base_model/test_from_attributes.py index 374aab8..c2c835e 100644 --- a/tests/models/base/internal_base_model/test_from_attributes.py +++ b/tests/models/base/internal_base_model/test_from_attributes.py @@ -184,3 +184,4 @@ class NameModel(InternalBaseModel): assert model.name == "spaced name" logger.info("Validation (whitespace strip) correctly applied") + diff --git a/tests/models/base/internal_base_model/test_revalidation.py b/tests/models/base/internal_base_model/test_revalidation.py index 5bf8891..b2fde7f 100644 --- a/tests/models/base/internal_base_model/test_revalidation.py +++ b/tests/models/base/internal_base_model/test_revalidation.py @@ -188,3 +188,4 @@ class CauseModel(InternalBaseModel): pytest.fail("Expected ValidationError was not raised") + diff --git a/tests/models/base/internal_base_model/test_serialization.py b/tests/models/base/internal_base_model/test_serialization.py index b927362..6761b94 100644 --- a/tests/models/base/internal_base_model/test_serialization.py +++ b/tests/models/base/internal_base_model/test_serialization.py @@ -228,3 +228,4 @@ class Order(InternalBaseModel): assert data["items"][1]["price"] == 200 logger.info("List of nested models serialized correctly") + diff --git a/tests/models/base/internal_base_settings/__init__.py b/tests/models/base/internal_base_settings/__init__.py new file mode 100644 index 0000000..6493f1e --- /dev/null +++ b/tests/models/base/internal_base_settings/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +__init__.py + +InternalBaseSettings test package. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + diff --git a/tests/models/base/internal_base_settings/test_config.py b/tests/models/base/internal_base_settings/test_config.py new file mode 100644 index 0000000..38a3112 --- /dev/null +++ b/tests/models/base/internal_base_settings/test_config.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +test_config.py + +Tests for InternalBaseSettings configuration. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + +import logging + +import pytest + +from leviftas.models.base.internal_base_settings import InternalBaseSettings + + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.internal + + +class TestModelConfigDefaults: + """Tests for default model_config values.""" + + def test_env_prefix_default(self) -> None: + """Default env_prefix should be LEVIFTAS_.""" + logger.info("Testing default env_prefix") + assert InternalBaseSettings.model_config.get("env_prefix") == "LEVIFTAS_" + logger.info("Default env_prefix is LEVIFTAS_") + + def test_env_nested_delimiter_default(self) -> None: + """Default env_nested_delimiter should be __.""" + logger.info("Testing default env_nested_delimiter") + assert InternalBaseSettings.model_config.get("env_nested_delimiter") == "__" + logger.info("Default env_nested_delimiter is __") + + def test_cli_parse_args_disabled(self) -> None: + """CLI parsing should be disabled by default.""" + logger.info("Testing cli_parse_args default") + assert InternalBaseSettings.model_config.get("cli_parse_args") is False + logger.info("cli_parse_args is disabled by default") + + def test_secrets_dir_none(self) -> None: + """secrets_dir should be None by default for cross-platform support.""" + logger.info("Testing secrets_dir default") + assert InternalBaseSettings.model_config.get("secrets_dir") is None + logger.info("secrets_dir is None by default") + + def test_env_file_default(self) -> None: + """Default env_file should be .env.""" + logger.info("Testing default env_file") + assert InternalBaseSettings.model_config.get("env_file") == ".env" + logger.info("Default env_file is .env") + + def test_env_file_encoding_utf8(self) -> None: + """env_file_encoding should be utf-8.""" + logger.info("Testing env_file_encoding") + assert InternalBaseSettings.model_config.get("env_file_encoding") == "utf-8" + logger.info("env_file_encoding is utf-8") + + def test_case_insensitive(self) -> None: + """case_sensitive should be False.""" + logger.info("Testing case_sensitive") + assert InternalBaseSettings.model_config.get("case_sensitive") is False + logger.info("case_sensitive is False") + + +class TestEnvVariableLoading: + """Tests for environment variable loading.""" + + def test_load_from_env_with_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Should load values from environment variables with LEVIFTAS_ prefix.""" + logger.info("Testing env variable loading with prefix") + + class TestSettings(InternalBaseSettings): + test_value: str = "default" + + monkeypatch.setenv("LEVIFTAS_TEST_VALUE", "from_env") + settings = TestSettings() + + logger.debug("Loaded test_value: %s", settings.test_value) + assert settings.test_value == "from_env" + logger.info("Successfully loaded value from env with prefix") + + def test_env_ignore_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Empty environment variables should be ignored.""" + logger.info("Testing empty env variable handling") + + class TestSettings(InternalBaseSettings): + test_value: str = "default" + + monkeypatch.setenv("LEVIFTAS_TEST_VALUE", "") + settings = TestSettings() + + logger.debug("Loaded test_value: %s", settings.test_value) + assert settings.test_value == "default" + logger.info("Empty env variable correctly ignored") + + def test_env_parse_none_str(self, monkeypatch: pytest.MonkeyPatch) -> None: + """String 'null' should be parsed as None.""" + logger.info("Testing null string parsing") + + class TestSettings(InternalBaseSettings): + test_value: str | None = "default" + + monkeypatch.setenv("LEVIFTAS_TEST_VALUE", "null") + settings = TestSettings() + + logger.debug("Loaded test_value: %s", settings.test_value) + assert settings.test_value is None + logger.info("'null' string correctly parsed as None") + + +class TestNestedEnvDelimiter: + """Tests for nested environment variable delimiter.""" + + def test_nested_model_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Nested models should load from env using __ delimiter.""" + logger.info("Testing nested model env loading") + + class DatabaseSettings(InternalBaseSettings): + model_config = InternalBaseSettings.model_config.copy() + host: str = "localhost" + port: int = 5432 + + class AppSettings(InternalBaseSettings): + database: DatabaseSettings = DatabaseSettings() + + monkeypatch.setenv("LEVIFTAS_DATABASE__HOST", "prod-db.example.com") + monkeypatch.setenv("LEVIFTAS_DATABASE__PORT", "5433") + + settings = AppSettings() + + logger.debug("Loaded database.host: %s", settings.database.host) + logger.debug("Loaded database.port: %d", settings.database.port) + + assert settings.database.host == "prod-db.example.com" + assert settings.database.port == 5433 + logger.info("Nested model correctly loaded from env") + + +class TestSubclassOverride: + """Tests for subclass configuration override.""" + + def test_subclass_can_override_env_prefix( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Subclass should be able to override env_prefix.""" + logger.info("Testing subclass env_prefix override") + + from pydantic_settings import SettingsConfigDict + from typing_extensions import ClassVar + + class CustomSettings(InternalBaseSettings): + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + **{**InternalBaseSettings.model_config, "env_prefix": "CUSTOM_"}, + ) + custom_value: str = "default" + + monkeypatch.setenv("CUSTOM_CUSTOM_VALUE", "overridden") + settings = CustomSettings() + + logger.debug("Loaded custom_value: %s", settings.custom_value) + assert settings.custom_value == "overridden" + logger.info("Subclass env_prefix override works correctly") + + def test_subclass_can_enable_cli(self) -> None: + """Subclass should be able to enable CLI parsing.""" + logger.info("Testing subclass CLI enable") + + from pydantic_settings import SettingsConfigDict + from typing_extensions import ClassVar + + class CliSettings(InternalBaseSettings): + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + **{ + **InternalBaseSettings.model_config, + "cli_parse_args": True, + "cli_prog_name": "test-cli", + }, + ) + + assert CliSettings.model_config.get("cli_parse_args") is True + assert CliSettings.model_config.get("cli_prog_name") == "test-cli" + logger.info("Subclass CLI enable works correctly") + diff --git a/tests/models/bootstrap/__init__.py b/tests/models/bootstrap/__init__.py new file mode 100644 index 0000000..8911d29 --- /dev/null +++ b/tests/models/bootstrap/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +__init__.py + +Bootstrap models test package. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + diff --git a/tests/models/bootstrap/config_center/__init__.py b/tests/models/bootstrap/config_center/__init__.py new file mode 100644 index 0000000..6392ef8 --- /dev/null +++ b/tests/models/bootstrap/config_center/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +__init__.py + +Config center models test package. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + diff --git a/tests/models/bootstrap/config_center/test_base.py b/tests/models/bootstrap/config_center/test_base.py new file mode 100644 index 0000000..002e04c --- /dev/null +++ b/tests/models/bootstrap/config_center/test_base.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +test_base.py + +Tests for ConfigCenterBaseModel registry pattern and configuration. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + +import logging + +import pytest + +from leviftas.models.bootstrap.config_center.base import ConfigCenterBaseModel + + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.internal + + +class TestModelConfig: + """Tests for ConfigCenterBaseModel model_config.""" + + def test_env_prefix_empty(self) -> None: + """env_prefix should be empty to let subclasses define their own.""" + logger.info("Testing env_prefix is empty") + assert ConfigCenterBaseModel.model_config.get("env_prefix") == "" + logger.info("env_prefix is empty as expected") + + def test_inherits_parent_config(self) -> None: + """Should inherit configuration from InternalBaseSettings.""" + logger.info("Testing parent config inheritance") + + # Check inherited settings + assert ConfigCenterBaseModel.model_config.get("env_file") == ".env" + assert ConfigCenterBaseModel.model_config.get("env_nested_delimiter") == "__" + assert ConfigCenterBaseModel.model_config.get("cli_parse_args") is False + + logger.info("Parent config inherited correctly") + + +class TestMetaDefaults: + """Tests for default Meta values.""" + + def test_default_abstract_true(self) -> None: + """Default abstract should be True.""" + logger.info("Testing default Meta.abstract") + assert ConfigCenterBaseModel.Meta.abstract is True + logger.info("Default Meta.abstract is True") + + def test_default_type_name_empty(self) -> None: + """Default type_name should be empty string.""" + logger.info("Testing default Meta.type_name") + assert ConfigCenterBaseModel.Meta.type_name == "" + logger.info("Default Meta.type_name is empty") + + +class TestRegistryPattern: + """Tests for registry pattern implementation.""" + + def test_nacos_registered(self) -> None: + """NacosConnConfigModel should be registered with type_name 'nacos'.""" + logger.info("Testing Nacos registration") + + # Import to ensure registration + from leviftas.models.bootstrap.config_center import NacosConnConfigModel + + assert "nacos" in ConfigCenterBaseModel.registry + assert ConfigCenterBaseModel.registry["nacos"] is NacosConnConfigModel + + logger.info("Nacos correctly registered") + + def test_get_available_types(self) -> None: + """get_available_types should return list of registered types.""" + logger.info("Testing get_available_types") + + types = ConfigCenterBaseModel.get_available_types() + + assert isinstance(types, list) + assert "nacos" in types + + logger.debug("Available types: %s", types) + logger.info("get_available_types works correctly") + + def test_get_by_type_returns_instance( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_by_type should return an instance of the registered model.""" + logger.info("Testing get_by_type returns instance") + + # Set required env vars for Nacos + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + + instance = ConfigCenterBaseModel.get_by_type("nacos") + + from leviftas.models.bootstrap.config_center import NacosConnConfigModel + + assert isinstance(instance, NacosConnConfigModel) + assert isinstance(instance, ConfigCenterBaseModel) + assert instance.server_address == "localhost:8848" + + logger.info("get_by_type correctly returns instance") + + def test_get_by_type_unknown_raises(self) -> None: + """get_by_type should raise ValueError for unknown types.""" + logger.info("Testing get_by_type with unknown type") + + with pytest.raises(ValueError) as exc_info: + ConfigCenterBaseModel.get_by_type("unknown_type") + + assert "Unknown config center type" in str(exc_info.value) + assert "unknown_type" in str(exc_info.value) + + logger.info("Unknown type correctly raises ValueError") + + def test_abstract_class_not_registered(self) -> None: + """Abstract classes should not be registered.""" + logger.info("Testing abstract class not registered") + + class AbstractConfig(ConfigCenterBaseModel): + class Meta: + abstract = True + type_name = "should_not_register" + + value: str = "test" + + assert "should_not_register" not in ConfigCenterBaseModel.registry + + logger.info("Abstract class correctly not registered") + + def test_duplicate_type_name_raises(self) -> None: + """Duplicate type_name should raise ValueError.""" + logger.info("Testing duplicate type_name detection") + + with pytest.raises(ValueError) as exc_info: + + class DuplicateNacos(ConfigCenterBaseModel): + class Meta: + abstract = False + type_name = "nacos" # Already registered + + value: str = "test" + + assert "Duplicate config center type_name" in str(exc_info.value) + assert "nacos" in str(exc_info.value) + + logger.info("Duplicate type_name correctly raises ValueError") + + def test_empty_type_name_not_registered(self) -> None: + """Classes with empty type_name should not be registered.""" + logger.info("Testing empty type_name not registered") + + registry_size_before = len(ConfigCenterBaseModel.registry) + + class EmptyTypeName(ConfigCenterBaseModel): + class Meta: + abstract = False + type_name = "" + + value: str = "test" + + registry_size_after = len(ConfigCenterBaseModel.registry) + + assert registry_size_after == registry_size_before + + logger.info("Empty type_name correctly not registered") + + +class TestSubclassInheritance: + """Tests for subclass configuration inheritance.""" + + def test_subclass_inherits_base_config(self) -> None: + """Subclass should inherit base configuration.""" + logger.info("Testing subclass config inheritance") + + from leviftas.models.bootstrap.config_center import NacosConnConfigModel + + # Should inherit env_file from InternalBaseSettings + assert NacosConnConfigModel.model_config.get("env_file") == ".env" + # Should have its own prefix + assert NacosConnConfigModel.model_config.get("env_prefix") == "NACOS_" + + logger.info("Subclass correctly inherits and overrides config") + diff --git a/tests/models/bootstrap/config_center/test_nacos.py b/tests/models/bootstrap/config_center/test_nacos.py new file mode 100644 index 0000000..add7f00 --- /dev/null +++ b/tests/models/bootstrap/config_center/test_nacos.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +test_nacos.py + +Tests for NacosConnConfigModel. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + +import logging + +import pytest +from pydantic import SecretStr, ValidationError + +from leviftas.models.bootstrap.config_center import NacosConnConfigModel + + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.internal + + +class TestModelConfig: + """Tests for NacosConnConfigModel model_config.""" + + def test_env_prefix_nacos(self) -> None: + """env_prefix should be NACOS_.""" + logger.info("Testing env_prefix is NACOS_") + assert NacosConnConfigModel.model_config.get("env_prefix") == "NACOS_" + logger.info("env_prefix is NACOS_ as expected") + + def test_inherits_parent_config(self) -> None: + """Should inherit configuration from parent classes.""" + logger.info("Testing parent config inheritance") + + # Check inherited settings from InternalBaseSettings + assert NacosConnConfigModel.model_config.get("env_file") == ".env" + assert NacosConnConfigModel.model_config.get("env_nested_delimiter") == "__" + assert NacosConnConfigModel.model_config.get("env_file_encoding") == "utf-8" + + logger.info("Parent config inherited correctly") + + +class TestMeta: + """Tests for NacosConnConfigModel Meta.""" + + def test_not_abstract(self) -> None: + """Meta.abstract should be False.""" + logger.info("Testing Meta.abstract is False") + assert NacosConnConfigModel.Meta.abstract is False + logger.info("Meta.abstract is False") + + def test_type_name_nacos(self) -> None: + """Meta.type_name should be 'nacos'.""" + logger.info("Testing Meta.type_name") + assert NacosConnConfigModel.Meta.type_name == "nacos" + logger.info("Meta.type_name is 'nacos'") + + +class TestRequiredFields: + """Tests for required fields.""" + + def test_server_address_required(self) -> None: + """server_address should be required.""" + logger.info("Testing server_address is required") + + with pytest.raises(ValidationError) as exc_info: + NacosConnConfigModel() + + errors = exc_info.value.errors() + field_names = [e["loc"][0] for e in errors] + assert "server_address" in field_names + + logger.info("server_address is correctly required") + + def test_server_address_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """server_address should load from NACOS_SERVER_ADDRESS.""" + logger.info("Testing server_address from env") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "nacos.example.com:8848") + config = NacosConnConfigModel() + + assert config.server_address == "nacos.example.com:8848" + logger.info("server_address correctly loaded from env") + + +class TestDefaultValues: + """Tests for default values.""" + + def test_namespace_id_default_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """namespace_id should default to empty string.""" + logger.info("Testing namespace_id default") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + config = NacosConnConfigModel() + + assert config.namespace_id == "" + logger.info("namespace_id defaults to empty string") + + def test_username_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """username should default to None.""" + logger.info("Testing username default") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + config = NacosConnConfigModel() + + assert config.username is None + logger.info("username defaults to None") + + def test_password_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """password should default to None.""" + logger.info("Testing password default") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + config = NacosConnConfigModel() + + assert config.password is None + logger.info("password defaults to None") + + def test_access_key_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """access_key should default to None.""" + logger.info("Testing access_key default") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + config = NacosConnConfigModel() + + assert config.access_key is None + logger.info("access_key defaults to None") + + def test_secret_key_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """secret_key should default to None.""" + logger.info("Testing secret_key default") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + config = NacosConnConfigModel() + + assert config.secret_key is None + logger.info("secret_key defaults to None") + + +class TestSecretStrFields: + """Tests for SecretStr field types.""" + + def test_password_is_secret_str(self, monkeypatch: pytest.MonkeyPatch) -> None: + """password should be SecretStr type.""" + logger.info("Testing password is SecretStr") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + monkeypatch.setenv("NACOS_PASSWORD", "my_secret_password") + + config = NacosConnConfigModel() + + assert isinstance(config.password, SecretStr) + assert config.password.get_secret_value() == "my_secret_password" + # String representation should be masked + assert "my_secret_password" not in str(config.password) + + logger.info("password correctly uses SecretStr") + + def test_access_key_is_secret_str(self, monkeypatch: pytest.MonkeyPatch) -> None: + """access_key should be SecretStr type.""" + logger.info("Testing access_key is SecretStr") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + monkeypatch.setenv("NACOS_ACCESS_KEY", "AKID12345") + + config = NacosConnConfigModel() + + assert isinstance(config.access_key, SecretStr) + assert config.access_key.get_secret_value() == "AKID12345" + + logger.info("access_key correctly uses SecretStr") + + def test_secret_key_is_secret_str(self, monkeypatch: pytest.MonkeyPatch) -> None: + """secret_key should be SecretStr type.""" + logger.info("Testing secret_key is SecretStr") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + monkeypatch.setenv("NACOS_SECRET_KEY", "secret12345") + + config = NacosConnConfigModel() + + assert isinstance(config.secret_key, SecretStr) + assert config.secret_key.get_secret_value() == "secret12345" + + logger.info("secret_key correctly uses SecretStr") + + def test_secrets_not_in_model_dump(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Secrets should be masked in model_dump output.""" + logger.info("Testing secrets masked in model_dump") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + monkeypatch.setenv("NACOS_PASSWORD", "super_secret") + + config = NacosConnConfigModel() + data = config.model_dump() + + # SecretStr is serialized as SecretStr object, not plain string + assert data["password"] != "super_secret" + + logger.info("Secrets correctly masked in model_dump") + + +class TestEnvLoading: + """Tests for environment variable loading.""" + + def test_all_fields_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """All fields should load from environment variables.""" + logger.info("Testing all fields from env") + + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "nacos.prod.example.com:8848") + monkeypatch.setenv("NACOS_NAMESPACE_ID", "prod-namespace") + monkeypatch.setenv("NACOS_USERNAME", "admin") + monkeypatch.setenv("NACOS_PASSWORD", "admin123") + monkeypatch.setenv("NACOS_ACCESS_KEY", "AKID999") + monkeypatch.setenv("NACOS_SECRET_KEY", "secret999") + + config = NacosConnConfigModel() + + assert config.server_address == "nacos.prod.example.com:8848" + assert config.namespace_id == "prod-namespace" + assert config.username == "admin" + assert config.password is not None + assert config.password.get_secret_value() == "admin123" + assert config.access_key is not None + assert config.access_key.get_secret_value() == "AKID999" + assert config.secret_key is not None + assert config.secret_key.get_secret_value() == "secret999" + + logger.info("All fields correctly loaded from env") + + def test_case_insensitive_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Environment variables should be case insensitive.""" + logger.info("Testing case insensitive env loading") + + # Use lowercase env var names + monkeypatch.setenv("nacos_server_address", "localhost:8848") + monkeypatch.setenv("nacos_namespace_id", "test-ns") + + config = NacosConnConfigModel() + + assert config.server_address == "localhost:8848" + assert config.namespace_id == "test-ns" + + logger.info("Case insensitive env loading works") + diff --git a/tests/models/bootstrap/test_bootstrap_configuration.py b/tests/models/bootstrap/test_bootstrap_configuration.py new file mode 100644 index 0000000..4d48f60 --- /dev/null +++ b/tests/models/bootstrap/test_bootstrap_configuration.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright © 2025 Leviftas authors. All rights reserved. + +Licensed under the GNU General Public License v3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.gnu.org/licenses/gpl-3.0.html + +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. + +test_bootstrap_configuration.py + +Tests for BootstrapConfigurationModel. + +- Author : FrostLeo +- Created : 2025/12/22 +- Modified : 2025/12/22 +""" + +import logging +import platform +import socket + +import pytest +from pydantic import ValidationError + +from leviftas.models.bootstrap.bootstrap_configuration_model import ( + BootstrapConfigurationModel, +) +from leviftas.models.bootstrap.config_center import ( + ConfigCenterBaseModel, + NacosConnConfigModel, +) + + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.internal + + +class TestModelConfig: + """Tests for BootstrapConfigurationModel model_config.""" + + def test_cli_parse_args_enabled(self) -> None: + """CLI parsing should be enabled for bootstrap config.""" + logger.info("Testing cli_parse_args is enabled") + assert BootstrapConfigurationModel.model_config.get("cli_parse_args") is True + logger.info("cli_parse_args is enabled") + + def test_cli_prog_name(self) -> None: + """CLI prog name should be 'leviftas'.""" + logger.info("Testing cli_prog_name") + assert BootstrapConfigurationModel.model_config.get("cli_prog_name") == "leviftas" + logger.info("cli_prog_name is 'leviftas'") + + def test_secrets_dir_linux(self) -> None: + """secrets_dir should be /run/secrets for container deployment.""" + logger.info("Testing secrets_dir") + assert BootstrapConfigurationModel.model_config.get("secrets_dir") == "/run/secrets" + logger.info("secrets_dir is /run/secrets") + + def test_inherits_parent_config(self) -> None: + """Should inherit configuration from InternalBaseSettings.""" + logger.info("Testing parent config inheritance") + + assert BootstrapConfigurationModel.model_config.get("env_prefix") == "LEVIFTAS_" + assert BootstrapConfigurationModel.model_config.get("env_file") == ".env" + assert BootstrapConfigurationModel.model_config.get("env_nested_delimiter") == "__" + + logger.info("Parent config inherited correctly") + + +class TestAutoDetectedFields: + """Tests for auto-detected system information fields.""" + + def test_hostname_auto_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """hostname should be auto-detected from system.""" + logger.info("Testing hostname auto-detection") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.hostname == socket.gethostname() + logger.info("hostname correctly auto-detected: %s", config.hostname) + + def test_os_type_auto_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """os_type should be auto-detected from system.""" + logger.info("Testing os_type auto-detection") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.os_type == platform.system() + logger.info("os_type correctly auto-detected: %s", config.os_type) + + def test_os_version_auto_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """os_version should be auto-detected from system.""" + logger.info("Testing os_version auto-detection") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.os_version == platform.version() + logger.info("os_version correctly auto-detected") + + def test_platform_arch_auto_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """platform_arch should be auto-detected from system.""" + logger.info("Testing platform_arch auto-detection") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.platform_arch == platform.machine() + logger.info("platform_arch correctly auto-detected: %s", config.platform_arch) + + def test_node_id_defaults_to_hostname(self, monkeypatch: pytest.MonkeyPatch) -> None: + """node_id should default to hostname.""" + logger.info("Testing node_id defaults to hostname") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.node_id == socket.gethostname() + logger.info("node_id correctly defaults to hostname") + + def test_instance_id_is_uuid(self, monkeypatch: pytest.MonkeyPatch) -> None: + """instance_id should be a valid UUID.""" + logger.info("Testing instance_id is UUID") + + import uuid + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + # Should be parseable as UUID + parsed_uuid = uuid.UUID(config.instance_id) + assert str(parsed_uuid) == config.instance_id + + logger.info("instance_id is valid UUID: %s", config.instance_id) + + def test_instance_id_unique_per_instance( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Each instance should have a unique instance_id.""" + logger.info("Testing instance_id uniqueness") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config1 = BootstrapConfigurationModel(_cli_parse_args=False) + config2 = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config1.instance_id != config2.instance_id + + logger.info("instance_id is unique per instance") + + +class TestRequiredFields: + """Tests for required fields.""" + + def test_service_name_required(self, monkeypatch: pytest.MonkeyPatch) -> None: + """service_name should be required.""" + logger.info("Testing service_name is required") + + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + with pytest.raises(ValidationError) as exc_info: + BootstrapConfigurationModel(_cli_parse_args=False) + + errors = exc_info.value.errors() + field_names = [e["loc"][0] for e in errors] + assert "service_name" in field_names + + logger.info("service_name is correctly required") + + +class TestDefaultValues: + """Tests for default values.""" + + def test_env_default_dev(self, monkeypatch: pytest.MonkeyPatch) -> None: + """env should default to 'dev'.""" + logger.info("Testing env default") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.env == "dev" + logger.info("env defaults to 'dev'") + + def test_config_source_default_remote(self, monkeypatch: pytest.MonkeyPatch) -> None: + """config_source should default to 'remote'.""" + logger.info("Testing config_source default") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_CENTER_TYPE", "nacos") + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "localhost:8848") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.config_source == "remote" + logger.info("config_source defaults to 'remote'") + + def test_config_dir_uses_platformdirs( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """config_dir should use platformdirs for default.""" + logger.info("Testing config_dir default") + + from platformdirs import user_config_dir + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + expected_dir = user_config_dir("leviftas") + assert config.config_dir == expected_dir + + logger.info("config_dir correctly uses platformdirs: %s", config.config_dir) + + +class TestConfigSourceValidation: + """Tests for config_source validation logic.""" + + def test_remote_requires_config_center_type( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """config_source='remote' should require config_center_type.""" + logger.info("Testing remote requires config_center_type") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "remote") + # Not setting config_center_type + + with pytest.raises(ValueError) as exc_info: + BootstrapConfigurationModel(_cli_parse_args=False) + + assert "config_center_type is required" in str(exc_info.value) + + logger.info("remote correctly requires config_center_type") + + def test_local_does_not_require_config_center_type( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """config_source='local' should not require config_center_type.""" + logger.info("Testing local does not require config_center_type") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.config_source == "local" + assert config.config_center_type is None + assert config.config_center is None + + logger.info("local correctly does not require config_center_type") + + def test_config_source_literal_validation( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """config_source should only accept 'remote' or 'local'.""" + logger.info("Testing config_source literal validation") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "invalid") + + with pytest.raises(ValidationError): + BootstrapConfigurationModel(_cli_parse_args=False) + + logger.info("config_source correctly validates literal values") + + +class TestConfigCenterAutoLoad: + """Tests for automatic config_center loading.""" + + def test_auto_load_nacos_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Should auto-load NacosConnConfigModel when type is 'nacos'.""" + logger.info("Testing auto-load Nacos config") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "remote") + monkeypatch.setenv("LEVIFTAS_CONFIG_CENTER_TYPE", "nacos") + monkeypatch.setenv("NACOS_SERVER_ADDRESS", "nacos.example.com:8848") + monkeypatch.setenv("NACOS_NAMESPACE_ID", "prod") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.config_center is not None + assert isinstance(config.config_center, NacosConnConfigModel) + assert isinstance(config.config_center, ConfigCenterBaseModel) + assert config.config_center.server_address == "nacos.example.com:8848" + assert config.config_center.namespace_id == "prod" + + logger.info("Nacos config correctly auto-loaded") + + def test_unknown_config_center_type_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Unknown config_center_type should raise ValueError.""" + logger.info("Testing unknown config_center_type") + + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "remote") + monkeypatch.setenv("LEVIFTAS_CONFIG_CENTER_TYPE", "unknown") + + with pytest.raises(ValueError) as exc_info: + BootstrapConfigurationModel(_cli_parse_args=False) + + assert "Unknown config center type" in str(exc_info.value) + + logger.info("Unknown config_center_type correctly raises ValueError") + + +class TestEnvLoading: + """Tests for environment variable loading.""" + + def test_all_fields_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """All fields should load from environment variables.""" + logger.info("Testing all fields from env") + + monkeypatch.setenv("LEVIFTAS_HOSTNAME", "custom-host") + monkeypatch.setenv("LEVIFTAS_NODE_ID", "node-001") + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "my-service") + monkeypatch.setenv("LEVIFTAS_ENV", "production") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + monkeypatch.setenv("LEVIFTAS_CONFIG_DIR", "/etc/leviftas") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.hostname == "custom-host" + assert config.node_id == "node-001" + assert config.service_name == "my-service" + assert config.env == "production" + assert config.config_source == "local" + assert config.config_dir == "/etc/leviftas" + + logger.info("All fields correctly loaded from env") + + def test_env_overrides_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Environment variables should override defaults.""" + logger.info("Testing env overrides defaults") + + original_hostname = socket.gethostname() + + monkeypatch.setenv("LEVIFTAS_HOSTNAME", "overridden-host") + monkeypatch.setenv("LEVIFTAS_SERVICE_NAME", "test-service") + monkeypatch.setenv("LEVIFTAS_CONFIG_SOURCE", "local") + + config = BootstrapConfigurationModel(_cli_parse_args=False) + + assert config.hostname != original_hostname + assert config.hostname == "overridden-host" + + logger.info("Env correctly overrides defaults") +