From 3435b10af4ead3c57a5a1f655299d24cc7f46e2d Mon Sep 17 00:00:00 2001 From: Dimitri Pisarev Date: Sat, 7 Feb 2026 23:42:08 +0100 Subject: [PATCH] feat: migrate config to pydantic-settings (#005) - Replace dataclass + os.getenv() with pydantic-settings BaseSettings - Add typed sub-configs: DatabaseConfig, RedisConfig, QRSizeConfig, WebhookConfig, EventBusConfig - Auto-validation of all fields at startup with clear error messages - URL-encode DB password in DSN (fixes special chars in passwords) - Remove boolean parsing boilerplate (pydantic handles it) - Remove duplicate db_dsn/database_url fields (use property) - Align Python version to 3.13 across pyproject, Dockerfile, CI - Update all consumers: main.py, DI modules, integration tests - Suppress UP046/UP047 lint warnings (PEP 695, separate task) - All 1667 unit tests pass --- .github/workflows/ci.yml | 2 +- .github/workflows/quick-check.yml | 2 +- bot/config.py | 289 +++++++++++++++++------------- bot/di/modules/infrastructure.py | 14 +- main.py | 22 +-- pyproject.toml | 4 +- tests/integration/conftest.py | 87 ++++++--- 7 files changed, 242 insertions(+), 178 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a72769b..c73f322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ permissions: # Переменные окружения env: - PYTHON_VERSION: '3.14' + PYTHON_VERSION: '3.13' jobs: # Job 1: Проверка кода diff --git a/.github/workflows/quick-check.yml b/.github/workflows/quick-check.yml index 3c62f84..329bd58 100644 --- a/.github/workflows/quick-check.yml +++ b/.github/workflows/quick-check.yml @@ -26,7 +26,7 @@ permissions: pull-requests: write env: - PYTHON_VERSION: '3.14' + PYTHON_VERSION: '3.13' jobs: quick-check: diff --git a/bot/config.py b/bot/config.py index eb970e7..4addbda 100644 --- a/bot/config.py +++ b/bot/config.py @@ -1,144 +1,175 @@ -"""Bot configuration""" +"""Bot configuration using pydantic-settings. + +Validates all settings at startup with clear error messages. +Replaces manual os.getenv() calls with typed, validated fields. +""" -import os -from dataclasses import dataclass from pathlib import Path +from urllib.parse import quote_plus -from dotenv import load_dotenv +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict -load_dotenv() +class BotConfig(BaseSettings): + """Telegram bot credentials.""" -@dataclass -class BotConfig: - """Bot configuration class""" + model_config = SettingsConfigDict(env_prefix="") - token: str - admin_ids: list[int] + token: str = Field(alias="BOT_TOKEN") + admin_ids: list[int] = Field(default_factory=list, alias="ADMIN_IDS") + @field_validator("admin_ids", mode="before") + @classmethod + def parse_admin_ids(cls, v: object) -> object: + if isinstance(v, str): + return [int(x.strip()) for x in v.split(",") if x.strip()] + if isinstance(v, int): + return [v] + return v -@dataclass -class Config: - """Main configuration class""" - bot: BotConfig - locales_path: Path - default_locale: str - app_url: str - site_url: str - redirect_port: int - db_dsn: str - redis_url: str - database_url: str - event_bus_mode: str - event_bus_consumer_name: str - event_store_enabled: bool - cache_warming_enabled: bool - qr_size_small: int - qr_size_medium: int - qr_size_large: int - # Webhook configuration - bot_mode: str - webhook_host: str - webhook_port: int - webhook_path: str - webhook_secret: str - webhook_url: str +class DatabaseConfig(BaseSettings): + """PostgreSQL connection settings.""" + model_config = SettingsConfigDict(env_prefix="DB_") -def load_config() -> Config: - """Load configuration from environment""" - bot_token = os.getenv("BOT_TOKEN") - if not bot_token: - raise ValueError("BOT_TOKEN is not set in environment variables") - - admin_ids_str = os.getenv("ADMIN_IDS", "") - admin_ids = [int(x.strip()) for x in admin_ids_str.split(",") if x.strip()] - - db_host = os.getenv("DB_HOST", "localhost") - db_port = os.getenv("DB_PORT", "5432") - db_name = os.getenv("DB_NAME", "qr_bot") - db_user = os.getenv("DB_USER", "qr_bot_user") - db_password = os.getenv("DB_PASSWORD", "") - - db_dsn = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" - - # Redis configuration - redis_host = os.getenv("REDIS_HOST", "localhost") - redis_port = os.getenv("REDIS_PORT", "6379") - redis_db = os.getenv("REDIS_DB", "0") - redis_password = os.getenv("REDIS_PASSWORD", "") - if redis_password: - redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}" - else: - redis_url = f"redis://{redis_host}:{redis_port}/{redis_db}" - - # Event Bus configuration - event_bus_mode = os.getenv("EVENT_BUS_MODE", "redis") # 'memory' or 'redis' - event_bus_consumer_name = os.getenv( - "EVENT_BUS_CONSUMER_NAME", "" - ) # Auto-generated if empty - - # Event Store configuration (PostgreSQL persistence) - event_store_enabled = os.getenv("EVENT_STORE_ENABLED", "true").lower() in ( - "true", - "1", - "yes", - ) + host: str = "localhost" + port: int = 5432 + name: str = "qr_bot" + user: str = "qr_bot_user" + password: str = "" - # Cache warming configuration - cache_warming_enabled = os.getenv("CACHE_WARMING_ENABLED", "true").lower() in ( - "true", - "1", - "yes", - ) + @property + def dsn(self) -> str: + """Build PostgreSQL DSN with proper URL encoding.""" + password = quote_plus(self.password) if self.password else "" + return ( + f"postgresql://{self.user}:{password}@{self.host}:{self.port}/{self.name}" + ) + + +class RedisConfig(BaseSettings): + """Redis connection settings.""" + + model_config = SettingsConfigDict(env_prefix="REDIS_") + + host: str = "localhost" + port: int = 6379 + db: int = 0 + password: str = "" + + @property + def url(self) -> str: + if self.password: + return f"redis://:{self.password}@{self.host}:{self.port}/{self.db}" + return f"redis://{self.host}:{self.port}/{self.db}" + + +class QRSizeConfig(BaseSettings): + """QR Code size presets (pixels).""" + + model_config = SettingsConfigDict(env_prefix="QR_SIZE_") + + small: int = 300 + medium: int = 600 + large: int = 1024 + + @field_validator("small", "medium", "large") + @classmethod + def validate_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("QR size must be positive") + return v + + +class WebhookConfig(BaseSettings): + """Webhook mode settings.""" - # App URL for dynamic QR redirects - app_url = os.getenv("APP_URL", "https://go.make-qr.de") - - # Site URL for fallback redirects (main website) - site_url = os.getenv("SITE_URL", "https://make-qr.de") - - # Redirect server port (standalone mode) - redirect_port = int(os.getenv("REDIRECT_PORT", "8080")) - - # QR Code size settings - qr_size_small = int(os.getenv("QR_SIZE_SMALL", "300")) - qr_size_medium = int(os.getenv("QR_SIZE_MEDIUM", "600")) - qr_size_large = int(os.getenv("QR_SIZE_LARGE", "1024")) - - # Webhook configuration - bot_mode = os.getenv("BOT_MODE", "polling").lower() # "polling" or "webhook" - webhook_host = os.getenv( - "WEBHOOK_HOST", "127.0.0.1" - ) # Default to localhost for security - webhook_port = int(os.getenv("WEBHOOK_PORT", "8080")) - webhook_path = os.getenv("WEBHOOK_PATH", "/webhook") - webhook_secret = os.getenv("WEBHOOK_SECRET", "") - webhook_base_url = os.getenv("WEBHOOK_BASE_URL", "") # e.g., https://yourdomain.com - webhook_url = f"{webhook_base_url}{webhook_path}" if webhook_base_url else "" - - return Config( - bot=BotConfig(token=bot_token, admin_ids=admin_ids), - locales_path=Path(__file__).parent.parent / "locales", - default_locale=os.getenv("DEFAULT_LOCALE", "en"), - app_url=app_url, - site_url=site_url, - redirect_port=redirect_port, - db_dsn=db_dsn, - database_url=db_dsn, # Alias for compatibility - redis_url=redis_url, - event_bus_mode=event_bus_mode, - event_bus_consumer_name=event_bus_consumer_name, - event_store_enabled=event_store_enabled, - cache_warming_enabled=cache_warming_enabled, - qr_size_small=qr_size_small, - qr_size_medium=qr_size_medium, - qr_size_large=qr_size_large, - bot_mode=bot_mode, - webhook_host=webhook_host, - webhook_port=webhook_port, - webhook_path=webhook_path, - webhook_secret=webhook_secret, - webhook_url=webhook_url, + model_config = SettingsConfigDict(env_prefix="WEBHOOK_") + + host: str = "127.0.0.1" + port: int = 8080 + path: str = "/webhook" + secret: str = "" + base_url: str = "" + + @property + def full_url(self) -> str: + return f"{self.base_url}{self.path}" if self.base_url else "" + + +class EventBusConfig(BaseSettings): + """Event bus settings.""" + + model_config = SettingsConfigDict(env_prefix="EVENT_BUS_") + + mode: str = "redis" + consumer_name: str = "" + + @field_validator("mode") + @classmethod + def validate_mode(cls, v: str) -> str: + if v not in ("memory", "redis"): + raise ValueError(f"Invalid mode: {v}. Must be 'memory' or 'redis'") + return v + + +class Config(BaseSettings): + """Main application configuration. + + Loads from environment variables and .env file automatically. + All values are validated at startup — invalid config = immediate crash + with clear error messages. + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", ) + + bot: BotConfig = Field(default_factory=BotConfig) + database: DatabaseConfig = Field(default_factory=DatabaseConfig) + redis: RedisConfig = Field(default_factory=RedisConfig) + qr_sizes: QRSizeConfig = Field(default_factory=QRSizeConfig) + webhook: WebhookConfig = Field(default_factory=WebhookConfig) + event_bus: EventBusConfig = Field(default_factory=EventBusConfig) + + # Simple settings + app_url: str = Field(default="https://go.make-qr.de", alias="APP_URL") + site_url: str = Field(default="https://make-qr.de", alias="SITE_URL") + redirect_port: int = Field(default=8080, alias="REDIRECT_PORT") + default_locale: str = Field(default="en", alias="DEFAULT_LOCALE") + bot_mode: str = Field(default="polling", alias="BOT_MODE") + event_store_enabled: bool = Field(default=True, alias="EVENT_STORE_ENABLED") + cache_warming_enabled: bool = Field(default=True, alias="CACHE_WARMING_ENABLED") + + @field_validator("bot_mode") + @classmethod + def validate_bot_mode(cls, v: str) -> str: + v = v.lower() + if v not in ("polling", "webhook"): + raise ValueError(f"Invalid bot_mode: {v}. Must be 'polling' or 'webhook'") + return v + + @property + def locales_path(self) -> Path: + return Path(__file__).parent.parent / "locales" + + @property + def db_dsn(self) -> str: + return self.database.dsn + + @property + def redis_url(self) -> str: + return self.redis.url + + +def load_config() -> Config: + """Load and validate configuration. + + Raises pydantic.ValidationError with detailed messages + if any required field is missing or has invalid value. + """ + return Config() diff --git a/bot/di/modules/infrastructure.py b/bot/di/modules/infrastructure.py index 3c10885..0aed74f 100644 --- a/bot/di/modules/infrastructure.py +++ b/bot/di/modules/infrastructure.py @@ -56,9 +56,9 @@ def register(self, container) -> None: # QR Code Generator with config-based sizes size_config = QRSizeConfig( - small=config.qr_size_small, - medium=config.qr_size_medium, - large=config.qr_size_large, + small=config.qr_sizes.small, + medium=config.qr_sizes.medium, + large=config.qr_sizes.large, ) container.register_lazy( "qr_generator", @@ -111,11 +111,11 @@ async def configure(self, container) -> None: except Exception as e: logger.warning(f"⚠️ Enhanced cache service initialization failed: {e}") - # Create and initialize event bus + # Event Bus configuration event_bus_instance = create_event_bus( - mode=config.event_bus_mode, - redis_url=config.redis_url if config.event_bus_mode == "redis" else None, - consumer_name=config.event_bus_consumer_name or None, + mode=config.event_bus.mode, + redis_url=config.redis_url if config.event_bus.mode == "redis" else None, + consumer_name=config.event_bus.consumer_name or None, ) await initialize_event_bus(event_bus_instance) diff --git a/main.py b/main.py index b1c74ea..27f05b6 100644 --- a/main.py +++ b/main.py @@ -84,13 +84,13 @@ async def on_startup(bot: Bot, container: Container, config): await set_bot_commands(bot, config) # Set webhook if in webhook mode - if config.bot_mode == "webhook" and config.webhook_url: + if config.bot_mode == "webhook" and config.webhook.full_url: await bot.set_webhook( - url=config.webhook_url, - secret_token=config.webhook_secret if config.webhook_secret else None, + url=config.webhook.full_url, + secret_token=config.webhook.secret or None, drop_pending_updates=True, ) - logger.info(f"Webhook set to: {config.webhook_url}") + logger.info(f"Webhook set to: {config.webhook.full_url}") # Start cache warming in background (non-blocking) if config.cache_warming_enabled: @@ -174,11 +174,11 @@ async def start_redirect_server(config, container: Container) -> web.AppRunner: async def start_webhook(bot: Bot, dp: Dispatcher, config, container: Container): """Start bot in webhook mode""" - if not config.webhook_url: + if not config.webhook.full_url: raise ValueError("WEBHOOK_BASE_URL must be set for webhook mode") logger.info( - f"Starting bot in webhook mode on {config.webhook_host}:{config.webhook_port}{config.webhook_path}" + f"Starting bot in webhook mode on {config.webhook.host}:{config.webhook.port}{config.webhook.path}" ) # Create aiohttp application @@ -188,11 +188,11 @@ async def start_webhook(bot: Bot, dp: Dispatcher, config, container: Container): webhook_requests_handler = SimpleRequestHandler( dispatcher=dp, bot=bot, - secret_token=config.webhook_secret if config.webhook_secret else None, + secret_token=config.webhook.secret or None, ) # Register webhook handler - webhook_requests_handler.register(app, path=config.webhook_path) + webhook_requests_handler.register(app, path=config.webhook.path) # Register QR redirect routes on the same app redirect_handler = RedirectHandler( @@ -213,11 +213,11 @@ async def start_webhook(bot: Bot, dp: Dispatcher, config, container: Container): # Start webhook server runner = web.AppRunner(app) await runner.setup() - site = web.TCPSite(runner, host=config.webhook_host, port=config.webhook_port) + site = web.TCPSite(runner, host=config.webhook.host, port=config.webhook.port) await site.start() logger.info( - f"Webhook server started on http://{config.webhook_host}:{config.webhook_port}{config.webhook_path}" + f"Webhook server started on https://{config.webhook.host}:{config.webhook.port}{config.webhook.path}" ) # Keep the server running @@ -239,7 +239,7 @@ async def main(): try: config = load_config() - except ValueError as e: + except Exception as e: logger.critical(f"Configuration error: {e}") return diff --git a/pyproject.toml b/pyproject.toml index 6a1a70e..d52619b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.ruff] # Основные настройки line-length = 88 -target-version = "py311" +target-version = "py313" # Директории для проверки src = ["bot", "tests"] @@ -40,6 +40,8 @@ ignore = [ "E501", # line too long (пусть black решает) "B008", # do not perform function calls in argument defaults "N999", # Invalid module name + "UP046", # Generic[T] → type params (PEP 695) — TODO: отдельный PR + "UP047", # Generic function type params (PEP 695) — TODO: отдельный PR ] # Автоисправления для этих правил diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6a6fb75..3be8773 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -28,7 +28,6 @@ from collections.abc import AsyncGenerator from dataclasses import dataclass, field from datetime import UTC, datetime -from pathlib import Path import asyncpg import pytest @@ -333,7 +332,7 @@ async def _clean_tables(pool: asyncpg.Pool) -> None: # Use loop_scope="session" to share event loop with module-scoped fixtures @pytest_asyncio.fixture(loop_scope="session", scope="session") -async def db_pool(test_config: TestConfig) -> AsyncGenerator[asyncpg.Pool, None]: +async def db_pool(test_config: TestConfig) -> AsyncGenerator[asyncpg.Pool]: """ Create database connection pool for test module. @@ -426,34 +425,66 @@ async def clean_redis(redis_client) -> None: @pytest_asyncio.fixture(loop_scope="session", scope="session") async def app_config(test_config: TestConfig): """Create application Config for testing.""" - from bot.config import BotConfig, Config + from bot.config import ( + BotConfig, + Config, + DatabaseConfig, + EventBusConfig, + QRSizeConfig, + RedisConfig, + WebhookConfig, + ) + + # Parse test database URL to extract components + # Format: postgresql://user:password@host:port/dbname + db_url = test_config.database_url + # Extract parts from URL + import re + + m = re.match(r"postgresql://([^:]+):([^@]*)@([^:]+):(\d+)/(.+)", db_url) + if m: + db_user, db_password, db_host, db_port, db_name = m.groups() + else: + db_user, db_password, db_host, db_port, db_name = ( + "qr_bot_user", + "", + "localhost", + "5432", + "qr_bot_test", + ) + + # Parse Redis URL + redis_url = test_config.redis_url + redis_m = re.match(r"redis://([^:]+):(\d+)/(\d+)", redis_url) + if redis_m: + redis_host, redis_port, redis_db = redis_m.groups() + else: + redis_host, redis_port, redis_db = "localhost", "6379", "0" return Config( - bot=BotConfig( - token="test_token_123456789", - admin_ids=[123456789], + bot=BotConfig(BOT_TOKEN="test_token_123456789", ADMIN_IDS=[123456789]), + database=DatabaseConfig( + host=db_host, + port=int(db_port), + name=db_name, + user=db_user, + password=db_password, + ), + redis=RedisConfig( + host=redis_host, + port=int(redis_port), + db=int(redis_db), ), - locales_path=Path(__file__).parent.parent.parent / "locales", - default_locale="en", - app_url="https://test.example.com", - site_url="https://test.example.com", - redirect_port=8080, - db_dsn=test_config.database_url, - database_url=test_config.database_url, - redis_url=test_config.redis_url, - event_bus_mode="memory", # Use in-memory for faster tests - event_bus_consumer_name="test_consumer", - event_store_enabled=True, - cache_warming_enabled=False, # Disable for faster tests - qr_size_small=300, - qr_size_medium=600, - qr_size_large=1024, - bot_mode="polling", # Use polling for tests - webhook_host="127.0.0.1", # Use localhost for tests - webhook_port=8080, - webhook_path="/webhook", - webhook_secret="test_secret", - webhook_url="", + qr_sizes=QRSizeConfig(), + webhook=WebhookConfig(secret="test_secret"), + event_bus=EventBusConfig(mode="memory"), + APP_URL="https://test.example.com", + SITE_URL="https://test.example.com", + REDIRECT_PORT=8080, + DEFAULT_LOCALE="en", + BOT_MODE="polling", + EVENT_STORE_ENABLED=True, + CACHE_WARMING_ENABLED=False, ) @@ -481,7 +512,7 @@ async def container( from bot.di.container import Container container = await Container.create( - db_dsn=app_config.database_url, + db_dsn=app_config.db_dsn, config=app_config, )