"""Logging configuration for Video Library Manager. This module provides centralized logging configuration with: - Configurable log levels (DEBUG, INFO, WARNING, ERROR) - Dual output: console (INFO+) and file (DEBUG+) - Timestamps, operation type, and file paths in log entries - Log rotation at 10MB threshold """ import logging import logging.handlers from pathlib import Path from typing import Optional DEFAULT_LOG_FILE = "vlm.log" MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB in bytes BACKUP_COUNT = 5 # Keep 5 backup log files def default_log_dir() -> Path: """Return the default log directory resolved at runtime.""" return Path.home() / ".vlm" / "logs" class OperationContextFilter(logging.Filter): """Filter to add operation context to log records.""" def filter(self, record: logging.LogRecord) -> bool: """Add operation_type and file_path attributes if not present.""" if not hasattr(record, 'operation_type'): record.operation_type = 'general' if not hasattr(record, 'file_path'): record.file_path = '' return True def setup_logging( log_level: str = "INFO", log_dir: Optional[Path] = None, log_file: str = DEFAULT_LOG_FILE ) -> logging.Logger: """Configure logging with dual output (console and file) and rotation. Args: log_level: Minimum log level for console output (DEBUG, INFO, WARNING, ERROR) log_dir: Directory for log files (defaults to ~/.vlm/logs) log_file: Name of the log file (defaults to vlm.log) Returns: Configured logger instance Raises: ValueError: If log_level is invalid """ # Validate log level numeric_level = getattr(logging, log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {log_level}") # Use default log directory if not specified if log_dir is None: log_dir = default_log_dir() # Get root logger logger = logging.getLogger("vlm") logger.setLevel(logging.DEBUG) # Capture all levels, handlers will filter # Remove existing handlers to avoid duplicates logger.handlers.clear() # Create formatter with timestamps, operation type, and file paths formatter = logging.Formatter( fmt='%(asctime)s - %(levelname)s - [%(operation_type)s] - %(message)s%(file_path)s', datefmt='%Y-%m-%d %H:%M:%S' ) # Console handler (INFO+) console_handler = logging.StreamHandler() console_handler.setLevel(numeric_level) console_handler.setFormatter(formatter) console_handler.addFilter(OperationContextFilter()) logger.addHandler(console_handler) # File handler with rotation (DEBUG+) try: log_dir.mkdir(parents=True, exist_ok=True) log_file_path = log_dir / log_file file_handler = logging.handlers.RotatingFileHandler( filename=log_file_path, maxBytes=MAX_LOG_SIZE, backupCount=BACKUP_COUNT, encoding='utf-8' ) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) file_handler.addFilter(OperationContextFilter()) logger.addHandler(file_handler) except (OSError, PermissionError) as exc: logger.warning( f"File logging disabled (cannot write to {log_dir}): {exc}" ) # Prevent propagation to root logger logger.propagate = False return logger def log_operation( logger: logging.Logger, level: int, message: str, operation_type: str = "general", file_path: Optional[Path] = None ) -> None: """Log a message with operation context. Args: logger: Logger instance level: Log level (logging.DEBUG, logging.INFO, etc.) message: Log message operation_type: Type of operation (scan, parse, execute, etc.) file_path: Optional file path related to the operation """ extra = { 'operation_type': operation_type, 'file_path': f' - {file_path}' if file_path else '' } logger.log(level, message, extra=extra)