Source code for CHAP.saxswaxs.server.logging_config

"""Logging configuration"""

import contextlib
import logging
import os
import sys
import time as _time
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path


_LOGDIR = None
_LOG_HANDLER = None


[docs] def set_logdir(path): global _LOGDIR, _LOG_HANDLER os.makedirs(path, exist_ok=True) _LOGDIR = path _LOG_HANDLER = None # force re-creation against the new path new_handler = _get_log_handler() for lgr in logging.Logger.manager.loggerDict.values(): if isinstance(lgr, logging.Logger) and not lgr.propagate: lgr.handlers = [new_handler]
_STDOUT_WRAPPER = None _STDERR_WRAPPER = None _TASK_HANDLER = None
[docs] def get_logger(name=__name__, log_level="DEBUG"): global _STDOUT_WRAPPER global _STDERR_WRAPPER logger = logging.getLogger(name) logger.propagate = False logger.setLevel(getattr(logging, log_level.upper())) handler = _get_log_handler() handlers = [handler] if _TASK_HANDLER is not None: handlers.append(_TASK_HANDLER) logger.handlers = handlers # Redirect stdout/stderr once a real file handler is available. if _STDOUT_WRAPPER is None and not isinstance(handler, logging.NullHandler): _STDOUT_WRAPPER = StreamToLogFile(handler) _STDERR_WRAPPER = StreamToLogFile(handler) sys.stdout = _STDOUT_WRAPPER sys.stderr = _STDERR_WRAPPER return logger
[docs] def get_task_logger(name, log_level="DEBUG"): """Return a logger that writes only to the active task log file. Unlike :func:`get_logger`, the returned logger is not attached to the shared application log handler. If no task context is active the logger has no handlers and its output is silently discarded. """ logger = logging.getLogger(name) logger.propagate = False logger.setLevel(getattr(logging, log_level.upper())) logger.handlers = [_TASK_HANDLER] if _TASK_HANDLER is not None else [] return logger
[docs] @contextlib.contextmanager def task_log_context(log_path): """Context manager that tees log output to a per-task append-only file. While active, all loggers created via :func:`get_logger` write to *log_path* in addition to the shared rotating log file, using the same formatter. Raw stdout/stderr writes are also teed to the same file without any additional formatting. The file is always opened in append mode so successive calls accumulate rather than overwrite. """ global _TASK_HANDLER log_path = Path(log_path) log_path.parent.mkdir(parents=True, exist_ok=True) task_handler = logging.FileHandler(str(log_path), mode='a', encoding='utf-8') task_handler.setFormatter(_get_log_handler().formatter) prev_task_handler = _TASK_HANDLER _TASK_HANDLER = task_handler # Add to all loggers that were created by get_logger (propagate=False). pre_existing = [ lgr for lgr in logging.Logger.manager.loggerDict.values() if isinstance(lgr, logging.Logger) and not lgr.propagate ] for lgr in pre_existing: lgr.addHandler(task_handler) # CHAP internal processors use StreamHandler(sys.stderr) loggers. # sys.stderr is _STDERR_WRAPPER (a StreamToLogFile), so their output # reaches the main log via StreamToLogFile.write(). Suppress that path # for the duration of the task so those messages go only to the task log. if _STDERR_WRAPPER is not None: _STDERR_WRAPPER._task_only = True try: yield finally: if _STDERR_WRAPPER is not None: _STDERR_WRAPPER._task_only = False _TASK_HANDLER = prev_task_handler # Remove from every logger that received this handler (pre-existing # and any created inside the context via get_logger). for lgr in logging.Logger.manager.loggerDict.values(): if isinstance(lgr, logging.Logger) and task_handler in lgr.handlers: lgr.handlers = [h for h in lgr.handlers if h is not task_handler] task_handler.close()
[docs] class StreamToLogFile: """Write stdout/stderr directly to the rotating log file.""" def __init__(self, handler): self.handler = handler self._task_only = False
[docs] def write(self, message): if not message: return # Click and some other libraries may write bytes rather than str. if isinstance(message, bytes): message = message.decode("utf-8", errors="replace") if not self._task_only: self.handler.acquire() try: # Rotate if necessary. record = logging.LogRecord( name="stream", level=logging.DEBUG, pathname="", lineno=0, msg="", args=(), exc_info=None, ) if self.handler.shouldRollover(record): self.handler.doRollover() self.handler.stream.write(message) self.handler.stream.flush() finally: self.handler.release() # Tee raw output to the active task log file. if _TASK_HANDLER is not None: _TASK_HANDLER.acquire() try: _TASK_HANDLER.stream.write(message) _TASK_HANDLER.stream.flush() finally: _TASK_HANDLER.release()
[docs] def flush(self): self.handler.acquire() try: if self.handler.stream is not None: self.handler.stream.flush() finally: self.handler.release()
[docs] def isatty(self): return False
@property def encoding(self): return self.handler.encoding def __getattr__(self, name): # Let libraries such as Click access other normal stream # attributes/methods if necessary. return getattr(sys.__stdout__, name)
class _DatestampedRotatingHandler(TimedRotatingFileHandler): """Like :class:`TimedRotatingFileHandler` but the active log file always carries a date stamp (``<base>.YYYY-MM-DD.log``). The standard handler writes to an undated ``<base>`` file and only adds a date suffix to the *rotated-away* copy. This subclass inverts that: every file, including the first one opened on startup, is date-stamped. On rollover the current file is left in place and a new dated file is opened for the incoming day; no renaming takes place. """ def __init__(self, base_path, backup_count=30, encoding='utf-8'): self._log_base = base_path os.makedirs(os.path.dirname(self._log_base), exist_ok=True) super().__init__( filename=self._current_path(), when='midnight', interval=1, backupCount=backup_count, encoding=encoding, ) def _current_path(self): from datetime import date return f"{self._log_base}_{date.today():%Y-%m-%d}.log" def doRollover(self): if self.stream: self.stream.close() self.stream = None if self.backupCount > 0: base_dir = os.path.dirname(os.path.abspath(self._log_base)) base_name = os.path.basename(self._log_base) dated_files = sorted( os.path.join(base_dir, f) for f in os.listdir(base_dir) if f.startswith(base_name + '.') and f.endswith('.log') ) for old in dated_files[:-self.backupCount]: try: os.remove(old) except OSError: pass self.baseFilename = os.path.abspath(self._current_path()) self.stream = self._open() self.rolloverAt = self.computeRollover(int(_time.time())) def _get_log_handler(): global _LOGDIR global _LOG_HANDLER if _LOGDIR is None: return logging.NullHandler() if _LOG_HANDLER is None: log_file = os.path.join( _LOGDIR, "saxswaxs-server", ) _LOG_HANDLER = _DatestampedRotatingHandler( log_file, backup_count=30, encoding="utf-8", ) _LOG_HANDLER.setFormatter( logging.Formatter( "{asctime}: {name:20} (L{lineno}): " "{levelname}: {message}", datefmt="%Y-%m-%d %H:%M:%S", style="{", ) ) return _LOG_HANDLER