"""Application settings with composition pattern. This module defines the application configuration using pydantic-settings. Provides typed configuration for database, Keycloak, security, and app settings. """ from enum import Enum from functools import cached_property from pydantic import Field, PostgresDsn, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Environment(str, Enum): """Application environment modes. Defines the available deployment environments. Each environment may have different configuration defaults. Attributes: DEV: Development environment with debug features. PROD: Production environment with strict security. Example: >>> if settings.environment == Environment.PROD: ... enable_strict_security() """ DEV = "dev" PROD = "prod" class AppConfig(BaseSettings): """Application configuration. Contains general application settings like name, host, and port. Attributes: name: Application display name. debug: Debug mode flag. host: Server bind host. port: Server bind port. Example: >>> config = AppConfig(name="My API", port=8000) """ name: str = "Blog API" debug: bool = False host: str = "0.0.0.0" port: int = 8000 model_config = SettingsConfigDict( env_prefix="APP_", env_file_encoding="utf-8", case_sensitive=False, ) class DBConfig(BaseSettings): """Database configuration. Contains database connection settings. Supports both SQLite for development and PostgreSQL for production. Attributes: url: Full database URL (optional, can build from components). echo: Enable SQL query logging. host: Database server host. port: Database server port. user: Database username. password: Database password. name: Database name. Example: >>> db_config = DBConfig(host="localhost", name="blog") """ url: str | None = None echo: bool = False host: str = "localhost" port: int = 5432 user: str = "postgres" password: str = "postgres" name: str = "blog" model_config = SettingsConfigDict( env_prefix="DB_", env_file_encoding="utf-8", case_sensitive=False, ) @field_validator("url") @classmethod def validate_url(cls, v: str | None) -> str | None: """Validate database URL if provided. Args: v: Database URL string to validate. Returns: Validated URL string. Raises: ValueError: If URL does not start with supported prefix. """ if v is None: return v if not any(v.startswith(prefix) for prefix in ("sqlite+", "postgresql+")): raise ValueError("Database URL must start with 'sqlite+' or 'postgresql+'") return v class KCConfig(BaseSettings): """Keycloak configuration. Contains Keycloak authentication server settings. Attributes: server_url: Keycloak server base URL. realm: Keycloak realm name. client_id: OAuth client identifier. client_secret: OAuth client secret. token_cache_ttl: Token cache time-to-live in seconds. Example: >>> kc = KCConfig(server_url="http://localhost:8080", realm="blog") """ server_url: str = "http://localhost:8080" realm: str = "blog" client_id: str = "blog-api" client_secret: str = Field( default="", description="Keycloak client secret - must be set via env in production", ) token_cache_ttl: int = 60 model_config = SettingsConfigDict( env_prefix="KC_", env_file_encoding="utf-8", case_sensitive=False, ) @property def is_configured(self) -> bool: """Check if Keycloak is properly configured. Returns: True if client_secret is set. """ return bool(self.client_secret) class SecurityConfig(BaseSettings): """Security configuration. Contains security-related settings for JWT and authentication. Attributes: secret_key: Secret key for JWT signing. access_token_expire_minutes: Token expiration time in minutes. Example: >>> security = SecurityConfig(secret_key="super-secret-key") """ secret_key: str = Field( default="", description="Secret key for JWT - must be set via env in production" ) access_token_expire_minutes: int = 30 model_config = SettingsConfigDict( env_prefix="SECURITY_", env_file_encoding="utf-8", case_sensitive=False, ) @property def is_configured(self) -> bool: """Check if security is properly configured. Returns: True if secret_key is set. """ return bool(self.secret_key) class Settings(BaseSettings): """Application configuration settings with composition. Main settings class that composes all sub-configurations. Validates production settings and provides computed properties. Attributes: environment: Current deployment environment. app: Application configuration. db: Database configuration. kc: Keycloak configuration. security: Security configuration. Raises: ValueError: If required production settings are missing. Example: >>> settings = Settings() >>> print(settings.database_url) """ environment: Environment = Environment.DEV app: AppConfig = Field(default_factory=AppConfig) db: DBConfig = Field(default_factory=DBConfig) kc: KCConfig = Field(default_factory=KCConfig) security: SecurityConfig = Field(default_factory=SecurityConfig) model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, env_nested_delimiter="__", ) def model_post_init(self, __context: object) -> None: """Validate settings after initialization. Checks that required settings are configured for production mode. Raises: ValueError: If required production settings are missing. """ if self.is_prod: if not self.security.is_configured: raise ValueError("SECURITY_SECRET_KEY must be set in production mode") if not self.kc.is_configured: raise ValueError("KC_CLIENT_SECRET must be set in production mode") @cached_property def database_url(self) -> str: """Get database URL based on environment. Returns configured URL or builds one from components. Uses SQLite for development, PostgreSQL for production. Returns: Complete database URL string. """ if self.db.url: return self.db.url if self.environment == Environment.PROD: return str( PostgresDsn.build( scheme="postgresql+asyncpg", username=self.db.user, password=self.db.password, host=self.db.host, port=self.db.port, path=self.db.name, ) ) return "sqlite+aiosqlite:///./blog.db" @property def is_dev(self) -> bool: """Check if running in development mode. Returns: True if environment is DEV. """ return self.environment == Environment.DEV @property def is_prod(self) -> bool: """Check if running in production mode. Returns: True if environment is PROD. """ return self.environment == Environment.PROD settings = Settings()