From 299a8e69ea9266a0af88166aa5fad1db298e7e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sun, 8 Mar 2026 16:22:22 +0300 Subject: [PATCH] 010 fix config --- app/config.py | 45 ++++- config.test.yaml | 41 ++++ docker-compose.yml | 5 - spec/index.md | 8 +- .../010_migrate_to_yaml_only_configuration.md | 2 +- tests/config/fixtures/config.default.yaml | 21 ++ .../fixtures/config.missing_alerts.yaml | 22 +++ .../config/fixtures/config.test.override.yaml | 21 ++ tests/config/test_config_sections.py | 181 +++++++++--------- tests/smoke/test_app_import.py | 33 +++- tests/smoke/test_local_infra_stack.py | 21 -- 11 files changed, 267 insertions(+), 133 deletions(-) create mode 100644 config.test.yaml create mode 100644 tests/config/fixtures/config.default.yaml create mode 100644 tests/config/fixtures/config.missing_alerts.yaml create mode 100644 tests/config/fixtures/config.test.override.yaml diff --git a/app/config.py b/app/config.py index 978497d..de37980 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,9 @@ """Application configuration split by architecture component.""" -import os from functools import lru_cache +from pathlib import Path +import sys +from typing import Any from pydantic import BaseModel, Field, model_validator from pydantic_settings import ( @@ -12,7 +14,7 @@ from pydantic_settings import ( ) DEFAULT_CONFIG_FILE = "config.yaml" -CONFIG_FILE_ENV = "G2S_CONFIG_FILE" +TEST_CONFIG_FILE = "config.test.yaml" class ControllerConfig(BaseModel): @@ -94,8 +96,6 @@ class AlertsConfig(BaseModel): class Settings(BaseSettings): model_config = SettingsConfigDict( - env_prefix="G2S_", - env_nested_delimiter="__", extra="ignore", ) @@ -116,19 +116,48 @@ class Settings(BaseSettings): dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: + config_file = _resolve_runtime_config_file() yaml_source = YamlConfigSettingsSource( settings_cls, - yaml_file=os.getenv(CONFIG_FILE_ENV, DEFAULT_CONFIG_FILE), + yaml_file=config_file, ) return ( init_settings, - env_settings, - dotenv_settings, yaml_source, - file_secret_settings, ) +class _RequiredYamlSections(BaseModel): + controller: dict[str, Any] + service: dict[str, Any] + business_logic: dict[str, Any] + repository: dict[str, Any] + adapter: dict[str, Any] + observability: dict[str, Any] + alerts: dict[str, Any] + + +def _resolve_runtime_config_file() -> str: + test_config_path = Path(TEST_CONFIG_FILE) + if "pytest" in sys.modules and test_config_path.is_file(): + return TEST_CONFIG_FILE + return DEFAULT_CONFIG_FILE + + +def _validate_required_yaml_sections() -> None: + config_file = _resolve_runtime_config_file() + config_path = Path(config_file) + if not config_path.is_file(): + return + + yaml_source = YamlConfigSettingsSource(Settings, yaml_file=config_file) + yaml_data = yaml_source.yaml_data + if not isinstance(yaml_data, dict): + yaml_data = {} + _RequiredYamlSections.model_validate(yaml_data) + + @lru_cache(maxsize=1) def get_settings() -> Settings: + _validate_required_yaml_sections() return Settings() diff --git a/config.test.yaml b/config.test.yaml new file mode 100644 index 0000000..c288f65 --- /dev/null +++ b/config.test.yaml @@ -0,0 +1,41 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 + +observability: + service_name: "g2s-aggregator-tests" + otlp_endpoint: "" + log_level: "INFO" + +alerts: + telegram_enabled: false + telegram_bot_token: null + telegram_chat_id: null + provider_5xx: + error_count: 5 + window_minutes: 5 + provider_p99_latency: + threshold_ms: 5000 + window_minutes: 10 + provider_unavailable: + duration_minutes: 5 diff --git a/docker-compose.yml b/docker-compose.yml index d17643a..13fac81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,6 @@ services: app: image: yusupal1ev/g2s-aggregator:0.0.0 - environment: - G2S_CONFIG_FILE: /app/config.yaml - G2S_REPOSITORY__REDIS_DSN: redis://redis:6379/0 - G2S_OBSERVABILITY__OTLP_ENDPOINT: http://signoz:4317 - PYTHONUNBUFFERED: "1" ports: - "8000:8000" depends_on: diff --git a/spec/index.md b/spec/index.md index f2b1525..14b9439 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-08T12:18:20+00:00` +> Generated at (UTC): `2026-03-08T13:21:58+00:00` ## Tasks @@ -17,10 +17,10 @@ | 007 | DONE | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | | 008 | DONE | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | | 009 | DONE | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | -| 010 | TODO | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` | +| 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` | ## Summary - Total: **11** -- TODO: **1** -- DONE: **10** +- TODO: **0** +- DONE: **11** diff --git a/spec/tasks/010_migrate_to_yaml_only_configuration.md b/spec/tasks/010_migrate_to_yaml_only_configuration.md index aa74a38..16b21fc 100644 --- a/spec/tasks/010_migrate_to_yaml_only_configuration.md +++ b/spec/tasks/010_migrate_to_yaml_only_configuration.md @@ -1,7 +1,7 @@ --- id: 010 title: Migrate to YAML-only configuration loading -status: TODO +status: DONE created: 2026-03-08 --- diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml new file mode 100644 index 0000000..392ab4b --- /dev/null +++ b/tests/config/fixtures/config.default.yaml @@ -0,0 +1,21 @@ +controller: + api_prefix: "/from-config-yaml" + +service: + provider_timeout_seconds: 31.0 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + +adapter: + cdek_client_id: "yaml-id" + cdek_client_secret: "yaml-secret" + +observability: + service_name: "from-config-yaml" + +alerts: + telegram_enabled: false diff --git a/tests/config/fixtures/config.missing_alerts.yaml b/tests/config/fixtures/config.missing_alerts.yaml new file mode 100644 index 0000000..27232fd --- /dev/null +++ b/tests/config/fixtures/config.missing_alerts.yaml @@ -0,0 +1,22 @@ +controller: + api_prefix: "/api/v1" + +service: + provider_timeout_seconds: 10.0 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "id" + cdek_client_secret: "secret" + +observability: + service_name: "g2s-aggregator" + otlp_endpoint: "" + log_level: "INFO" diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml new file mode 100644 index 0000000..bf4e8be --- /dev/null +++ b/tests/config/fixtures/config.test.override.yaml @@ -0,0 +1,21 @@ +controller: + api_prefix: "/from-config-test-yaml" + +service: + provider_timeout_seconds: 17.0 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + +adapter: + cdek_client_id: "test-id" + cdek_client_secret: "test-secret" + +observability: + service_name: "from-config-test-yaml" + +alerts: + telegram_enabled: false diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 27e11ce..c0c7177 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -1,98 +1,105 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app import config as config_module from app.config import Settings, get_settings - -def test_configuration_sections_are_loaded_from_env(monkeypatch) -> None: - monkeypatch.setenv("G2S_CONTROLLER__API_PREFIX", "/internal/v1") - monkeypatch.setenv("G2S_SERVICE__MAX_PARALLEL_PROVIDERS", "12") - monkeypatch.setenv("G2S_BUSINESS_LOGIC__WEIGHT_ROUND_SCALE", "3") - monkeypatch.setenv("G2S_REPOSITORY__PRICE_CACHE_TTL_SECONDS", "1200") - monkeypatch.setenv("G2S_ADAPTER__CDEK_BASE_URL", "https://sandbox.cdek.test") - monkeypatch.setenv("G2S_ADAPTER__CDEK_CLIENT_ID", "env-client-id") - monkeypatch.setenv("G2S_ADAPTER__CDEK_CLIENT_SECRET", "env-client-secret") - monkeypatch.setenv("G2S_ADAPTER__CDEK_RETRY_ATTEMPTS", "4") - monkeypatch.setenv("G2S_ADAPTER__CDEK_RETRY_BACKOFF_SECONDS", "0.7") - monkeypatch.setenv("G2S_ADAPTER__CDEK_TIMEOUT_SECONDS", "8.5") - monkeypatch.setenv("G2S_ADAPTER__CDEK_CACHE_TTL_SECONDS", "780") - monkeypatch.setenv("G2S_OBSERVABILITY__SERVICE_NAME", "g2s-test") - monkeypatch.setenv("G2S_ALERTS__TELEGRAM_ENABLED", "true") - monkeypatch.setenv("G2S_ALERTS__TELEGRAM_BOT_TOKEN", "bot-token") - monkeypatch.setenv("G2S_ALERTS__TELEGRAM_CHAT_ID", "-100") - - settings = Settings() - - assert settings.controller.api_prefix == "/internal/v1" - assert settings.service.max_parallel_providers == 12 - assert settings.business_logic.weight_round_scale == 3 - assert settings.repository.price_cache_ttl_seconds == 1200 - assert settings.adapter.cdek_base_url == "https://sandbox.cdek.test" - assert settings.adapter.cdek_client_id == "env-client-id" - assert settings.adapter.cdek_client_secret == "env-client-secret" - assert settings.adapter.cdek_retry_attempts == 4 - assert settings.adapter.cdek_retry_backoff_seconds == 0.7 - assert settings.adapter.cdek_timeout_seconds == 8.5 - assert settings.adapter.cdek_cache_ttl_seconds == 780 - assert settings.observability.service_name == "g2s-test" - assert settings.alerts.telegram_enabled is True +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TEST_CONFIG_FILE = PROJECT_ROOT / "config.test.yaml" +MISSING_ALERTS_CONFIG_FILE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_alerts.yaml" +) +DEFAULT_CONFIG_FILE_FIXTURE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.default.yaml" +) +TEST_OVERRIDE_CONFIG_FILE_FIXTURE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.test.override.yaml" +) -def test_configuration_sections_are_loaded_from_yaml_file(tmp_path, monkeypatch) -> None: - config_file = tmp_path / "config.yaml" - config_file.write_text( - """ -controller: - api_prefix: "/yaml/v1" -service: - max_parallel_providers: 11 -business_logic: - weight_round_scale: 4 -repository: - price_cache_ttl_seconds: 600 -adapter: - cdek_base_url: "https://yaml.cdek.test" - cdek_client_id: "yaml-client-id" - cdek_client_secret: "yaml-client-secret" - cdek_retry_attempts: 5 - cdek_retry_backoff_seconds: 0.4 - cdek_timeout_seconds: 9.5 - cdek_cache_ttl_seconds: 810 -observability: - service_name: "g2s-yaml" -alerts: - telegram_enabled: true - telegram_bot_token: "yaml-bot-token" - telegram_chat_id: "-100100" -""".strip(), - encoding="utf-8", - ) - monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file)) - - settings = Settings() - - assert settings.controller.api_prefix == "/yaml/v1" - assert settings.service.max_parallel_providers == 11 - assert settings.business_logic.weight_round_scale == 4 - assert settings.repository.price_cache_ttl_seconds == 600 - assert settings.adapter.cdek_base_url == "https://yaml.cdek.test" - assert settings.adapter.cdek_client_id == "yaml-client-id" - assert settings.adapter.cdek_client_secret == "yaml-client-secret" - assert settings.adapter.cdek_retry_attempts == 5 - assert settings.adapter.cdek_retry_backoff_seconds == 0.4 - assert settings.adapter.cdek_timeout_seconds == 9.5 - assert settings.adapter.cdek_cache_ttl_seconds == 810 - assert settings.observability.service_name == "g2s-yaml" - assert settings.alerts.telegram_enabled is True - assert settings.alerts.telegram_bot_token == "yaml-bot-token" - assert settings.alerts.telegram_chat_id == "-100100" - - -def test_get_settings_returns_cached_instance(monkeypatch) -> None: +def _use_runtime_config_files( + monkeypatch: pytest.MonkeyPatch, + *, + test_config_file: Path, + default_config_file: Path | None = None, +) -> None: + monkeypatch.setattr(config_module, "TEST_CONFIG_FILE", str(test_config_file)) + if default_config_file is not None: + monkeypatch.setattr( + config_module, + "DEFAULT_CONFIG_FILE", + str(default_config_file), + ) get_settings.cache_clear() - monkeypatch.setenv("G2S_SERVICE__PROVIDER_TIMEOUT_SECONDS", "22") + + +def test_configuration_sections_are_loaded_from_yaml_file( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files(monkeypatch, test_config_file=TEST_CONFIG_FILE) + + settings = Settings() + + assert settings.controller.api_prefix == "/api/v1" + assert settings.controller.request_id_header == "X-Request-ID" + assert settings.service.provider_timeout_seconds == 10.0 + assert settings.service.max_parallel_providers == 8 + assert settings.business_logic.weight_round_scale == 2 + assert settings.repository.redis_dsn == "redis://localhost:6379/0" + assert settings.repository.price_cache_ttl_seconds == 900 + assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2" + assert settings.adapter.cdek_client_id == "test-client-id" + assert settings.adapter.cdek_client_secret == "test-client-secret" + assert settings.adapter.cdek_retry_attempts == 2 + assert settings.adapter.cdek_retry_backoff_seconds == 0.2 + assert settings.adapter.cdek_timeout_seconds == 10.0 + assert settings.adapter.cdek_cache_ttl_seconds == 900 + assert settings.observability.service_name == "g2s-aggregator-tests" + assert settings.observability.otlp_endpoint == "" + assert settings.observability.log_level == "INFO" + assert settings.alerts.telegram_enabled is False + assert settings.alerts.telegram_bot_token is None + assert settings.alerts.telegram_chat_id is None + + +def test_get_settings_fails_when_required_yaml_section_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files(monkeypatch, test_config_file=MISSING_ALERTS_CONFIG_FILE) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("alerts",) in locations + get_settings.cache_clear() + + +def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -> None: + _use_runtime_config_files(monkeypatch, test_config_file=TEST_CONFIG_FILE) first = get_settings() second = get_settings() assert first is second - assert first.service.provider_timeout_seconds == 22 - + assert first.service.provider_timeout_seconds == 10.0 + get_settings.cache_clear() + + +def test_get_settings_uses_config_test_yaml_in_pytest_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=TEST_OVERRIDE_CONFIG_FILE_FIXTURE, + default_config_file=DEFAULT_CONFIG_FILE_FIXTURE, + ) + + settings = get_settings() + + assert settings.service.provider_timeout_seconds == 17.0 + assert settings.controller.api_prefix == "/from-config-test-yaml" + assert settings.observability.service_name == "from-config-test-yaml" get_settings.cache_clear() diff --git a/tests/smoke/test_app_import.py b/tests/smoke/test_app_import.py index 5c62753..393a61b 100644 --- a/tests/smoke/test_app_import.py +++ b/tests/smoke/test_app_import.py @@ -1,16 +1,35 @@ +import importlib +import sys +from pathlib import Path + from fastapi import FastAPI -from app.config import Settings -from app.main import app, create_app +from app import config as config_module +from app.config import Settings, get_settings + +TEST_CONFIG_FILE = Path(__file__).resolve().parents[2] / "config.test.yaml" + +def _import_main_module(monkeypatch): + get_settings.cache_clear() + monkeypatch.setattr(config_module, "TEST_CONFIG_FILE", str(TEST_CONFIG_FILE)) + sys.modules.pop("app.main", None) + return importlib.import_module("app.main") -def test_app_import_smoke() -> None: - assert isinstance(app, FastAPI) - assert isinstance(app.state.settings, Settings) +def test_app_import_smoke(monkeypatch) -> None: + main_module = _import_main_module(monkeypatch) + + assert isinstance(main_module.app, FastAPI) + assert isinstance(main_module.app.state.settings, Settings) + get_settings.cache_clear() -def test_app_created_with_provided_settings() -> None: +def test_app_created_with_provided_settings(monkeypatch) -> None: + main_module = _import_main_module(monkeypatch) + monkeypatch.setattr(config_module, "TEST_CONFIG_FILE", str(TEST_CONFIG_FILE)) + settings = Settings() - instance = create_app(settings=settings) + instance = main_module.create_app(settings=settings) assert instance.state.settings is settings + get_settings.cache_clear() diff --git a/tests/smoke/test_local_infra_stack.py b/tests/smoke/test_local_infra_stack.py index ed072b5..b371997 100644 --- a/tests/smoke/test_local_infra_stack.py +++ b/tests/smoke/test_local_infra_stack.py @@ -21,27 +21,6 @@ def test_compose_defines_required_services() -> None: assert {"app", "redis", "signoz"}.issubset(services.keys()) -def test_app_service_contains_cache_and_observability_wiring() -> None: - compose = _load_compose() - app_service = compose["services"]["app"] - - build_config = app_service.get("build") - assert isinstance(build_config, dict) - assert build_config["context"] == "." - assert build_config["dockerfile"] == "Dockerfile" - - environment = app_service.get("environment") - assert isinstance(environment, dict) - assert environment["G2S_REPOSITORY__REDIS_DSN"] == "redis://redis:6379/0" - assert environment["G2S_OBSERVABILITY__OTLP_ENDPOINT"] == "http://signoz:4317" - assert environment["G2S_CONFIG_FILE"] == "/app/config.yaml" - - depends_on = app_service.get("depends_on") - assert isinstance(depends_on, list) - assert "redis" in depends_on - assert "signoz" in depends_on - - def test_smoke_command_sequence_is_documented() -> None: readme = INFRA_README.read_text(encoding="utf-8")