Source code for gemseo.utils.logging

# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
# Contributors:
#    INITIAL AUTHORS - initial API and implementation and/or initial
#                           documentation
#        :author: Antoine Dechaume
#    OTHER AUTHORS   - MACROSCOPIC CHANGES
"""Logging."""

from __future__ import annotations

import types
from dataclasses import dataclass
from logging import WARNING
from logging import FileHandler
from logging import Formatter
from logging import LogRecord
from logging import StreamHandler
from logging import getLogger
from logging import root
from pathlib import Path
from sys import stderr
from sys import stdout
from typing import TYPE_CHECKING
from typing import Final
from typing import TypeVar

from pydantic import BaseModel
from pydantic import Field
from pydantic import model_validator

from gemseo.utils.constants import _LOGGING_DATE_FORMAT
from gemseo.utils.constants import _LOGGING_FILE_MODE
from gemseo.utils.constants import _LOGGING_FILE_PATH
from gemseo.utils.constants import _LOGGING_LEVEL
from gemseo.utils.constants import _LOGGING_MESSAGE_FORMAT

if TYPE_CHECKING:
    from logging import Logger
    from types import TracebackType

    from _typeshed import SupportsWrite
    from typing_extensions import Self

# TODO: API: remove this variable.
DEFAULT_DATE_FORMAT: Final[str] = _LOGGING_DATE_FORMAT
"""The format of the date of the logged message."""

# TODO: API: remove this variable.
DEFAULT_MESSAGE_FORMAT: Final[str] = _LOGGING_MESSAGE_FORMAT
"""The format of the logged message."""

# TODO: API: remove this variable.
GEMSEO_LOGGER: Final[Logger] = getLogger("gemseo")
"""The GEMSEO's logger."""

_StreamT = TypeVar("_StreamT", bound="SupportsWrite[str]")


[docs] class LoggingConfiguration(BaseModel, validate_assignment=True): """The configuration for |g| loggers.""" date_format: str = Field( default=_LOGGING_DATE_FORMAT, description="The logging date format.", ) enable: bool = Field(default=True, description="Whether to enable GEMSEO logging.") file_path: str | Path = Field( default=_LOGGING_FILE_PATH, description="The path to the log file, if outputs must be written in a file.", ) file_mode: str = Field( default=_LOGGING_FILE_MODE, description="""The logging output file mode, either 'w' (overwrite) or 'a' (append).""", ) level: str | int = Field( default=_LOGGING_LEVEL, description="""The numerical value or name of the logging level, as defined in :py:mod:`logging`. Values can either be ``logging.NOTSET`` (``"NOTSET"``), ``logging.DEBUG`` (``"DEBUG"``), ``logging.INFO`` (``"INFO"``), ``logging.WARNING`` (``"WARNING"`` or ``"WARN"``), ``logging.ERROR`` (``"ERROR"``), or ``logging.CRITICAL`` (``"FATAL"`` or ``"CRITICAL"``).""", ) message_format: str = Field( default=_LOGGING_MESSAGE_FORMAT, description="The logging message format." ) @model_validator(mode="after") def __validate(self) -> Self: """Create the logger.""" # Configure the loggers for GEMSEO and its plugins. # Do not configure the loggers for their modules. if self.enable: for name in root.manager.loggerDict: if _is_gemseo_logger(name): _configure_logger( name, self.level, self.message_format, self.date_format, self.file_path, self.file_mode, ) else: for name in root.manager.loggerDict: if _is_gemseo_logger(name): logger = getLogger(name) for handler in logger.handlers[:]: logger.removeHandler(handler) return self
def _is_gemseo_logger(name: str) -> bool: """Check whether a name is the name of the GEMSEO logger or one of its plugins. Args: name: The name. Returns: Whether the name is the name of the GEMSEO logger or one of its plugins. """ return name.startswith("gemseo") and "." not in name def _configure_logger( name: str, level: int | str, message_format: str, date_format: str, file_path: str | Path, file_mode: str, stream: _StreamT | None = None, ) -> Logger: """Configure a logger. Args: name: The name of the logger. level: The logging level. message_format: The logging message format. date_format: The logging date format. file_path: The path to the log file, if outputs must be written in a file. file_mode: The logging file mode. stream: The stream to use for logging, if any. Returns: The configured logger. """ logger = getLogger(name) logger.setLevel(level) formatter = Formatter(fmt=message_format, datefmt=date_format) # Remove all existing handlers. for handler in logger.handlers[:]: logger.removeHandler(handler) stream_handler = MultiLineStreamHandler(stream) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) if file_path: file_handler = MultiLineFileHandler( file_path, mode=file_mode, delay=True, encoding="utf-8" ) file_handler.setFormatter(formatter) logger.addHandler(file_handler) return logger # TODO: API: remove and use gemseo.configuration.logger instead.
[docs] @dataclass class LoggingSettings: """The settings of a logger.""" date_format: str = DEFAULT_DATE_FORMAT """The format of the date of the logged message.""" message_format: str = DEFAULT_MESSAGE_FORMAT """The format of the logged message.""" logger: Logger = GEMSEO_LOGGER """The logger."""
# TODO: API: remove and use gemseo.configuration.logger instead. LOGGING_SETTINGS = LoggingSettings() """The logging settings. The parameters are changed by :func:`.configure_logger`. """
[docs] class MultiLineHandlerMixin: """Stateless mixin class to override logging handlers behavior.""" @staticmethod def __get_raw_record_message(record): """Return the raw message of a log record.""" return record.msg
[docs] def emit(self, record) -> None: """Emit one logging message per input record line.""" # compute the message without the logging prefixes (timestamp, level, ...) message = record.getMessage() # replace getMessage so the message is not computed again when emitting # each line record.getMessage = types.MethodType(self.__get_raw_record_message, record) # backup old raw message old_msg = record.msg for line in message.split("\n"): record.msg = line super().emit(record) # restore genuine getMessage and raw message for other handlers record.getMessage = types.MethodType(LogRecord.getMessage, record) record.msg = old_msg
[docs] class MultiLineStreamHandler(MultiLineHandlerMixin, StreamHandler): """StreamHandler to split multiline logging messages."""
[docs] class MultiLineFileHandler(MultiLineHandlerMixin, FileHandler): """FileHandler to split multiline logging messages."""
[docs] class LoggingContext: """Context manager for selective logging. Change the level of the logger in a ``with`` block. Examples: >>> import logging >>> logger = logging.getLogger() >>> logger.setLevel(logging.INFO) >>> logger.info("This should appear.") >>> with LoggingContext(logger): >>> logger.warning("This should appear.") >>> logger.info("This should not appear.") >>> >>> logger.info("This should appear.") Source: `Logging Cookbook <https://docs.python.org/3/howto/ logging-cookbook.html#using-a-context-manager-for-selective-logging>`_ """ logger: Logger """The logger.""" level: int | None """The level of the logger to be used on block entry. If ``None``, do not change the level of the logger. """ handler: StreamHandler """An additional handler to be used on block entry.""" close: bool """Whether to close the handler on block exit.""" def __init__( self, logger: Logger | None = None, level: int | None = WARNING, handler: StreamHandler | None = None, close: bool = True, ) -> None: """ Args: logger: The logger. level: The level of the logger to be used on block entry. If ``None``, do not change the level of the logger. handler: An additional handler to be used on block entry. close: Whether to close the handler on block exit. """ # noqa:D205 D212 D415 self.logger = getLogger("gemseo") if logger is None else logger self.level = level self.handler = handler self.close = close def __enter__(self) -> None: if self.level is not None: self.old_level = self.logger.level self.logger.setLevel(self.level) if self.handler: self.logger.addHandler(self.handler) def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: if self.level is not None: self.logger.setLevel(self.old_level) if self.handler: self.logger.removeHandler(self.handler) if self.handler and self.close: self.handler.close()
# implicit return of None => don't swallow exceptions
[docs] class OneLineLogging: """A context manager to make the StreamHandlers use only one line. Each record replaces the previous one. """ __handler: StreamHandler | None """The handler used by the context manager if any.""" __terminators: tuple[str] """One line terminator per stream handler.""" __formatters: tuple[Formatter] """One formatter per stream handler.""" __logger: Logger """The logger used by the context manager when the handler is not ``None``.""" def __init__(self, logger: Logger) -> None: """ Args: logger: The logger. """ # noqa: D205 D212 D415 self.__handler = None self.__formatter = () self.__terminator = () while logger: self.__logger = logger stop = False for handler in logger.handlers: if isinstance(handler, StreamHandler) and handler.stream in { stdout, stderr, }: self.__handler = handler self.__terminator = handler.terminator self.__formatter = handler.formatter stop = True if stop: break if not logger.propagate: break logger = logger.parent def __enter__(self) -> None: from gemseo.utils.global_configuration import _configuration if self.__handler is not None: self.__handler.terminator = "" self.__handler.formatter = Formatter( fmt="\r" + _configuration.logging.message_format, datefmt=_configuration.logging.date_format, ) def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: if self.__handler is not None: self.__handler.terminator = self.__terminator self.__handler.formatter = self.__formatter self.__handler.stream.write("\n")