ytdl-sub/src/ytdl_sub/utils/logger.py
Jesse Bannon 57fd6901e4
[DEV] Linter: Use ruff over black and isort (#1445)
Will eventually replace pylint with ruff
2026-03-09 11:39:01 -07:00

276 lines
8.4 KiB
Python

import contextlib
import io
import logging
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
from ytdl_sub import __local_version__
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
@dataclass
class LoggerLevel:
name: str
level: int
logging_level: int
class LoggerLevels:
"""
Custom log levels
"""
QUIET = LoggerLevel(name="quiet", level=0, logging_level=logging.WARNING) # Only warnings
INFO = LoggerLevel(name="info", level=10, logging_level=logging.INFO) # ytdl-sub info logs
VERBOSE = LoggerLevel(name="verbose", level=20, logging_level=logging.INFO) # ytdl-sub + yt-dlp
DEBUG = LoggerLevel(
name="debug", level=30, logging_level=logging.DEBUG
) # ytdl-sub + yt-dlp debug logs
@classmethod
def all(cls) -> List[LoggerLevel]:
"""
Returns
-------
All log levels
"""
return [cls.QUIET, cls.INFO, cls.VERBOSE, cls.DEBUG]
@classmethod
def from_str(cls, name: str) -> LoggerLevel:
"""
Parameters
----------
name
The log level name
Raises
------
ValueError
Name is not a valid logger level
"""
for logger_level in cls.all():
if name == logger_level.name:
return logger_level
raise ValueError("Invalid logger level name")
@classmethod
def names(cls) -> List[str]:
"""
Returns
-------
All log level names
"""
return [logger_level.name for logger_level in cls.all()]
class StreamToLogger(io.StringIO):
def __init__(self, logger: logging.Logger, *args, **kwargs):
super().__init__(*args, **kwargs)
self._logger = logger
def write(self, __s: str) -> int:
"""
Writes to the logger and stream
"""
if __s != "\n":
self._logger.info(__s.removesuffix("\n"))
return super().write(__s)
class Logger:
# The level set via CLI arguments
_LOGGER_LEVEL: LoggerLevel = LoggerLevels.DEBUG
# Ignore 'using with' warning since this will be cleaned up later
# pylint: disable=R1732
_DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False)
_ERROR_LOG_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.errors", delete=False)
# pylint: enable=R1732
# Keep track of all Loggers created
_LOGGERS: List[logging.Logger] = []
@classmethod
def debug_log_filename(cls) -> str:
"""
Returns
-------
File name of the debug log file
"""
return cls._DEBUG_LOGGER_FILE.name
@classmethod
def error_log_filename(cls) -> str:
"""
Returns
-------
File name of the error log file
"""
return cls._ERROR_LOG_FILE.name
@classmethod
def set_log_level(cls, log_level_name: str):
"""
Parameters
----------
log_level_name
Name of the log level to set
"""
cls._LOGGER_LEVEL = LoggerLevels.from_str(name=log_level_name)
@classmethod
def _get_formatter(cls) -> logging.Formatter:
"""
Returns
-------
Formatter for all ytdl-sub loggers
"""
return logging.Formatter("[%(name)s] %(message)s")
@classmethod
def _get_stdout_handler(cls) -> logging.StreamHandler:
"""
Returns
-------
Logger handler
"""
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(cls._LOGGER_LEVEL.logging_level)
handler.setFormatter(cls._get_formatter())
return handler
@classmethod
def _get_debug_file_handler(cls) -> logging.FileHandler:
handler = logging.FileHandler(filename=cls.debug_log_filename(), encoding="utf-8")
handler.setLevel(logging.DEBUG)
handler.setFormatter(cls._get_formatter())
return handler
@classmethod
def _get(
cls, name: Optional[str] = None, stdout: bool = True, debug_file: bool = True
) -> logging.Logger:
logger_name = "ytdl-sub"
if name:
logger_name += f":{name}"
logger = logging.Logger(name=logger_name, level=logging.DEBUG)
if stdout:
logger.addHandler(cls._get_stdout_handler())
if debug_file:
logger.addHandler(cls._get_debug_file_handler())
cls._LOGGERS.append(logger)
return logger
@classmethod
def get(cls, name: Optional[str] = None) -> logging.Logger:
"""
Parameters
----------
name
Optional. Name of the logger which is included in the prefix like [ytdl-sub:<name>].
If None, the prefix is just [ytdl-sub]
Returns
-------
A configured logger
"""
return cls._get(name=name, stdout=True, debug_file=True)
@classmethod
@contextlib.contextmanager
def handle_external_logs(cls, name: Optional[str] = None) -> None:
"""
Suppresses all stdout and stderr logs. Intended to suppress other packages logs.
Will always write these logs to the debug logger file.
Parameters
----------
name
Optional. Name of the logger which is included in the prefix like [ytdl-sub:<name>].
If None, the prefix is just [ytdl-sub]mak
"""
logger = cls._get(
name=name, stdout=cls._LOGGER_LEVEL.level >= LoggerLevels.VERBOSE.level, debug_file=True
)
with StreamToLogger(logger=logger) as redirect_stream:
try:
with contextlib.redirect_stdout(new_target=redirect_stream):
with contextlib.redirect_stderr(new_target=redirect_stream):
yield
finally:
redirect_stream.flush()
@classmethod
def _append_to_error_log(cls):
# Any time an exception occurs, dump all debug logs into the error log
with (
open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs,
open(cls.error_log_filename(), mode="a", encoding="utf-8") as error_logs,
):
error_logs.writelines(debug_logs.readlines())
@classmethod
def log_exception(cls, exception: Exception, log_filepath: Optional[Path] = None):
"""
Logs an exception based on the exception type. Will transfer all
debug logs into the error log file. This allows for subscriptions to only write to the
error log if an error occurred - successful subscriptions will clean the debug log file
w/out any write to the error log.
Parameters
----------
exception
The exception to log
log_filepath
Optional. The filepath to the debug logs
"""
logger = cls.get()
# Log validation exceptions as-is
if isinstance(exception, ValidationException):
logger.error(str(exception))
# Log permission errors explicitly
elif isinstance(exception, PermissionError):
logger.error(
"A permission error occurred:\n%s\n"
"The user running ytdl-sub must have permission to this file/directory.",
str(exception),
)
# For other uncaught errors, log as bug:
else:
logger.exception("An uncaught error occurred:")
logger.error(
"Version %s\nPlease upload the error log file '%s' and make a Github "
"issue at https://github.com/jmbannon/ytdl-sub/issues with your config and "
"command/subscription yaml file to reproduce. Thanks for trying ytdl-sub!",
__local_version__,
log_filepath if log_filepath else Logger.error_log_filename(),
)
cls._append_to_error_log()
@classmethod
def cleanup(cls, has_error: bool = False):
"""
Cleans up debug log file left behind
"""
for logger in cls._LOGGERS:
for handler in logger.handlers:
handler.close()
if has_error:
cls._append_to_error_log()
else:
cls._ERROR_LOG_FILE.close()
FileHandler.delete(cls.error_log_filename())
cls._DEBUG_LOGGER_FILE.close()
FileHandler.delete(cls.debug_log_filename())