Source code for platzky.config

"""Configuration module for Platzky application.

This module defines all configuration models and parsing logic for the application.
"""

import sys
import typing as t

import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.config import ExtraValues

from platzky.attachment.constants import BLOCKED_EXTENSIONS, DEFAULT_MAX_ATTACHMENT_SIZE
from platzky.db.db import DBConfig
from platzky.db.db_loader import get_db_module
from platzky.feature_flags_wrapper import FeatureFlagSet


[docs] class LanguageConfig(BaseModel): """Configuration for a single language. Attributes: name: Display name of the language flag: Flag icon code (country code) country: Country code domain: Optional domain specific to this language """ model_config = ConfigDict(frozen=True) name: str flag: str country: str domain: t.Optional[str] = None
Languages = dict[str, LanguageConfig] LanguagesMapping = t.Mapping[str, t.Mapping[str, str]] # Validation error messages _INVALID_ENDPOINT_FORMAT_MSG = ( "Invalid endpoint: '{}'. Must be host:port or [http|https]://host[:port]" ) _INVALID_ENDPOINT_SCHEME_MSG = "Invalid endpoint scheme: '{}'. Must be http or https" _MISSING_HOSTNAME_MSG = "Invalid endpoint: '{}'. Missing hostname"
[docs] def languages_dict(languages: Languages) -> LanguagesMapping: """Convert Languages configuration to a mapping dictionary. Excludes None values to align with type signature. Args: languages: Dictionary of language configurations Returns: Mapping of language codes to their configuration dictionaries (excludes None values) """ return { name: {k: v for k, v in lang.model_dump().items() if v is not None} for name, lang in languages.items() }
[docs] class TelemetryConfig(BaseModel): """OpenTelemetry configuration for application tracing. Attributes: enabled: Enable or disable telemetry tracing endpoint: OTLP gRPC endpoint (e.g., localhost:4317 or http://localhost:4317) console_export: Export traces to console for debugging timeout: Timeout in seconds for exporter (default: 10) deployment_environment: Deployment environment (e.g., production, staging, dev) service_instance_id: Service instance ID (auto-generated if not provided) flush_on_request: Flush spans after each request (default: True, may impact latency) flush_timeout_ms: Timeout in milliseconds for per-request flush (default: 5000) instrument_logging: Enable automatic logging instrumentation (default: True) """ model_config = ConfigDict(frozen=True) enabled: bool = False endpoint: t.Optional[str] = None console_export: bool = False timeout: int = Field(default=10, gt=0) deployment_environment: t.Optional[str] = None service_instance_id: t.Optional[str] = None flush_on_request: bool = True flush_timeout_ms: int = Field(default=5000, gt=0) instrument_logging: bool = True
[docs] @field_validator("endpoint") @classmethod def validate_endpoint(cls, v: t.Optional[str]) -> t.Optional[str]: """Validate endpoint URL format. Accepts OTLP/gRPC spec-compliant formats: - host:port (e.g., localhost:4317) - http://host[:port] - https://host[:port] Note: grpc:// scheme is NOT supported per OTLP spec and will be rejected. """ if v is None: return v from urllib.parse import urlparse # Check if it has a scheme (contains ://) if "://" not in v: # Must be host:port format - validate it has a colon if ":" in v and not v.startswith("/"): return v raise ValueError(_INVALID_ENDPOINT_FORMAT_MSG.format(v)) # Parse URL with scheme parsed = urlparse(v) # Validate scheme (only http/https per OTLP spec, grpc is NOT supported) if parsed.scheme not in ("http", "https"): raise ValueError(_INVALID_ENDPOINT_SCHEME_MSG.format(parsed.scheme)) # Validate hostname exists if not parsed.hostname: raise ValueError(_MISSING_HOSTNAME_MSG.format(v)) return v
_DEFAULT_ALLOWED_MIME_TYPES: frozenset[str] = frozenset( { # Image types (binary formats with verifiable magic bytes) "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/tiff", # Application types (binary formats) "application/pdf", # Archive types - validated but NEVER auto-extracted (zip bomb protection) "application/zip", "application/gzip", "application/x-tar", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", # Audio types "audio/mpeg", "audio/wav", "audio/ogg", # Video types "video/mp4", "video/webm", "video/ogg", # Note: Text types (text/*, application/json, application/xml, application/rtf, # image/svg+xml) are NOT included by default for security reasons. They can # bypass content validation and may contain executable code. To allow text # types, explicitly add them: # AttachmentConfig(allowed_mime_types=_DEFAULT_ALLOWED_MIME_TYPES | {"text/plain"}) } )
[docs] class AttachmentConfig(BaseModel): """Configuration for attachment handling. Attributes: allowed_mime_types: MIME types allowed for attachments. validate_content: Whether to validate content matches declared MIME type. allow_unrecognized_content: If True, allow content that cannot be identified. max_size: Maximum attachment size in bytes (default: 10MB). blocked_extensions: File extensions that are PERMANENTLY blocked (executable and script formats). These cannot be overridden via allowed_extensions. allowed_extensions: File extensions to allow. Defaults to common safe formats (images, documents, archives, audio/video). Set to None to block all. Note: blocked_extensions takes precedence over allowed_extensions. Files without extensions are always blocked when allowed_extensions is set. """ model_config = ConfigDict(frozen=True) allowed_mime_types: frozenset[str] = Field(default=_DEFAULT_ALLOWED_MIME_TYPES) validate_content: bool = Field(default=True) allow_unrecognized_content: bool = Field(default=False) max_size: int = Field(default=DEFAULT_MAX_ATTACHMENT_SIZE, gt=0) blocked_extensions: frozenset[str] = Field(default=BLOCKED_EXTENSIONS) allowed_extensions: frozenset[str] | None = Field( default=frozenset( { # Images "png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", # Documents "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", # Archives "zip", "gz", "tar", # Audio/Video "mp3", "wav", "ogg", "mp4", "webm", } ) )
[docs] class Config(BaseModel): """Main application configuration. Attributes: app_name: Application name secret_key: Flask secret key for sessions db: Database configuration use_www: Redirect non-www to www URLs seo_prefix: URL prefix for SEO routes blog_prefix: URL prefix for blog routes languages: Supported languages configuration translation_directories: Additional translation directories debug: Enable debug mode testing: Enable testing mode feature_flags: Feature flag configuration telemetry: OpenTelemetry configuration attachment: Attachment handling configuration """ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) app_name: str = Field(alias="APP_NAME") secret_key: str = Field(alias="SECRET_KEY") db: DBConfig = Field(alias="DB") use_www: bool = Field(default=True, alias="USE_WWW") seo_prefix: str = Field(default="/", alias="SEO_PREFIX") blog_prefix: str = Field(default="/", alias="BLOG_PREFIX") languages: Languages = Field(default_factory=dict, alias="LANGUAGES") translation_directories: list[str] = Field( default_factory=list, alias="TRANSLATION_DIRECTORIES", ) debug: bool = Field(default=False, alias="DEBUG") testing: bool = Field(default=False, alias="TESTING") feature_flags: FeatureFlagSet = Field( default_factory=lambda: FeatureFlagSet({}), alias="FEATURE_FLAGS", ) telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig, alias="TELEMETRY") attachment: AttachmentConfig = Field(default_factory=AttachmentConfig, alias="ATTACHMENT")
[docs] @field_validator("feature_flags", mode="before") @classmethod def validate_feature_flags(cls, v: FeatureFlagSet | dict[str, bool] | None) -> FeatureFlagSet: """Coerce dict or None into a FeatureFlagSet.""" if isinstance(v, FeatureFlagSet): return v if isinstance(v, dict): return FeatureFlagSet(v) if v is None: return FeatureFlagSet({}) return v
[docs] @classmethod def model_validate( cls, obj: dict[str, t.Any], *, strict: bool | None = None, from_attributes: bool | None = None, context: dict[str, t.Any] | None = None, by_alias: bool | None = None, by_name: bool | None = None, extra: ExtraValues | None = None, ) -> "Config": """Validate and construct Config from dictionary. Parses the raw FEATURE_FLAGS dict into a ``FeatureFlagSet``. Args: obj: Configuration dictionary strict: Enable strict validation from_attributes: Populate from object attributes context: Additional validation context by_alias: Whether to use field aliases by_name: Whether to use field names extra: Extra fields handling Returns: Validated Config instance """ try: db_section = obj["DB"] db_type = db_section["TYPE"] except KeyError as e: raise ValueError(f"Missing required config key: {e}. DB.TYPE is required.") from e db_cfg_type = get_db_module(db_type).db_config_type() obj["DB"] = db_cfg_type.model_validate(db_section) return super().model_validate( obj, strict=strict, from_attributes=from_attributes, context=context, by_alias=by_alias, by_name=by_name, extra=extra, )
[docs] @classmethod def parse_yaml(cls, path: str) -> "Config": """Parse configuration from YAML file. Args: path: Path to YAML configuration file Returns: Validated Config instance Raises: SystemExit: If config file is not found """ try: with open(path, "r") as f: return cls.model_validate(yaml.safe_load(f)) except FileNotFoundError: print(f"Config file not found: {path}", file=sys.stderr) raise SystemExit(1)