"""Конфигурация генератора из переменных окружения.""" import os from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError def _parse_model_timestamp(value: str) -> datetime: """Разбирает ISO-метку модельного времени и нормализует её к UTC.""" normalized = value.replace("Z", "+00:00") timestamp = datetime.fromisoformat(normalized) if timestamp.tzinfo is None: raise ValueError("GEN_MODEL_T0 must include timezone") return timestamp.astimezone(timezone.utc) @dataclass(frozen=True) class Config: """Конфигурация генератора из переменных окружения.""" kafka_bootstrap_servers: str = field( default_factory=lambda: os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:29092") ) tick_seconds: int = field( default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5")) ) lambda_base_per_min: int = field( default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "30")) ) jitter_pct: int = field( default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20")) ) min_events_per_tick: int = field( default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "1")) ) max_events_per_tick: int = field( default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50")) ) max_session_events: int = field( default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30")) ) max_active_sessions: int = field( default_factory=lambda: int(os.getenv("GEN_MAX_ACTIVE_SESSIONS", "200")) ) population_max: int = field( default_factory=lambda: int(os.getenv("GEN_POPULATION_MAX", "300")) ) p_new_user: float = field( default_factory=lambda: float(os.getenv("GEN_P_NEW_USER", "0.15")) ) min_return_minutes: int = field( default_factory=lambda: int(os.getenv("GEN_MIN_RETURN_MINUTES", "30")) ) data_dir: Path = field( default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data")) ) seed: int | None = field( default_factory=lambda: int(os.getenv("GEN_SEED")) if os.getenv("GEN_SEED") else None ) enabled: bool = field( default_factory=lambda: os.getenv("GEN_ENABLED", "true").lower() == "true" ) metrics_port: int = field( default_factory=lambda: int(os.getenv("GEN_METRICS_PORT", "9109")) ) metrics_enabled: bool = field( default_factory=lambda: os.getenv("GEN_METRICS_ENABLED", "true").lower() == "true" ) state_enabled: bool = field( default_factory=lambda: os.getenv("GEN_STATE_ENABLED", "true").lower() == "true" ) state_reset: bool = field( default_factory=lambda: os.getenv("GEN_STATE_RESET", "false").lower() == "true" ) model_t0: datetime = field( default_factory=lambda: _parse_model_timestamp( os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00") ) ) model_t_end: datetime | None = field( default_factory=lambda: _parse_optional_model_timestamp( os.getenv("GEN_MODEL_T_END") ) ) model_timezone: str = field( default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC") ) model_time_speed: float = field( default_factory=lambda: float(os.getenv("GEN_MODEL_TIME_SPEED", "1")) ) run_mode: str = field( default_factory=lambda: os.getenv("GEN_RUN_MODE", "live") ) startup_history_artifact: Path | None = field( default_factory=lambda: Path(os.getenv("GEN_STARTUP_HISTORY_ARTIFACT")) if os.getenv("GEN_STARTUP_HISTORY_ARTIFACT") else None ) launch_profile: str = field( default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "ci") ) def __post_init__(self): if self.tick_seconds < 1: raise ValueError("GEN_TICK_SECONDS must be >= 1") if self.lambda_base_per_min < 1: raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1") if self.max_session_events < 1: raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1") if self.max_active_sessions < 1: raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be >= 1") if self.population_max < 1: raise ValueError("GEN_POPULATION_MAX must be >= 1") if not 0 <= self.p_new_user <= 1: raise ValueError("GEN_P_NEW_USER must be between 0 and 1") if self.min_return_minutes < 0: raise ValueError("GEN_MIN_RETURN_MINUTES must be >= 0") if self.max_active_sessions >= self.population_max: raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be < GEN_POPULATION_MAX") if not self.data_dir.exists(): raise ValueError(f"Data directory does not exist: {self.data_dir}") if self.model_t0.tzinfo is None: raise ValueError("GEN_MODEL_T0 must include timezone") object.__setattr__( self, "model_t0", self.model_t0.astimezone(timezone.utc), ) try: ZoneInfo(self.model_timezone) except ZoneInfoNotFoundError as e: raise ValueError(f"Unknown GEN_MODEL_TIMEZONE: {self.model_timezone}") from e if self.model_time_speed <= 0: raise ValueError("GEN_MODEL_TIME_SPEED must be > 0") if self.run_mode not in {"live", "backfill", "next-day"}: raise ValueError("GEN_RUN_MODE должен быть live, backfill или next-day") if self.model_t_end is not None: object.__setattr__( self, "model_t_end", self.model_t_end.astimezone(timezone.utc), ) if self.run_mode == "backfill": if self.model_t_end is None: raise ValueError("GEN_MODEL_T_END is required for backfill") if self.model_t_end <= self.model_t0: raise ValueError("GEN_MODEL_T_END must be after GEN_MODEL_T0") if self.run_mode == "next-day": if self.model_t_end is None: raise ValueError("для next-day требуется GEN_MODEL_T_END") if self.model_t_end <= self.model_t0: raise ValueError("GEN_MODEL_T_END должен быть позже GEN_MODEL_T0") def _parse_optional_model_timestamp(value: str | None) -> datetime | None: """Разбирает необязательную ISO-метку модельного времени.""" if not value: return None return _parse_model_timestamp(value)