"""Environment-sourced configuration classes (12-factor factor 3). Every value comes from os.environ. No secret, DB URL, or filesystem path is ever hardcoded; .env.example documents every variable a deployment must set. """ from __future__ import annotations import os from datetime import timedelta def _bool_env(name: str, default: bool = False) -> bool: """Parse a boolean-ish environment variable.""" val = os.environ.get(name) return default if val is None else val.strip().lower() in {"1", "true", "yes", "on"} def _engine_options_for(database_url: str) -> dict: """pool_size/pool_recycle are QueuePool-only kwargs. BUG FIX (caught by actually booting the app): SQLite's default pool classes (NullPool for file DBs, StaticPool for :memory:) raise TypeError if handed pool_size/pool_recycle at all — they're not silently ignored. Chapter 06 makes SQLite the default engine and MySQL the opt-in fallback, so these kwargs are only meaningful (and only passed) when DATABASE_URL actually points at a non-SQLite engine. """ if database_url.startswith("sqlite"): return {} return { "pool_size": int(os.environ.get("DB_POOL_SIZE", "3")), "pool_recycle": int(os.environ.get("DB_POOL_RECYCLE_SECONDS", "280")), "pool_pre_ping": True, } class BaseConfig: """Shared config. Subclasses override only what differs per environment.""" SECRET_KEY: str | None = os.environ.get("SECRET_KEY") # Chapter 06: SQLite/WAL default; swappable via DATABASE_URL without code changes. SQLALCHEMY_DATABASE_URI: str = os.environ.get("DATABASE_URL", "sqlite:///kavosh.db") SQLALCHEMY_ENGINE_OPTIONS: dict = _engine_options_for(SQLALCHEMY_DATABASE_URI) SQLALCHEMY_TRACK_MODIFICATIONS = False # Chapter 03: 150 max DB connections shared across up to 60 entry # processes -> keep each process's pool small. Exposed as its own # config key (not just buried inside SQLALCHEMY_ENGINE_OPTIONS) so # budget.py can validate the *intended* setting regardless of whether # the active engine (SQLite) actually consumes it. DB_POOL_SIZE: int = int(os.environ.get("DB_POOL_SIZE", "3")) # Chapter 03: filesystem cache, not Redis. CACHE_TYPE = os.environ.get("CACHE_TYPE", "FileSystemCache") CACHE_DIR = os.environ.get("CACHE_DIR", "/tmp/kavosh-cache") CACHE_DEFAULT_TIMEOUT = int(os.environ.get("CACHE_DEFAULT_TIMEOUT", "60")) # Chapter 12: secure session cookie flags. SESSION_COOKIE_SECURE = _bool_env("SESSION_COOKIE_SECURE", True) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" PERMANENT_SESSION_LIFETIME = timedelta( hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "12")) ) WTF_CSRF_ENABLED = True # Chapter 04/12: upload constraints. UPLOAD_MAX_SIZE_MB = int(os.environ.get("UPLOAD_MAX_SIZE_MB", "500")) MAX_CONTENT_LENGTH = UPLOAD_MAX_SIZE_MB * 1024 * 1024 UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/kavosh/uploads") PARSE_BATCH_SIZE = int(os.environ.get("PARSE_BATCH_SIZE", "5000")) ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") class DevConfig(BaseConfig): DEBUG = True SESSION_COOKIE_SECURE = False # allow plain-http local dev class ProdConfig(BaseConfig): DEBUG = False class TestConfig(BaseConfig): TESTING = True DEBUG = True # lets asset() tolerate a missing Vite manifest during tests SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" SQLALCHEMY_ENGINE_OPTIONS = {} # always in-memory SQLite regardless of DATABASE_URL WTF_CSRF_ENABLED = False _CONFIGS = {"development": DevConfig, "production": ProdConfig, "testing": TestConfig} def get_config(config_name: str | None = None): """Resolve a config class from FLASK_ENV or an explicit name. Defaults to `production` if unset, so an unconfigured deployment never silently runs with DEBUG on. """ name = config_name or os.environ.get("FLASK_ENV", "production") try: return _CONFIGS[name] except KeyError as exc: raise ValueError(f"Unknown config_name {name!r}; expected one of {list(_CONFIGS)}") from exc