diff --git a/src/cerbero_mcp/settings.py b/src/cerbero_mcp/settings.py new file mode 100644 index 0000000..2999695 --- /dev/null +++ b/src/cerbero_mcp/settings.py @@ -0,0 +1,107 @@ +"""Pydantic Settings: legge .env e variabili d'ambiente.""" +from __future__ import annotations + +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class _Sub(BaseSettings): + """Base per sub-settings, condivide model_config con env_file.""" + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +class DeribitSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="DERIBIT_", + extra="ignore", + ) + client_id: str + client_secret: SecretStr + url_live: str + url_testnet: str + max_leverage: int = 3 + + +class BybitSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="BYBIT_", + extra="ignore", + ) + api_key: str + api_secret: SecretStr + url_live: str + url_testnet: str + max_leverage: int = 3 + + +class HyperliquidSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="HYPERLIQUID_", + extra="ignore", + ) + wallet_address: str + api_wallet_address: str + private_key: SecretStr + url_live: str + url_testnet: str + max_leverage: int = 3 + + +class AlpacaSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="ALPACA_", + extra="ignore", + ) + api_key_id: str + secret_key: SecretStr + url_live: str + url_testnet: str + max_leverage: int = 1 + + +class MacroSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + fred_api_key: SecretStr + finnhub_api_key: SecretStr + + +class SentimentSettings(_Sub): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + cryptopanic_key: SecretStr + lunarcrush_key: SecretStr + + +class Settings(_Sub): + host: str = "0.0.0.0" + port: int = 9000 + log_level: str = "info" + + testnet_token: SecretStr + mainnet_token: SecretStr + + deribit: DeribitSettings = Field(default_factory=DeribitSettings) + bybit: BybitSettings = Field(default_factory=BybitSettings) + hyperliquid: HyperliquidSettings = Field(default_factory=HyperliquidSettings) + alpaca: AlpacaSettings = Field(default_factory=AlpacaSettings) + macro: MacroSettings = Field(default_factory=MacroSettings) + sentiment: SentimentSettings = Field(default_factory=SentimentSettings) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..2ee49c9 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + + +def _minimal_env(**overrides) -> dict: + base = { + "TESTNET_TOKEN": "t_test_123", + "MAINNET_TOKEN": "t_live_456", + "DERIBIT_CLIENT_ID": "id", + "DERIBIT_CLIENT_SECRET": "secret", + "DERIBIT_URL_LIVE": "https://www.deribit.com/api/v2", + "DERIBIT_URL_TESTNET": "https://test.deribit.com/api/v2", + "BYBIT_API_KEY": "k", + "BYBIT_API_SECRET": "s", + "BYBIT_URL_LIVE": "https://api.bybit.com", + "BYBIT_URL_TESTNET": "https://api-testnet.bybit.com", + "HYPERLIQUID_WALLET_ADDRESS": "0xabc", + "HYPERLIQUID_API_WALLET_ADDRESS": "0xdef", + "HYPERLIQUID_PRIVATE_KEY": "0x123", + "HYPERLIQUID_URL_LIVE": "https://api.hyperliquid.xyz", + "HYPERLIQUID_URL_TESTNET": "https://api.hyperliquid-testnet.xyz", + "ALPACA_API_KEY_ID": "k", + "ALPACA_SECRET_KEY": "s", + "ALPACA_URL_LIVE": "https://api.alpaca.markets", + "ALPACA_URL_TESTNET": "https://paper-api.alpaca.markets", + "FRED_API_KEY": "x", + "FINNHUB_API_KEY": "y", + "CRYPTOPANIC_KEY": "z", + "LUNARCRUSH_KEY": "w", + } + base.update(overrides) + return base + + +def test_settings_load_minimal(monkeypatch): + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + monkeypatch.setenv("PORT", "9123") + + from cerbero_mcp.settings import Settings + + s = Settings() + assert s.port == 9123 + assert s.host == "0.0.0.0" + assert s.testnet_token.get_secret_value() == "t_test_123" + assert s.mainnet_token.get_secret_value() == "t_live_456" + assert s.deribit.url_testnet.endswith("test.deribit.com/api/v2") + assert s.bybit.max_leverage == 3 + assert s.alpaca.max_leverage == 1 + + +def test_settings_missing_token_fails(monkeypatch): + env = _minimal_env() + env.pop("TESTNET_TOKEN") + for k, v in env.items(): + monkeypatch.setenv(k, v) + monkeypatch.delenv("TESTNET_TOKEN", raising=False) + + from cerbero_mcp.settings import Settings + + with pytest.raises(ValidationError): + Settings() + + +def test_settings_extras_ignored(monkeypatch): + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + monkeypatch.setenv("UNRELATED_VAR", "ignored") + + from cerbero_mcp.settings import Settings + + s = Settings() + assert s.testnet_token.get_secret_value() == "t_test_123" + + +def test_settings_secret_str_no_leak(monkeypatch): + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.settings import Settings + + s = Settings() + assert "t_test_123" not in repr(s) + assert "t_live_456" not in repr(s)