from __future__ import annotations import math import os from collections.abc import Mapping from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import urlsplit if TYPE_CHECKING: from src.core.config import Settings RUN_LIVE_TESTS_ENV = "DZENTRA_RUN_LIVE_TESTS" LIVE_REST_URL_ENV = "DZENTRA_LIVE_REST_URL" LIVE_WEBSOCKET_URL_ENV = "DZENTRA_LIVE_WS_URL" LIVE_SYMBOLS_ENV = "DZENTRA_LIVE_SYMBOLS" LIVE_TRADE_TIMEOUT_ENV = "DZENTRA_LIVE_TRADE_TIMEOUT_SECONDS" DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS = 600.0 LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS = 30.0 class LiveTestConfigurationError(ValueError): """Ошибка явной конфигурации opt-in live verification.""" @dataclass(frozen=True, slots=True) class LiveTradeStreamTestConfig: rest_url: str websocket_url: str symbol: str trade_timeout_seconds: float @property def scenario_timeout_seconds(self) -> float: return ( self.trade_timeout_seconds * 2 + LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS * 2 ) def load_live_trade_stream_test_config( environment: Mapping[str, str] | None = None, ) -> LiveTradeStreamTestConfig | None: values = environment if environment is not None else os.environ opt_in = values.get(RUN_LIVE_TESTS_ENV, "").strip() if opt_in in {"", "0"}: return None if opt_in != "1": raise LiveTestConfigurationError( f"{RUN_LIVE_TESTS_ENV} must be exactly 1 when enabled." ) rest_url = _require_environment_value( values, LIVE_REST_URL_ENV, ).rstrip("/") websocket_url = _require_environment_value( values, LIVE_WEBSOCKET_URL_ENV, ) symbols_value = _require_environment_value( values, LIVE_SYMBOLS_ENV, ) symbols = tuple( symbol.strip() for symbol in symbols_value.split(",") ) if any(not symbol for symbol in symbols): raise LiveTestConfigurationError( f"{LIVE_SYMBOLS_ENV} must not contain empty symbols." ) if len(symbols) != 1: raise LiveTestConfigurationError( f"{LIVE_SYMBOLS_ENV} must contain exactly one symbol " "for bounded live verification." ) _validate_secure_url( rest_url, name=LIVE_REST_URL_ENV, expected_scheme="https", required_path=None, ) _validate_secure_url( websocket_url, name=LIVE_WEBSOCKET_URL_ENV, expected_scheme="wss", required_path="/connect", ) timeout_seconds = _parse_positive_timeout( values.get( LIVE_TRADE_TIMEOUT_ENV, str(DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS), ), ) return LiveTradeStreamTestConfig( rest_url=rest_url, websocket_url=websocket_url, symbol=symbols[0], trade_timeout_seconds=timeout_seconds, ) def build_live_trade_stream_settings( config: LiveTradeStreamTestConfig, ) -> Settings: from src.core.config import Settings, TradeStreamSettings return Settings( bot_token="live-verification-does-not-use-telegram", bot_parse_mode="HTML", app_env="live-verification", log_level="INFO", tz="UTC", exchange_enabled=True, exchange_name="dzengi", exchange_base_url=config.rest_url, exchange_ws_url="", exchange_api_key="", exchange_api_secret="", exchange_timeout_sec=20, exchange_testnet=False, default_symbol=config.symbol, trade_stream=TradeStreamSettings( enabled=True, websocket_url=config.websocket_url, symbols=(config.symbol,), open_timeout_seconds=10.0, probe_timeout_seconds=20.0, close_timeout_seconds=10.0, heartbeat_timeout_seconds=20.0, scheduler_interval_seconds=5.0, recovery_window_ms=3_599_999, ), db_host="localhost", db_port=5432, db_name="live-verification", db_user="live-verification", db_password="", debug_enabled=False, journal_debug_enabled=False, ) def _require_environment_value( environment: Mapping[str, str], name: str, ) -> str: value = environment.get(name, "").strip() if not value: raise LiveTestConfigurationError( f"{name} is required when {RUN_LIVE_TESTS_ENV}=1." ) return value def _validate_secure_url( raw_url: str, *, name: str, expected_scheme: str, required_path: str | None, ) -> None: parsed = urlsplit(raw_url) if parsed.scheme.lower() != expected_scheme or not parsed.netloc: raise LiveTestConfigurationError( f"{name} must be an absolute {expected_scheme} URL." ) if parsed.username is not None or parsed.password is not None: raise LiveTestConfigurationError( f"{name} must not contain embedded credentials." ) if parsed.query or parsed.fragment: raise LiveTestConfigurationError( f"{name} must not contain a query or fragment." ) if required_path is None: if parsed.path not in {"", "/"}: raise LiveTestConfigurationError( f"{name} must be a base URL without an endpoint path." ) return if parsed.path.rstrip("/") != required_path: raise LiveTestConfigurationError( f"{name} must end with {required_path}." ) def _parse_positive_timeout(raw_value: str) -> float: try: timeout_seconds = float(raw_value.strip()) except ValueError as error: raise LiveTestConfigurationError( f"{LIVE_TRADE_TIMEOUT_ENV} must be a number." ) from error if ( not math.isfinite(timeout_seconds) or timeout_seconds <= 0 ): raise LiveTestConfigurationError( f"{LIVE_TRADE_TIMEOUT_ENV} must be positive and finite." ) return timeout_seconds