from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] class ConfigurationError(ValueError): pass def _resolve_path(raw_value: str, default: Path) -> Path: candidate = Path(raw_value).expanduser() if raw_value.strip() else default if not candidate.is_absolute(): candidate = PROJECT_ROOT / candidate return candidate.resolve() def _parse_port(raw_value: str) -> int: try: port = int(raw_value) except ValueError as exc: raise ConfigurationError("APP_PORT must be an integer") from exc if not 1 <= port <= 65535: raise ConfigurationError("APP_PORT must be between 1 and 65535") return port @dataclass(frozen=True, slots=True) class Settings: environment: str debug: bool data_directory: Path database_path: Path mentor_skills_directory: Path private_mentor_skills_directory: Path log_file: Path | None log_level: str host: str port: int encryption_key: str | None frontend_dist_directory: Path = PROJECT_ROOT / "frontend" / "dist" timezone: str = "Asia/Shanghai" @classmethod def from_environment(cls) -> Settings: environment = os.getenv("APP_ENV", "development").strip().lower() if environment not in {"development", "test", "production"}: raise ConfigurationError("APP_ENV must be development, test, or production") timezone = os.getenv("APP_TIMEZONE", "Asia/Shanghai").strip() if timezone != "Asia/Shanghai": raise ConfigurationError("APP_TIMEZONE must be Asia/Shanghai") data_directory = _resolve_path(os.getenv("APP_DATA_DIR", ""), PROJECT_ROOT / "data") database_path = _resolve_path( os.getenv("APP_DATABASE_PATH", ""), data_directory / "xiaobai.db" ) mentor_skills_directory = _resolve_path( os.getenv("APP_MENTOR_SKILLS_DIR", ""), PROJECT_ROOT / "config" / "mentor-skills" ) private_mentor_skills_directory = _resolve_path( os.getenv("APP_PRIVATE_MENTOR_SKILLS_DIR", ""), data_directory / "private-mentor-skills", ) frontend_dist_directory = _resolve_path( os.getenv("APP_FRONTEND_DIST", ""), PROJECT_ROOT / "frontend" / "dist" ) log_file = _resolve_path( os.getenv("APP_LOG_FILE", ""), data_directory / "logs" / "application.log", ) log_level = os.getenv("APP_LOG_LEVEL", "INFO").strip().upper() if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}: raise ConfigurationError("APP_LOG_LEVEL is invalid") encryption_key = os.getenv("APP_ENCRYPTION_KEY", "").strip() or None if environment == "production" and encryption_key is None: raise ConfigurationError("APP_ENCRYPTION_KEY is required in production") return cls( environment=environment, debug=environment == "development", data_directory=data_directory, database_path=database_path, mentor_skills_directory=mentor_skills_directory, private_mentor_skills_directory=private_mentor_skills_directory, frontend_dist_directory=frontend_dist_directory, log_file=log_file, log_level=log_level, host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1", port=_parse_port(os.getenv("APP_PORT", "8780")), encryption_key=encryption_key, timezone=timezone, ) @classmethod def for_test(cls, data_directory: Path) -> Settings: root = data_directory.resolve() return cls( environment="test", debug=False, data_directory=root, database_path=root / "xiaobai-test.db", mentor_skills_directory=PROJECT_ROOT / "config" / "mentor-skills", private_mentor_skills_directory=root / "private-mentor-skills", frontend_dist_directory=root / "frontend-dist", log_file=None, log_level="CRITICAL", host="127.0.0.1", port=8780, encryption_key=None, )