diff --git a/pyproject.toml b/pyproject.toml index d8f08a0..958fba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "sqlalchemy>=2.0.0", "alembic>=1.13.0", "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", "python-dotenv>=1.0.0", "websockets>=12.0", "prometheus-client>=0.19.0", diff --git a/src/polymarket_insider_tracker/config.py b/src/polymarket_insider_tracker/config.py new file mode 100644 index 0000000..d63cf03 --- /dev/null +++ b/src/polymarket_insider_tracker/config.py @@ -0,0 +1,263 @@ +"""Configuration management service with Pydantic Settings. + +This module provides centralized configuration management for the +Polymarket Insider Tracker application, loading and validating +environment variables at startup. +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from typing import Literal + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class DatabaseSettings(BaseSettings): + """Database connection settings.""" + + model_config = SettingsConfigDict(env_prefix="") + + url: str = Field( + alias="DATABASE_URL", + description="PostgreSQL connection string", + ) + + @field_validator("url") + @classmethod + def validate_url(cls, v: str) -> str: + """Validate database URL format.""" + if not v.startswith(("postgresql://", "postgresql+asyncpg://")): + raise ValueError("DATABASE_URL must be a PostgreSQL connection string") + return v + + +class RedisSettings(BaseSettings): + """Redis connection settings.""" + + model_config = SettingsConfigDict(env_prefix="") + + url: str = Field( + default="redis://localhost:6379", + alias="REDIS_URL", + description="Redis connection string", + ) + + @field_validator("url") + @classmethod + def validate_url(cls, v: str) -> str: + """Validate Redis URL format.""" + if not v.startswith("redis://"): + raise ValueError("REDIS_URL must start with redis://") + return v + + +class PolygonSettings(BaseSettings): + """Polygon blockchain RPC settings.""" + + model_config = SettingsConfigDict(env_prefix="POLYGON_") + + rpc_url: str = Field( + default="https://polygon-rpc.com", + alias="POLYGON_RPC_URL", + description="Primary Polygon RPC endpoint", + ) + fallback_rpc_url: str | None = Field( + default=None, + alias="POLYGON_FALLBACK_RPC_URL", + description="Fallback Polygon RPC endpoint", + ) + + @field_validator("rpc_url", "fallback_rpc_url") + @classmethod + def validate_url(cls, v: str | None) -> str | None: + """Validate RPC URL format.""" + if v is None: + return v + if not v.startswith(("http://", "https://")): + raise ValueError("RPC URL must be an HTTP(S) endpoint") + return v + + +class PolymarketSettings(BaseSettings): + """Polymarket API settings.""" + + model_config = SettingsConfigDict(env_prefix="POLYMARKET_") + + ws_url: str = Field( + default="wss://ws-subscriptions-clob.polymarket.com/ws/market", + alias="POLYMARKET_WS_URL", + description="Polymarket WebSocket URL for live data", + ) + api_key: SecretStr | None = Field( + default=None, + alias="POLYMARKET_API_KEY", + description="Optional Polymarket API key", + ) + + @field_validator("ws_url") + @classmethod + def validate_ws_url(cls, v: str) -> str: + """Validate WebSocket URL format.""" + if not v.startswith(("ws://", "wss://")): + raise ValueError("WebSocket URL must start with ws:// or wss://") + return v + + +class DiscordSettings(BaseSettings): + """Discord notification settings.""" + + model_config = SettingsConfigDict(env_prefix="DISCORD_") + + webhook_url: SecretStr | None = Field( + default=None, + alias="DISCORD_WEBHOOK_URL", + description="Discord webhook URL for alerts", + ) + + @property + def enabled(self) -> bool: + """Check if Discord notifications are enabled.""" + return self.webhook_url is not None + + +class TelegramSettings(BaseSettings): + """Telegram notification settings.""" + + model_config = SettingsConfigDict(env_prefix="TELEGRAM_") + + bot_token: SecretStr | None = Field( + default=None, + alias="TELEGRAM_BOT_TOKEN", + description="Telegram bot token", + ) + chat_id: str | None = Field( + default=None, + alias="TELEGRAM_CHAT_ID", + description="Telegram chat ID for alerts", + ) + + @property + def enabled(self) -> bool: + """Check if Telegram notifications are enabled.""" + return self.bot_token is not None and self.chat_id is not None + + +class Settings(BaseSettings): + """Main application settings. + + Loads configuration from environment variables with support for + .env files via python-dotenv. + + Example: + ```python + from polymarket_insider_tracker.config import get_settings + + settings = get_settings() + print(settings.database.url) + print(settings.log_level) + ``` + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Nested configuration groups + database: DatabaseSettings = Field(default_factory=DatabaseSettings) + redis: RedisSettings = Field(default_factory=RedisSettings) + polygon: PolygonSettings = Field(default_factory=PolygonSettings) + polymarket: PolymarketSettings = Field(default_factory=PolymarketSettings) + discord: DiscordSettings = Field(default_factory=DiscordSettings) + telegram: TelegramSettings = Field(default_factory=TelegramSettings) + + # Application settings + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( + default="INFO", + alias="LOG_LEVEL", + description="Logging level", + ) + health_port: int = Field( + default=8080, + alias="HEALTH_PORT", + description="HTTP port for health check endpoints", + ge=1, + le=65535, + ) + dry_run: bool = Field( + default=False, + alias="DRY_RUN", + description="Run without sending actual alerts", + ) + + def get_logging_level(self) -> int: + """Get the numeric logging level.""" + level: int = getattr(logging, self.log_level) + return level + + def redacted_summary(self) -> dict[str, str | dict[str, str]]: + """Get a summary of settings with secrets redacted. + + Returns: + Dictionary of settings with sensitive values masked. + """ + return { + "database_url": self._redact_url(self.database.url), + "redis_url": self._redact_url(self.redis.url), + "polygon": { + "rpc_url": self.polygon.rpc_url, + "fallback_rpc_url": self.polygon.fallback_rpc_url or "(not set)", + }, + "polymarket": { + "ws_url": self.polymarket.ws_url, + "api_key": "(set)" if self.polymarket.api_key else "(not set)", + }, + "discord_enabled": str(self.discord.enabled), + "telegram_enabled": str(self.telegram.enabled), + "log_level": self.log_level, + "health_port": str(self.health_port), + "dry_run": str(self.dry_run), + } + + @staticmethod + def _redact_url(url: str) -> str: + """Redact password from URL if present.""" + if "@" in url and "://" in url: + # URL has credentials - redact the password + protocol_end = url.index("://") + 3 + at_pos = url.index("@") + creds_part = url[protocol_end:at_pos] + if ":" in creds_part: + username = creds_part.split(":")[0] + return f"{url[:protocol_end]}{username}:***@{url[at_pos + 1 :]}" + return url + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Get the application settings singleton. + + Uses LRU cache to ensure settings are loaded only once and + reused across the application. + + Returns: + The Settings instance. + + Raises: + ValidationError: If required environment variables are missing + or have invalid values. + """ + return Settings() + + +def clear_settings_cache() -> None: + """Clear the settings cache. + + Useful for testing when you need to reload settings with + different environment variables. + """ + get_settings.cache_clear() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4e470b5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,329 @@ +"""Tests for configuration management service.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from polymarket_insider_tracker.config import ( + DatabaseSettings, + DiscordSettings, + PolygonSettings, + PolymarketSettings, + RedisSettings, + Settings, + TelegramSettings, + clear_settings_cache, + get_settings, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture(autouse=True) +def clear_cache() -> Iterator[None]: + """Clear settings cache before and after each test.""" + clear_settings_cache() + yield + clear_settings_cache() + + +class TestDatabaseSettings: + """Tests for DatabaseSettings.""" + + def test_valid_postgresql_url(self) -> None: + """Test valid PostgreSQL URL.""" + with patch.dict(os.environ, {"DATABASE_URL": "postgresql://user:pass@localhost/db"}): + settings = DatabaseSettings() + assert settings.url == "postgresql://user:pass@localhost/db" + + def test_valid_asyncpg_url(self) -> None: + """Test valid asyncpg URL.""" + with patch.dict( + os.environ, {"DATABASE_URL": "postgresql+asyncpg://user:pass@localhost/db"} + ): + settings = DatabaseSettings() + assert settings.url == "postgresql+asyncpg://user:pass@localhost/db" + + def test_invalid_url_raises(self) -> None: + """Test that invalid database URL raises validation error.""" + with ( + patch.dict(os.environ, {"DATABASE_URL": "mysql://user:pass@localhost/db"}), + pytest.raises(ValidationError, match="PostgreSQL connection string"), + ): + DatabaseSettings() + + +class TestRedisSettings: + """Tests for RedisSettings.""" + + def test_default_url(self) -> None: + """Test default Redis URL.""" + with patch.dict(os.environ, {}, clear=True): + settings = RedisSettings() + assert settings.url == "redis://localhost:6379" + + def test_custom_url(self) -> None: + """Test custom Redis URL.""" + with patch.dict(os.environ, {"REDIS_URL": "redis://redis:6380"}): + settings = RedisSettings() + assert settings.url == "redis://redis:6380" + + def test_invalid_url_raises(self) -> None: + """Test that invalid Redis URL raises validation error.""" + with ( + patch.dict(os.environ, {"REDIS_URL": "http://localhost:6379"}), + pytest.raises(ValidationError, match="redis://"), + ): + RedisSettings() + + +class TestPolygonSettings: + """Tests for PolygonSettings.""" + + def test_default_rpc_url(self) -> None: + """Test default Polygon RPC URL.""" + with patch.dict(os.environ, {}, clear=True): + settings = PolygonSettings() + assert settings.rpc_url == "https://polygon-rpc.com" + assert settings.fallback_rpc_url is None + + def test_custom_urls(self) -> None: + """Test custom Polygon RPC URLs.""" + with patch.dict( + os.environ, + { + "POLYGON_RPC_URL": "https://alchemy.io/polygon", + "POLYGON_FALLBACK_RPC_URL": "https://backup.polygon.io", + }, + ): + settings = PolygonSettings() + assert settings.rpc_url == "https://alchemy.io/polygon" + assert settings.fallback_rpc_url == "https://backup.polygon.io" + + def test_invalid_url_raises(self) -> None: + """Test that invalid RPC URL raises validation error.""" + with ( + patch.dict(os.environ, {"POLYGON_RPC_URL": "ws://polygon.io"}), + pytest.raises(ValidationError, match="HTTP"), + ): + PolygonSettings() + + +class TestPolymarketSettings: + """Tests for PolymarketSettings.""" + + def test_default_ws_url(self) -> None: + """Test default Polymarket WebSocket URL.""" + with patch.dict(os.environ, {}, clear=True): + settings = PolymarketSettings() + assert "polymarket.com" in settings.ws_url + assert settings.api_key is None + + def test_custom_api_key(self) -> None: + """Test custom API key (secret).""" + with patch.dict(os.environ, {"POLYMARKET_API_KEY": "secret-key-123"}): + settings = PolymarketSettings() + assert settings.api_key is not None + assert settings.api_key.get_secret_value() == "secret-key-123" + + def test_invalid_ws_url_raises(self) -> None: + """Test that invalid WebSocket URL raises validation error.""" + with ( + patch.dict(os.environ, {"POLYMARKET_WS_URL": "http://polymarket.com"}), + pytest.raises(ValidationError, match="ws://"), + ): + PolymarketSettings() + + +class TestDiscordSettings: + """Tests for DiscordSettings.""" + + def test_disabled_by_default(self) -> None: + """Test Discord is disabled when no webhook URL.""" + with patch.dict(os.environ, {}, clear=True): + settings = DiscordSettings() + assert not settings.enabled + assert settings.webhook_url is None + + def test_enabled_with_webhook(self) -> None: + """Test Discord is enabled with webhook URL.""" + with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://discord.com/webhook/123"}): + settings = DiscordSettings() + assert settings.enabled + assert settings.webhook_url is not None + + +class TestTelegramSettings: + """Tests for TelegramSettings.""" + + def test_disabled_by_default(self) -> None: + """Test Telegram is disabled when no credentials.""" + with patch.dict(os.environ, {}, clear=True): + settings = TelegramSettings() + assert not settings.enabled + + def test_disabled_with_partial_config(self) -> None: + """Test Telegram is disabled with only token or chat_id.""" + with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "token123"}): + settings = TelegramSettings() + assert not settings.enabled + + with patch.dict(os.environ, {"TELEGRAM_CHAT_ID": "12345"}): + settings = TelegramSettings() + assert not settings.enabled + + def test_enabled_with_full_config(self) -> None: + """Test Telegram is enabled with both token and chat_id.""" + with patch.dict( + os.environ, + { + "TELEGRAM_BOT_TOKEN": "token123", + "TELEGRAM_CHAT_ID": "12345", + }, + ): + settings = TelegramSettings() + assert settings.enabled + + +class TestSettings: + """Tests for main Settings class.""" + + def test_loads_with_required_vars(self) -> None: + """Test settings load with required environment variables.""" + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "REDIS_URL": "redis://localhost:6379", + }, + ): + settings = Settings() + assert settings.database.url == "postgresql://user:pass@localhost/db" + assert settings.redis.url == "redis://localhost:6379" + + def test_default_log_level(self) -> None: + """Test default log level is INFO.""" + with patch.dict( + os.environ, + {"DATABASE_URL": "postgresql://user:pass@localhost/db"}, + ): + settings = Settings() + assert settings.log_level == "INFO" + + def test_custom_log_level(self) -> None: + """Test custom log level.""" + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "LOG_LEVEL": "DEBUG", + }, + ): + settings = Settings() + assert settings.log_level == "DEBUG" + + def test_invalid_log_level_raises(self) -> None: + """Test invalid log level raises validation error.""" + with ( + patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "LOG_LEVEL": "TRACE", + }, + ), + pytest.raises(ValidationError), + ): + Settings() + + def test_health_port_validation(self) -> None: + """Test health port must be valid port number.""" + with ( + patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "HEALTH_PORT": "99999", + }, + ), + pytest.raises(ValidationError, match="65535"), + ): + Settings() + + def test_get_logging_level(self) -> None: + """Test get_logging_level returns numeric level.""" + import logging + + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "LOG_LEVEL": "WARNING", + }, + ): + settings = Settings() + assert settings.get_logging_level() == logging.WARNING + + def test_redacted_summary(self) -> None: + """Test redacted_summary masks sensitive data.""" + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:secretpass@localhost/db", + "REDIS_URL": "redis://localhost:6379", + }, + ): + settings = Settings() + summary = settings.redacted_summary() + + # Database password should be redacted + db_url = summary["database_url"] + assert isinstance(db_url, str) + assert "secretpass" not in db_url + assert "***" in db_url + assert "user" in db_url + + +class TestGetSettings: + """Tests for get_settings singleton.""" + + def test_returns_same_instance(self) -> None: + """Test get_settings returns cached instance.""" + with patch.dict( + os.environ, + {"DATABASE_URL": "postgresql://user:pass@localhost/db"}, + ): + settings1 = get_settings() + settings2 = get_settings() + assert settings1 is settings2 + + def test_clear_cache_allows_reload(self) -> None: + """Test clear_settings_cache allows reloading settings.""" + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "LOG_LEVEL": "INFO", + }, + ): + settings1 = get_settings() + assert settings1.log_level == "INFO" + + clear_settings_cache() + + with patch.dict( + os.environ, + { + "DATABASE_URL": "postgresql://user:pass@localhost/db", + "LOG_LEVEL": "DEBUG", + }, + ): + settings2 = get_settings() + assert settings2.log_level == "DEBUG" + assert settings1 is not settings2 diff --git a/uv.lock b/uv.lock index 1d596e4..6313558 100644 --- a/uv.lock +++ b/uv.lock @@ -1547,6 +1547,7 @@ dependencies = [ { name = "prometheus-client" }, { name = "py-clob-client" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "redis" }, { name = "scikit-learn" }, @@ -1578,6 +1579,7 @@ requires-dist = [ { name = "prometheus-client", specifier = ">=0.19.0" }, { name = "py-clob-client", specifier = ">=0.1.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -1903,6 +1905,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2"